refactor(forms): move packages/form-schema → apps/app/src/composables/forms
Inlines the form-schema source folder (no package.json, alias-only) into apps/app/src/composables/forms. Drops the @form-schema alias from apps/app/vite.config.ts (replaced by @/composables/forms via the existing @ alias). apps/portal vite + vitest configs keep @form-schema as a temporary alias pointing at the new location so portal tests/build keep working until apps/portal is removed at the end of this PR. Two pure-logic form-schema tests moved alongside. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,188 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import { formatFieldValue } from '@/composables/forms/composables/formatFieldValue'
|
||||
import { FormFieldType } from '@/composables/forms/types/formBuilder'
|
||||
import type {
|
||||
AvailableTag,
|
||||
PublicFormField,
|
||||
PublicFormSectionOption,
|
||||
PublicFormTimeSlot,
|
||||
} from '@/composables/forms/types/formBuilder'
|
||||
|
||||
function field(partial: Partial<PublicFormField> = {}): PublicFormField {
|
||||
return {
|
||||
id: partial.id ?? 'f',
|
||||
slug: partial.slug ?? 'slug',
|
||||
field_type: partial.field_type ?? FormFieldType.TEXT,
|
||||
label: partial.label ?? 'Label',
|
||||
help_text: partial.help_text ?? null,
|
||||
options: partial.options ?? null,
|
||||
available_tags: partial.available_tags ?? null,
|
||||
validation_rules: partial.validation_rules ?? null,
|
||||
is_required: partial.is_required ?? false,
|
||||
display_width: partial.display_width ?? 'full',
|
||||
conditional_logic: partial.conditional_logic ?? null,
|
||||
sort_order: partial.sort_order ?? 0,
|
||||
form_schema_section_id: partial.form_schema_section_id ?? null,
|
||||
}
|
||||
}
|
||||
|
||||
function tag(partial: Partial<AvailableTag>): AvailableTag {
|
||||
return { id: partial.id ?? 't_1', name: partial.name ?? 'Tag', category: partial.category ?? '' }
|
||||
}
|
||||
|
||||
function timeSlot(partial: Partial<PublicFormTimeSlot>): PublicFormTimeSlot {
|
||||
return {
|
||||
id: partial.id ?? 'ts_1',
|
||||
name: partial.name ?? 'Vrijdag avond',
|
||||
date: partial.date ?? '2026-07-10',
|
||||
start_time: partial.start_time ?? '18:00:00',
|
||||
end_time: partial.end_time ?? '23:00:00',
|
||||
duration_hours: partial.duration_hours ?? 5,
|
||||
event_id: partial.event_id ?? 'evt_1',
|
||||
event_name: partial.event_name ?? 'Event',
|
||||
}
|
||||
}
|
||||
|
||||
function section(partial: Partial<PublicFormSectionOption>): PublicFormSectionOption {
|
||||
return {
|
||||
id: partial.id ?? 's_1',
|
||||
name: partial.name ?? 'Section',
|
||||
category: partial.category ?? null,
|
||||
icon: partial.icon ?? null,
|
||||
registration_description: partial.registration_description ?? null,
|
||||
}
|
||||
}
|
||||
|
||||
describe('formatFieldValue', () => {
|
||||
describe('empty values', () => {
|
||||
it.each<[unknown]>([
|
||||
[null],
|
||||
[undefined],
|
||||
[''],
|
||||
[[]],
|
||||
])('returns "—" for empty value %s on any field type', v => {
|
||||
expect(formatFieldValue(field({ field_type: FormFieldType.TEXT }), v, [], [])).toBe('—')
|
||||
})
|
||||
})
|
||||
|
||||
describe('TAG_PICKER', () => {
|
||||
it('maps IDs to tag names using field.available_tags', () => {
|
||||
const f = field({
|
||||
field_type: FormFieldType.TAG_PICKER,
|
||||
available_tags: [tag({ id: 'a', name: 'Tapper' }), tag({ id: 'b', name: 'Barista' })],
|
||||
})
|
||||
|
||||
expect(formatFieldValue(f, ['a', 'b'], undefined, undefined)).toBe('Tapper, Barista')
|
||||
})
|
||||
|
||||
it('labels unknown tag IDs as "(onbekende tag)"', () => {
|
||||
const f = field({
|
||||
field_type: FormFieldType.TAG_PICKER,
|
||||
available_tags: [tag({ id: 'a', name: 'Tapper' })],
|
||||
})
|
||||
|
||||
expect(formatFieldValue(f, ['a', 'zzz'], undefined, undefined)).toBe('Tapper, (onbekende tag)')
|
||||
})
|
||||
})
|
||||
|
||||
describe('AVAILABILITY_PICKER', () => {
|
||||
it('maps IDs to "name (start–end)" with seconds stripped', () => {
|
||||
const f = field({ field_type: FormFieldType.AVAILABILITY_PICKER })
|
||||
const slots = [timeSlot({ id: 'x', name: 'Vrijwilligers afbraak zondag', start_time: '10:00:00', end_time: '16:00:00' })]
|
||||
|
||||
expect(formatFieldValue(f, ['x'], slots, undefined))
|
||||
.toBe('Vrijwilligers afbraak zondag (10:00–16:00)')
|
||||
})
|
||||
|
||||
it('labels unknown time_slot IDs as "(onbekend tijdslot)"', () => {
|
||||
const f = field({ field_type: FormFieldType.AVAILABILITY_PICKER })
|
||||
const slots = [timeSlot({ id: 'x' })]
|
||||
|
||||
expect(formatFieldValue(f, ['x', 'y'], slots, undefined))
|
||||
.toBe('Vrijdag avond (18:00–23:00), (onbekend tijdslot)')
|
||||
})
|
||||
|
||||
it('returns "Laden…" while the time-slots query is still fetching', () => {
|
||||
const f = field({ field_type: FormFieldType.AVAILABILITY_PICKER })
|
||||
|
||||
expect(formatFieldValue(f, ['x'], undefined, undefined)).toBe('Laden…')
|
||||
})
|
||||
})
|
||||
|
||||
describe('SECTION_PRIORITY', () => {
|
||||
it('renders "N. Name, …" sorted by priority', () => {
|
||||
const f = field({ field_type: FormFieldType.SECTION_PRIORITY })
|
||||
const sections = [
|
||||
section({ id: 's_1', name: 'Hoofdpodium Bar' }),
|
||||
section({ id: 's_2', name: 'Theatertent Bar' }),
|
||||
]
|
||||
|
||||
expect(formatFieldValue(
|
||||
f,
|
||||
[{ section_id: 's_1', priority: 1 }, { section_id: 's_2', priority: 2 }],
|
||||
undefined,
|
||||
sections,
|
||||
)).toBe('1. Hoofdpodium Bar, 2. Theatertent Bar')
|
||||
})
|
||||
|
||||
it('re-sorts unordered input by priority before rendering', () => {
|
||||
const f = field({ field_type: FormFieldType.SECTION_PRIORITY })
|
||||
const sections = [section({ id: 's_1', name: 'A' }), section({ id: 's_2', name: 'B' })]
|
||||
|
||||
expect(formatFieldValue(
|
||||
f,
|
||||
[{ section_id: 's_2', priority: 2 }, { section_id: 's_1', priority: 1 }],
|
||||
undefined,
|
||||
sections,
|
||||
)).toBe('1. A, 2. B')
|
||||
})
|
||||
|
||||
it('labels unknown section IDs as "(onbekende sectie)"', () => {
|
||||
const f = field({ field_type: FormFieldType.SECTION_PRIORITY })
|
||||
const sections = [section({ id: 's_1', name: 'Known' })]
|
||||
|
||||
expect(formatFieldValue(
|
||||
f,
|
||||
[{ section_id: 's_1', priority: 1 }, { section_id: 'missing', priority: 2 }],
|
||||
undefined,
|
||||
sections,
|
||||
)).toBe('1. Known, 2. (onbekende sectie)')
|
||||
})
|
||||
|
||||
it('returns "—" when the value shape is malformed (defensive guard)', () => {
|
||||
const f = field({ field_type: FormFieldType.SECTION_PRIORITY })
|
||||
|
||||
// Flat string[] — wrong shape, must not leak [object Object].
|
||||
expect(formatFieldValue(f, ['s_1', 's_2'], undefined, [section({ id: 's_1', name: 'Known' })])).toBe('—')
|
||||
})
|
||||
|
||||
it('returns "Laden…" while the sections query is still fetching', () => {
|
||||
const f = field({ field_type: FormFieldType.SECTION_PRIORITY })
|
||||
|
||||
expect(formatFieldValue(
|
||||
f,
|
||||
[{ section_id: 's_1', priority: 1 }],
|
||||
undefined,
|
||||
undefined,
|
||||
)).toBe('Laden…')
|
||||
})
|
||||
})
|
||||
|
||||
describe('scalars', () => {
|
||||
it('formats BOOLEAN true as "Ja", false as "Nee"', () => {
|
||||
const f = field({ field_type: FormFieldType.BOOLEAN })
|
||||
expect(formatFieldValue(f, true, undefined, undefined)).toBe('Ja')
|
||||
expect(formatFieldValue(f, false, undefined, undefined)).toBe('Nee')
|
||||
})
|
||||
|
||||
it('stringifies unknown-type array values via join', () => {
|
||||
const f = field({ field_type: FormFieldType.MULTISELECT })
|
||||
expect(formatFieldValue(f, ['A', 'B'], undefined, undefined)).toBe('A, B')
|
||||
})
|
||||
|
||||
it('stringifies scalars for text-like types', () => {
|
||||
const f = field({ field_type: FormFieldType.TEXT })
|
||||
expect(formatFieldValue(f, 'hello', undefined, undefined)).toBe('hello')
|
||||
})
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,94 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import { evaluateConditionalLogic } from '@/composables/forms/composables/useConditionalLogic'
|
||||
import type { ConditionalLogic } from '@/composables/forms/types/formBuilder'
|
||||
|
||||
describe('evaluateConditionalLogic', () => {
|
||||
it('returns true when logic is null or undefined', () => {
|
||||
expect(evaluateConditionalLogic(null, {})).toBe(true)
|
||||
expect(evaluateConditionalLogic(undefined, {})).toBe(true)
|
||||
expect(evaluateConditionalLogic({}, {})).toBe(true)
|
||||
})
|
||||
|
||||
it('handles simple equals / not_equals', () => {
|
||||
const logic: ConditionalLogic = {
|
||||
show_when: { all: [{ field_slug: 'foo', operator: 'equals', value: 'bar' }] },
|
||||
}
|
||||
expect(evaluateConditionalLogic(logic, { foo: 'bar' })).toBe(true)
|
||||
expect(evaluateConditionalLogic(logic, { foo: 'baz' })).toBe(false)
|
||||
|
||||
const negative: ConditionalLogic = {
|
||||
show_when: { all: [{ field_slug: 'foo', operator: 'not_equals', value: 'bar' }] },
|
||||
}
|
||||
expect(evaluateConditionalLogic(negative, { foo: 'baz' })).toBe(true)
|
||||
expect(evaluateConditionalLogic(negative, { foo: 'bar' })).toBe(false)
|
||||
})
|
||||
|
||||
it('handles in / not_in over single and array values', () => {
|
||||
const logic: ConditionalLogic = {
|
||||
show_when: { all: [{ field_slug: 'tags', operator: 'in', value: ['a', 'b'] }] },
|
||||
}
|
||||
expect(evaluateConditionalLogic(logic, { tags: 'a' })).toBe(true)
|
||||
expect(evaluateConditionalLogic(logic, { tags: 'c' })).toBe(false)
|
||||
expect(evaluateConditionalLogic(logic, { tags: ['c', 'a'] })).toBe(true)
|
||||
|
||||
const negative: ConditionalLogic = {
|
||||
show_when: { all: [{ field_slug: 'tags', operator: 'not_in', value: ['a'] }] },
|
||||
}
|
||||
expect(evaluateConditionalLogic(negative, { tags: 'a' })).toBe(false)
|
||||
expect(evaluateConditionalLogic(negative, { tags: ['b', 'c'] })).toBe(true)
|
||||
})
|
||||
|
||||
it('handles empty / not_empty across string, array, null', () => {
|
||||
const empty: ConditionalLogic = { show_when: { all: [{ field_slug: 'x', operator: 'empty' }] } }
|
||||
expect(evaluateConditionalLogic(empty, {})).toBe(true)
|
||||
expect(evaluateConditionalLogic(empty, { x: null })).toBe(true)
|
||||
expect(evaluateConditionalLogic(empty, { x: '' })).toBe(true)
|
||||
expect(evaluateConditionalLogic(empty, { x: [] })).toBe(true)
|
||||
expect(evaluateConditionalLogic(empty, { x: 'hi' })).toBe(false)
|
||||
|
||||
const notEmpty: ConditionalLogic = { show_when: { all: [{ field_slug: 'x', operator: 'not_empty' }] } }
|
||||
expect(evaluateConditionalLogic(notEmpty, { x: 'hi' })).toBe(true)
|
||||
expect(evaluateConditionalLogic(notEmpty, {})).toBe(false)
|
||||
})
|
||||
|
||||
it('evaluates nested all/any groups', () => {
|
||||
const logic: ConditionalLogic = {
|
||||
show_when: {
|
||||
all: [
|
||||
{ field_slug: 'role', operator: 'equals', value: 'admin' },
|
||||
{
|
||||
any: [
|
||||
{ field_slug: 'region', operator: 'equals', value: 'NL' },
|
||||
{ field_slug: 'region', operator: 'equals', value: 'BE' },
|
||||
],
|
||||
},
|
||||
],
|
||||
},
|
||||
}
|
||||
expect(evaluateConditionalLogic(logic, { role: 'admin', region: 'BE' })).toBe(true)
|
||||
expect(evaluateConditionalLogic(logic, { role: 'admin', region: 'DE' })).toBe(false)
|
||||
expect(evaluateConditionalLogic(logic, { role: 'guest', region: 'NL' })).toBe(false)
|
||||
})
|
||||
|
||||
it('does not throw on missing field values', () => {
|
||||
const eq: ConditionalLogic = {
|
||||
show_when: { all: [{ field_slug: 'missing', operator: 'equals', value: 'x' }] },
|
||||
}
|
||||
expect(() => evaluateConditionalLogic(eq, {})).not.toThrow()
|
||||
expect(evaluateConditionalLogic(eq, {})).toBe(false)
|
||||
|
||||
const emptyCheck: ConditionalLogic = {
|
||||
show_when: { all: [{ field_slug: 'missing', operator: 'empty' }] },
|
||||
}
|
||||
expect(evaluateConditionalLogic(emptyCheck, {})).toBe(true)
|
||||
})
|
||||
|
||||
it('handles greater_than / less_than with non-numeric fallback', () => {
|
||||
const gt: ConditionalLogic = {
|
||||
show_when: { all: [{ field_slug: 'age', operator: 'greater_than', value: 18 }] },
|
||||
}
|
||||
expect(evaluateConditionalLogic(gt, { age: 20 })).toBe(true)
|
||||
expect(evaluateConditionalLogic(gt, { age: 17 })).toBe(false)
|
||||
expect(evaluateConditionalLogic(gt, { age: 'not-a-number' })).toBe(false)
|
||||
})
|
||||
})
|
||||
137
apps/app/src/composables/forms/composables/formatFieldValue.ts
Normal file
137
apps/app/src/composables/forms/composables/formatFieldValue.ts
Normal file
@@ -0,0 +1,137 @@
|
||||
import { FormFieldType } from '../types/formBuilder'
|
||||
import type {
|
||||
PublicFormField,
|
||||
PublicFormSectionOption,
|
||||
PublicFormTimeSlot,
|
||||
SectionPriorityValue,
|
||||
} from '../types/formBuilder'
|
||||
|
||||
const EMPTY = '—'
|
||||
const LOADING = 'Laden…'
|
||||
const UNKNOWN_TAG = '(onbekende tag)'
|
||||
const UNKNOWN_TIME_SLOT = '(onbekend tijdslot)'
|
||||
const UNKNOWN_SECTION = '(onbekende sectie)'
|
||||
|
||||
// Single source of truth for how a submitted value is rendered on the
|
||||
// review step and the post-submit confirmation page. Shared so the
|
||||
// stringified-id / [object Object] bugs fixed in S3a PR 2.2 can't
|
||||
// regress via a naive caller.
|
||||
//
|
||||
// `timeSlots` / `sections` are intentionally accepted as raw arrays (or
|
||||
// undefined when the underlying TanStack Query is still fetching).
|
||||
// Callers pass the cached `.data.value` from usePublicFormTimeSlots /
|
||||
// usePublicFormSections; this keeps the formatter side-effect-free and
|
||||
// trivial to unit-test.
|
||||
export function formatFieldValue(
|
||||
field: PublicFormField,
|
||||
value: unknown,
|
||||
timeSlots: readonly PublicFormTimeSlot[] | undefined,
|
||||
sections: readonly PublicFormSectionOption[] | undefined,
|
||||
): string {
|
||||
if (isEmptyValue(value)) return EMPTY
|
||||
|
||||
switch (field.field_type) {
|
||||
case FormFieldType.TAG_PICKER:
|
||||
return formatTagPicker(field, value)
|
||||
case FormFieldType.AVAILABILITY_PICKER:
|
||||
return formatAvailabilityPicker(value, timeSlots)
|
||||
case FormFieldType.SECTION_PRIORITY:
|
||||
return formatSectionPriority(value, sections)
|
||||
case FormFieldType.BOOLEAN:
|
||||
return value ? 'Ja' : 'Nee'
|
||||
default:
|
||||
return formatScalarOrList(value)
|
||||
}
|
||||
}
|
||||
|
||||
function isEmptyValue(value: unknown): boolean {
|
||||
if (value === null || value === undefined || value === '') return true
|
||||
if (Array.isArray(value) && value.length === 0) return true
|
||||
|
||||
return false
|
||||
}
|
||||
|
||||
function formatTagPicker(field: PublicFormField, value: unknown): string {
|
||||
if (!Array.isArray(value)) return EMPTY
|
||||
|
||||
const byId = new Map<string, string>()
|
||||
for (const tag of field.available_tags ?? []) byId.set(tag.id, tag.name)
|
||||
|
||||
const parts = value
|
||||
.map(v => (typeof v === 'string' ? v : String(v)))
|
||||
.map(id => byId.get(id) ?? UNKNOWN_TAG)
|
||||
|
||||
return parts.length > 0 ? parts.join(', ') : EMPTY
|
||||
}
|
||||
|
||||
function formatAvailabilityPicker(
|
||||
value: unknown,
|
||||
timeSlots: readonly PublicFormTimeSlot[] | undefined,
|
||||
): string {
|
||||
if (!Array.isArray(value)) return EMPTY
|
||||
if (timeSlots === undefined) return LOADING
|
||||
|
||||
const byId = new Map<string, PublicFormTimeSlot>()
|
||||
for (const slot of timeSlots) byId.set(slot.id, slot)
|
||||
|
||||
const parts = value
|
||||
.map(v => (typeof v === 'string' ? v : String(v)))
|
||||
.map(id => {
|
||||
const slot = byId.get(id)
|
||||
if (!slot) return UNKNOWN_TIME_SLOT
|
||||
|
||||
return `${slot.name} (${stripSeconds(slot.start_time)}–${stripSeconds(slot.end_time)})`
|
||||
})
|
||||
|
||||
return parts.length > 0 ? parts.join(', ') : EMPTY
|
||||
}
|
||||
|
||||
function formatSectionPriority(
|
||||
value: unknown,
|
||||
sections: readonly PublicFormSectionOption[] | undefined,
|
||||
): string {
|
||||
// Defensive shape-guard: if the value isn't {section_id, priority}[],
|
||||
// fall back to EMPTY rather than leaking `[object Object]`.
|
||||
if (!Array.isArray(value)) return EMPTY
|
||||
|
||||
const entries: SectionPriorityValue[] = []
|
||||
for (const entry of value) {
|
||||
if (!entry || typeof entry !== 'object') return EMPTY
|
||||
const obj = entry as Record<string, unknown>
|
||||
if (typeof obj.section_id !== 'string' || typeof obj.priority !== 'number') {
|
||||
return EMPTY
|
||||
}
|
||||
entries.push({ section_id: obj.section_id, priority: obj.priority })
|
||||
}
|
||||
if (entries.length === 0) return EMPTY
|
||||
if (sections === undefined) return LOADING
|
||||
|
||||
const byId = new Map<string, PublicFormSectionOption>()
|
||||
for (const section of sections) byId.set(section.id, section)
|
||||
|
||||
// Input may be out of order; the review/confirmation copy is "1. Foo,
|
||||
// 2. Bar" so sort by priority ascending before rendering.
|
||||
const sorted = [...entries].sort((a, b) => a.priority - b.priority)
|
||||
|
||||
return sorted
|
||||
.map(({ section_id, priority }) => {
|
||||
const name = byId.get(section_id)?.name ?? UNKNOWN_SECTION
|
||||
|
||||
return `${priority}. ${name}`
|
||||
})
|
||||
.join(', ')
|
||||
}
|
||||
|
||||
function formatScalarOrList(value: unknown): string {
|
||||
if (Array.isArray(value)) {
|
||||
return value.length > 0 ? value.map(v => String(v)).join(', ') : EMPTY
|
||||
}
|
||||
|
||||
return String(value)
|
||||
}
|
||||
|
||||
function stripSeconds(t: string): string {
|
||||
const parts = t.split(':')
|
||||
|
||||
return parts.length >= 2 ? `${parts[0]}:${parts[1]}` : t
|
||||
}
|
||||
@@ -0,0 +1,127 @@
|
||||
import type {
|
||||
ConditionalGroup,
|
||||
ConditionalLogic,
|
||||
ConditionalOperator,
|
||||
ConditionalRule,
|
||||
FormValues,
|
||||
} from '../types/formBuilder'
|
||||
|
||||
function isEmptyValue(v: unknown): boolean {
|
||||
if (v === null || v === undefined) return true
|
||||
if (typeof v === 'string') return v.length === 0
|
||||
if (Array.isArray(v)) return v.length === 0
|
||||
|
||||
return false
|
||||
}
|
||||
|
||||
function toComparable(v: unknown): string | number | boolean {
|
||||
if (typeof v === 'string' || typeof v === 'number' || typeof v === 'boolean') return v
|
||||
|
||||
return String(v ?? '')
|
||||
}
|
||||
|
||||
function evaluateRule(rule: ConditionalRule, values: FormValues): boolean {
|
||||
const op = rule.operator as ConditionalOperator
|
||||
const actual = values[rule.field_slug]
|
||||
const expected = rule.value
|
||||
|
||||
switch (op) {
|
||||
case 'empty':
|
||||
return isEmptyValue(actual)
|
||||
case 'not_empty':
|
||||
return !isEmptyValue(actual)
|
||||
case 'equals':
|
||||
if (isEmptyValue(actual) && !isEmptyValue(expected)) return false
|
||||
|
||||
return toComparable(actual) === toComparable(expected)
|
||||
case 'not_equals':
|
||||
if (isEmptyValue(actual) && !isEmptyValue(expected)) return true
|
||||
|
||||
return toComparable(actual) !== toComparable(expected)
|
||||
case 'contains': {
|
||||
if (isEmptyValue(actual)) return false
|
||||
if (Array.isArray(actual)) return actual.map(toComparable).includes(toComparable(expected))
|
||||
|
||||
return String(actual).includes(String(expected ?? ''))
|
||||
}
|
||||
case 'not_contains': {
|
||||
if (isEmptyValue(actual)) return true
|
||||
if (Array.isArray(actual)) return !actual.map(toComparable).includes(toComparable(expected))
|
||||
|
||||
return !String(actual).includes(String(expected ?? ''))
|
||||
}
|
||||
case 'in': {
|
||||
if (isEmptyValue(actual)) return false
|
||||
if (!Array.isArray(expected)) return false
|
||||
const exp = expected.map(toComparable)
|
||||
if (Array.isArray(actual)) return actual.some(a => exp.includes(toComparable(a)))
|
||||
|
||||
return exp.includes(toComparable(actual))
|
||||
}
|
||||
case 'not_in': {
|
||||
if (isEmptyValue(actual)) return true
|
||||
if (!Array.isArray(expected)) return true
|
||||
const exp = expected.map(toComparable)
|
||||
if (Array.isArray(actual)) return !actual.some(a => exp.includes(toComparable(a)))
|
||||
|
||||
return !exp.includes(toComparable(actual))
|
||||
}
|
||||
case 'greater_than': {
|
||||
if (isEmptyValue(actual)) return false
|
||||
const a = Number(actual)
|
||||
const e = Number(expected)
|
||||
if (Number.isNaN(a) || Number.isNaN(e)) return false
|
||||
|
||||
return a > e
|
||||
}
|
||||
case 'less_than': {
|
||||
if (isEmptyValue(actual)) return false
|
||||
const a = Number(actual)
|
||||
const e = Number(expected)
|
||||
if (Number.isNaN(a) || Number.isNaN(e)) return false
|
||||
|
||||
return a < e
|
||||
}
|
||||
default:
|
||||
return true
|
||||
}
|
||||
}
|
||||
|
||||
function isGroup(node: ConditionalRule | ConditionalGroup): node is ConditionalGroup {
|
||||
return typeof node === 'object' && node !== null && (('all' in node) || ('any' in node))
|
||||
}
|
||||
|
||||
function evaluateGroup(group: ConditionalGroup, values: FormValues): boolean {
|
||||
if (Array.isArray(group.all) && group.all.length > 0) {
|
||||
for (const node of group.all) {
|
||||
const ok = isGroup(node) ? evaluateGroup(node, values) : evaluateRule(node, values)
|
||||
if (!ok) return false
|
||||
}
|
||||
|
||||
return true
|
||||
}
|
||||
if (Array.isArray(group.any) && group.any.length > 0) {
|
||||
for (const node of group.any) {
|
||||
const ok = isGroup(node) ? evaluateGroup(node, values) : evaluateRule(node, values)
|
||||
if (ok) return true
|
||||
}
|
||||
|
||||
return false
|
||||
}
|
||||
|
||||
return true
|
||||
}
|
||||
|
||||
/**
|
||||
* Evaluate a conditional logic block against the current form values.
|
||||
* Returns true when the field/group should be visible; defaults to true
|
||||
* when the logic is absent or malformed.
|
||||
*/
|
||||
export function evaluateConditionalLogic(
|
||||
logic: ConditionalLogic | null | undefined,
|
||||
values: FormValues,
|
||||
): boolean {
|
||||
if (!logic || !logic.show_when) return true
|
||||
|
||||
return evaluateGroup(logic.show_when, values)
|
||||
}
|
||||
138
apps/app/src/composables/forms/composables/useFormSteps.ts
Normal file
138
apps/app/src/composables/forms/composables/useFormSteps.ts
Normal file
@@ -0,0 +1,138 @@
|
||||
import { computed } from 'vue'
|
||||
import type { ComputedRef, Ref } from 'vue'
|
||||
import { evaluateConditionalLogic } from './useConditionalLogic'
|
||||
import { FormFieldType } from '../types/formBuilder'
|
||||
import type { FormValues, PublicFormField, PublicFormSchema } from '../types/formBuilder'
|
||||
import { isFieldValueEmpty } from '../utils/formValidation'
|
||||
|
||||
export type StepKind = 'submitter' | 'section' | 'heading_group' | 'flat' | 'review'
|
||||
|
||||
export interface FormStep {
|
||||
key: string
|
||||
kind: StepKind
|
||||
title: string
|
||||
subtitle?: string
|
||||
fields: PublicFormField[]
|
||||
}
|
||||
|
||||
function partitionByHeading(fields: PublicFormField[]): FormStep[] {
|
||||
if (fields.length === 0) return []
|
||||
|
||||
const out: FormStep[] = []
|
||||
let current: FormStep | null = null
|
||||
let index = 0
|
||||
|
||||
for (const field of fields) {
|
||||
if (field.field_type === FormFieldType.HEADING) {
|
||||
current = {
|
||||
key: `heading-${field.id}`,
|
||||
kind: 'heading_group',
|
||||
title: field.label,
|
||||
fields: [field],
|
||||
}
|
||||
out.push(current)
|
||||
index++
|
||||
continue
|
||||
}
|
||||
if (!current) {
|
||||
current = {
|
||||
key: `group-${index}`,
|
||||
kind: 'heading_group',
|
||||
title: 'Vragen',
|
||||
fields: [],
|
||||
}
|
||||
out.push(current)
|
||||
index++
|
||||
}
|
||||
current.fields.push(field)
|
||||
}
|
||||
|
||||
return out
|
||||
}
|
||||
|
||||
export function useFormSteps(schema: Ref<PublicFormSchema | null | undefined>): ComputedRef<FormStep[]> {
|
||||
return computed<FormStep[]>(() => {
|
||||
const s = schema.value
|
||||
const submitterStep: FormStep = {
|
||||
key: 'submitter',
|
||||
kind: 'submitter',
|
||||
title: 'Contactgegevens',
|
||||
subtitle: 'Zo kunnen we contact met je opnemen',
|
||||
fields: [],
|
||||
}
|
||||
const reviewStep: FormStep = {
|
||||
key: 'review',
|
||||
kind: 'review',
|
||||
title: 'Controleer en versturen',
|
||||
subtitle: 'Check je antwoorden en verstuur het formulier',
|
||||
fields: [],
|
||||
}
|
||||
|
||||
if (!s) return [submitterStep, reviewStep]
|
||||
|
||||
const sorted = [...s.fields].sort((a, b) => a.sort_order - b.sort_order)
|
||||
|
||||
const steps: FormStep[] = [submitterStep]
|
||||
|
||||
if (s.sections.length > 0 && s.section_level_submit === false) {
|
||||
const sectionsSorted = [...s.sections].sort((a, b) => a.sort_order - b.sort_order)
|
||||
for (const section of sectionsSorted) {
|
||||
const fields = sorted.filter(f => f.form_schema_section_id === section.id)
|
||||
steps.push({
|
||||
key: `section-${section.id}`,
|
||||
kind: 'section',
|
||||
title: section.name,
|
||||
subtitle: section.description ?? undefined,
|
||||
fields,
|
||||
})
|
||||
}
|
||||
const loose = sorted.filter(f => f.form_schema_section_id === null)
|
||||
if (loose.length > 0) {
|
||||
steps.push({
|
||||
key: 'section-loose',
|
||||
kind: 'section',
|
||||
title: 'Overig',
|
||||
fields: loose,
|
||||
})
|
||||
}
|
||||
}
|
||||
else if (sorted.some(f => f.field_type === FormFieldType.HEADING)) {
|
||||
steps.push(...partitionByHeading(sorted))
|
||||
}
|
||||
else {
|
||||
steps.push({
|
||||
key: 'all-fields',
|
||||
kind: 'flat',
|
||||
title: 'Vragen',
|
||||
fields: sorted,
|
||||
})
|
||||
}
|
||||
|
||||
steps.push(reviewStep)
|
||||
|
||||
return steps
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns true when all visible required fields in `step` have a
|
||||
* non-empty value. Hidden fields (failing conditional logic) are
|
||||
* skipped. HEADING/PARAGRAPH fields carry no value and are skipped.
|
||||
*/
|
||||
export function isStepValid(
|
||||
step: FormStep,
|
||||
values: FormValues,
|
||||
submitterValid: boolean,
|
||||
): boolean {
|
||||
if (step.kind === 'submitter') return submitterValid
|
||||
if (step.kind === 'review') return true
|
||||
|
||||
for (const field of step.fields) {
|
||||
if (field.field_type === FormFieldType.HEADING || field.field_type === FormFieldType.PARAGRAPH) continue
|
||||
if (!field.is_required) continue
|
||||
if (!evaluateConditionalLogic(field.conditional_logic, values)) continue
|
||||
if (isFieldValueEmpty(values[field.slug])) return false
|
||||
}
|
||||
|
||||
return true
|
||||
}
|
||||
242
apps/app/src/composables/forms/types/formBuilder.ts
Normal file
242
apps/app/src/composables/forms/types/formBuilder.ts
Normal file
@@ -0,0 +1,242 @@
|
||||
// Mirrors backend form builder enums and public resources.
|
||||
// Source of truth: api/app/Enums/FormBuilder/* and
|
||||
// api/app/Http/Resources/FormBuilder/PublicForm(Schema|Submission)Resource.php
|
||||
|
||||
export const FormFieldType = {
|
||||
TEXT: 'TEXT',
|
||||
TEXTAREA: 'TEXTAREA',
|
||||
EMAIL: 'EMAIL',
|
||||
PHONE: 'PHONE',
|
||||
NUMBER: 'NUMBER',
|
||||
DATE: 'DATE',
|
||||
DATETIME: 'DATETIME',
|
||||
BOOLEAN: 'BOOLEAN',
|
||||
RADIO: 'RADIO',
|
||||
SELECT: 'SELECT',
|
||||
MULTISELECT: 'MULTISELECT',
|
||||
CHECKBOX_LIST: 'CHECKBOX_LIST',
|
||||
FILE_UPLOAD: 'FILE_UPLOAD',
|
||||
IMAGE_UPLOAD: 'IMAGE_UPLOAD',
|
||||
SIGNATURE: 'SIGNATURE',
|
||||
TAG_PICKER: 'TAG_PICKER',
|
||||
HEADING: 'HEADING',
|
||||
PARAGRAPH: 'PARAGRAPH',
|
||||
URL: 'URL',
|
||||
SECTION_PRIORITY: 'SECTION_PRIORITY',
|
||||
AVAILABILITY_PICKER: 'AVAILABILITY_PICKER',
|
||||
TABLE_ROWS: 'TABLE_ROWS',
|
||||
} as const
|
||||
export type FormFieldType = typeof FormFieldType[keyof typeof FormFieldType]
|
||||
|
||||
// Backend only ships 'full' | 'half' today; 'third' | 'quarter' are
|
||||
// forward-compat placeholders matching the future ARCH design. Layout
|
||||
// maps unknown widths to full width.
|
||||
export type FormFieldDisplayWidth = 'full' | 'half' | 'third' | 'quarter'
|
||||
|
||||
export const ConditionalOperator = {
|
||||
equals: 'equals',
|
||||
not_equals: 'not_equals',
|
||||
contains: 'contains',
|
||||
not_contains: 'not_contains',
|
||||
in: 'in',
|
||||
not_in: 'not_in',
|
||||
greater_than: 'greater_than',
|
||||
less_than: 'less_than',
|
||||
empty: 'empty',
|
||||
not_empty: 'not_empty',
|
||||
} as const
|
||||
export type ConditionalOperator = typeof ConditionalOperator[keyof typeof ConditionalOperator]
|
||||
|
||||
export interface ConditionalRule {
|
||||
field_slug: string
|
||||
operator: ConditionalOperator
|
||||
value?: unknown
|
||||
}
|
||||
|
||||
export interface ConditionalGroup {
|
||||
all?: Array<ConditionalRule | ConditionalGroup>
|
||||
any?: Array<ConditionalRule | ConditionalGroup>
|
||||
}
|
||||
|
||||
export interface ConditionalLogic {
|
||||
show_when?: ConditionalGroup
|
||||
}
|
||||
|
||||
export interface FormFieldValidationRules {
|
||||
min?: number
|
||||
max?: number
|
||||
pattern?: string
|
||||
min_selections?: number
|
||||
max_selections?: number
|
||||
tag_categories?: string[]
|
||||
[key: string]: unknown
|
||||
}
|
||||
|
||||
// Post-WS-5d rich-shape option contract. Per ARCH-FORM-BUILDER §17.6,
|
||||
// every RADIO/SELECT/MULTISELECT/CHECKBOX_LIST option carries
|
||||
// value+label+sort_order on the row, and per-locale translations as a
|
||||
// JSON bag indexed by BCP-47 locale (e.g. "nl", "en_GB"). The legacy
|
||||
// `description` field is gone — ARCH §5.1's option-bearing field types
|
||||
// don't model descriptions; that lives on the parallel
|
||||
// RegistrationFieldTemplate domain in apps/app and is out of scope.
|
||||
export interface OptionSpec {
|
||||
value: string
|
||||
label: string
|
||||
sort_order: number
|
||||
translations?: Record<string, string>
|
||||
}
|
||||
|
||||
export interface AvailableTag {
|
||||
id: string
|
||||
name: string
|
||||
category: string
|
||||
}
|
||||
|
||||
export interface PublicFormField {
|
||||
id: string
|
||||
slug: string
|
||||
field_type: FormFieldType
|
||||
label: string
|
||||
help_text: string | null
|
||||
options: OptionSpec[] | null
|
||||
available_tags: AvailableTag[] | null
|
||||
validation_rules: FormFieldValidationRules | null
|
||||
is_required: boolean
|
||||
display_width: FormFieldDisplayWidth
|
||||
conditional_logic: ConditionalLogic | null
|
||||
sort_order: number
|
||||
form_schema_section_id: string | null
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolve an option's display label for a given locale. Falls back to
|
||||
* the default label when no translation exists for that locale.
|
||||
*/
|
||||
export function resolveOptionLabel(option: OptionSpec, locale: string): string {
|
||||
return option.translations?.[locale] ?? option.label
|
||||
}
|
||||
|
||||
export interface PublicFormSection {
|
||||
id: string
|
||||
slug: string
|
||||
name: string
|
||||
description: string | null
|
||||
sort_order: number
|
||||
}
|
||||
|
||||
export interface PublicFormSchema {
|
||||
id: string
|
||||
name: string
|
||||
slug: string
|
||||
purpose: string
|
||||
description: string | null
|
||||
locale: string
|
||||
version: number
|
||||
opened_at: string
|
||||
consent_version: string | null
|
||||
submission_deadline: string | null
|
||||
section_level_submit: boolean
|
||||
sections: PublicFormSection[]
|
||||
fields: PublicFormField[]
|
||||
}
|
||||
|
||||
export type FormSubmissionStatus = 'draft' | 'submitted' | 'reviewed' | 'rejected' | 'approved'
|
||||
|
||||
export interface PublicFormSubmissionValue {
|
||||
value: unknown
|
||||
value_anonymised: boolean
|
||||
}
|
||||
|
||||
export interface PublicFormSubmissionIdentityMatch {
|
||||
status: 'pending' | 'matched' | 'none'
|
||||
message: string
|
||||
}
|
||||
|
||||
export interface PublicFormSubmissionDuplicate {
|
||||
count: number
|
||||
first_submitted_at: string
|
||||
title: string
|
||||
body: string
|
||||
}
|
||||
|
||||
export interface PublicFormSubmission {
|
||||
id: string
|
||||
form_schema_id: string
|
||||
status: FormSubmissionStatus
|
||||
auto_save_count: number
|
||||
submitted_in_locale: string | null
|
||||
schema_version_at_submit: number | null
|
||||
schema_drift: boolean
|
||||
values: Record<string, PublicFormSubmissionValue>
|
||||
identity_match: PublicFormSubmissionIdentityMatch | null
|
||||
duplicate_submission: PublicFormSubmissionDuplicate | null
|
||||
opened_at: string | null
|
||||
first_interacted_at: string | null
|
||||
submitted_at: string | null
|
||||
submission_duration_seconds: number | null
|
||||
created_at: string | null
|
||||
updated_at: string | null
|
||||
}
|
||||
|
||||
export type FormErrorCode =
|
||||
| 'TOKEN_EXPIRED'
|
||||
| 'TOKEN_REVOKED'
|
||||
| 'SCHEMA_UNPUBLISHED'
|
||||
| 'SCHEMA_NOT_FOUND'
|
||||
| 'SUBMISSION_ALREADY_SUBMITTED'
|
||||
| 'SUBMISSION_NOT_FOUND'
|
||||
| 'RATE_LIMITED'
|
||||
|
||||
export interface PublicFormErrorBody {
|
||||
message: string
|
||||
code?: FormErrorCode | string
|
||||
errors?: Record<string, string[]>
|
||||
}
|
||||
|
||||
export interface PublicFormTimeSlot {
|
||||
id: string
|
||||
name: string
|
||||
date: string // YYYY-MM-DD
|
||||
start_time: string // HH:MM:SS
|
||||
end_time: string // HH:MM:SS
|
||||
duration_hours: number | null
|
||||
event_id: string
|
||||
event_name: string
|
||||
}
|
||||
|
||||
export interface PublicFormSectionOption {
|
||||
id: string
|
||||
name: string
|
||||
category: string | null
|
||||
icon: string | null
|
||||
registration_description: string | null
|
||||
}
|
||||
|
||||
export interface SectionPriorityValue {
|
||||
section_id: string
|
||||
priority: number
|
||||
}
|
||||
|
||||
export type FormValues = Record<string, unknown>
|
||||
|
||||
export interface StartDraftBody {
|
||||
idempotency_key: string
|
||||
opened_at?: string
|
||||
submitted_in_locale?: string
|
||||
public_submitter_name?: string
|
||||
public_submitter_email?: string
|
||||
}
|
||||
|
||||
export interface SaveDraftBody {
|
||||
values?: FormValues
|
||||
first_interacted_at?: string
|
||||
public_submitter_name?: string
|
||||
public_submitter_email?: string
|
||||
}
|
||||
|
||||
export interface SubmitBody {
|
||||
values?: FormValues
|
||||
captcha_token?: string
|
||||
public_submitter_name?: string
|
||||
public_submitter_email?: string
|
||||
}
|
||||
149
apps/app/src/composables/forms/utils/formValidation.ts
Normal file
149
apps/app/src/composables/forms/utils/formValidation.ts
Normal file
@@ -0,0 +1,149 @@
|
||||
import { emailValidator, regexValidator, requiredValidator, urlValidator } from './validators'
|
||||
import { FormFieldType } from '../types/formBuilder'
|
||||
import type { PublicFormField } from '../types/formBuilder'
|
||||
|
||||
export type Validator = (value: unknown) => true | string
|
||||
|
||||
/**
|
||||
* Build a list of client-side validators for a public form field.
|
||||
* Runs in VTextField `:rules` and the stepper "current step valid"
|
||||
* gate. Mirrors (a subset of) the backend relaxed rule set so the
|
||||
* submitter gets feedback before the submit round-trip.
|
||||
*/
|
||||
export function getValidatorsForField(field: PublicFormField): Validator[] {
|
||||
const rules: Validator[] = []
|
||||
const v = field.validation_rules ?? {}
|
||||
|
||||
if (field.is_required) {
|
||||
rules.push(value => {
|
||||
const ok = requiredValidator(value)
|
||||
|
||||
return ok === true ? true : 'Dit veld is verplicht.'
|
||||
})
|
||||
}
|
||||
|
||||
switch (field.field_type) {
|
||||
case FormFieldType.EMAIL:
|
||||
rules.push(value => {
|
||||
const ok = emailValidator(value)
|
||||
|
||||
return ok === true ? true : 'Vul een geldig e-mailadres in.'
|
||||
})
|
||||
break
|
||||
|
||||
case FormFieldType.URL:
|
||||
rules.push(value => {
|
||||
const ok = urlValidator(value)
|
||||
|
||||
return ok === true ? true : 'Vul een geldige URL in (beginnend met http:// of https://).'
|
||||
})
|
||||
break
|
||||
|
||||
case FormFieldType.PHONE:
|
||||
rules.push(value => {
|
||||
if (value === null || value === undefined || value === '') return true
|
||||
const s = String(value).replace(/\s+/g, '')
|
||||
|
||||
return /^\+?[\d()-]{6,}$/.test(s) || 'Vul een geldig telefoonnummer in.'
|
||||
})
|
||||
break
|
||||
|
||||
case FormFieldType.NUMBER:
|
||||
rules.push(value => {
|
||||
if (value === null || value === undefined || value === '') return true
|
||||
const n = Number(value)
|
||||
|
||||
return Number.isFinite(n) || 'Vul een geldig getal in.'
|
||||
})
|
||||
if (typeof v.min === 'number') {
|
||||
const min = v.min
|
||||
rules.push(value => {
|
||||
if (value === null || value === undefined || value === '') return true
|
||||
const n = Number(value)
|
||||
|
||||
return Number.isFinite(n) && n >= min ? true : `Minimaal ${min}.`
|
||||
})
|
||||
}
|
||||
if (typeof v.max === 'number') {
|
||||
const max = v.max
|
||||
rules.push(value => {
|
||||
if (value === null || value === undefined || value === '') return true
|
||||
const n = Number(value)
|
||||
|
||||
return Number.isFinite(n) && n <= max ? true : `Maximaal ${max}.`
|
||||
})
|
||||
}
|
||||
break
|
||||
|
||||
case FormFieldType.TEXT:
|
||||
case FormFieldType.TEXTAREA:
|
||||
if (typeof v.min === 'number') {
|
||||
const min = v.min
|
||||
rules.push(value => {
|
||||
if (value === null || value === undefined || value === '') return true
|
||||
|
||||
return String(value).length >= min ? true : `Minimaal ${min} tekens.`
|
||||
})
|
||||
}
|
||||
if (typeof v.max === 'number') {
|
||||
const max = v.max
|
||||
rules.push(value => {
|
||||
if (value === null || value === undefined || value === '') return true
|
||||
|
||||
return String(value).length <= max ? true : `Maximaal ${max} tekens.`
|
||||
})
|
||||
}
|
||||
if (typeof v.pattern === 'string' && v.pattern.length > 0) {
|
||||
const pattern = v.pattern
|
||||
rules.push(value => {
|
||||
if (value === null || value === undefined || value === '') return true
|
||||
const ok = regexValidator(value, pattern)
|
||||
|
||||
return ok === true ? true : 'Ongeldige invoer.'
|
||||
})
|
||||
}
|
||||
break
|
||||
|
||||
case FormFieldType.MULTISELECT:
|
||||
case FormFieldType.CHECKBOX_LIST:
|
||||
if (typeof v.min_selections === 'number') {
|
||||
const min = v.min_selections
|
||||
rules.push(value => {
|
||||
const arr = Array.isArray(value) ? value : []
|
||||
|
||||
return arr.length >= min ? true : `Kies er minimaal ${min}.`
|
||||
})
|
||||
}
|
||||
if (typeof v.max_selections === 'number') {
|
||||
const max = v.max_selections
|
||||
rules.push(value => {
|
||||
const arr = Array.isArray(value) ? value : []
|
||||
|
||||
return arr.length <= max ? true : `Kies er maximaal ${max}.`
|
||||
})
|
||||
}
|
||||
break
|
||||
|
||||
default:
|
||||
break
|
||||
}
|
||||
|
||||
return rules
|
||||
}
|
||||
|
||||
export function runValidators(rules: Validator[], value: unknown): string | true {
|
||||
for (const rule of rules) {
|
||||
const r = rule(value)
|
||||
if (r !== true) return r
|
||||
}
|
||||
|
||||
return true
|
||||
}
|
||||
|
||||
export function isFieldValueEmpty(value: unknown): boolean {
|
||||
if (value === null || value === undefined) return true
|
||||
if (typeof value === 'string') return value.trim() === ''
|
||||
if (Array.isArray(value)) return value.length === 0
|
||||
|
||||
return false
|
||||
}
|
||||
54
apps/app/src/composables/forms/utils/validators.ts
Normal file
54
apps/app/src/composables/forms/utils/validators.ts
Normal file
@@ -0,0 +1,54 @@
|
||||
// Pure boolean validators used as gates by formValidation.ts. Error
|
||||
// messages (in Dutch) are produced by the caller, not by these validators.
|
||||
// This file deliberately has no external imports — the package must be
|
||||
// standalone and free of cross-app coupling.
|
||||
|
||||
function isNullOrUndefined(value: unknown): boolean {
|
||||
return value === null || value === undefined
|
||||
}
|
||||
|
||||
function isEmptyArray(value: unknown): boolean {
|
||||
return Array.isArray(value) && value.length === 0
|
||||
}
|
||||
|
||||
function isEmpty(value: unknown): boolean {
|
||||
if (isNullOrUndefined(value)) return true
|
||||
if (typeof value === 'string') return value.trim() === ''
|
||||
if (Array.isArray(value)) return value.length === 0
|
||||
|
||||
return false
|
||||
}
|
||||
|
||||
export function requiredValidator(value: unknown): boolean {
|
||||
if (isNullOrUndefined(value) || isEmptyArray(value) || value === false) return false
|
||||
|
||||
return String(value).trim().length > 0
|
||||
}
|
||||
|
||||
export function emailValidator(value: unknown): boolean {
|
||||
if (isEmpty(value)) return true
|
||||
|
||||
const re = /^(?:[^<>()[\]\\.,;:\s@"]+(?:\.[^<>()[\]\\.,;:\s@"]+)*|".+")@(?:\[\d{1,3}\.\d{1,3}\.\d{1,3}\.\d{1,3}\]|(?:[a-z\-\d]+\.)+[a-z]{2,})$/i
|
||||
|
||||
if (Array.isArray(value)) return value.every(val => re.test(String(val)))
|
||||
|
||||
return re.test(String(value))
|
||||
}
|
||||
|
||||
export function urlValidator(value: unknown): boolean {
|
||||
if (isEmpty(value)) return true
|
||||
|
||||
const re = /^https?:\/\/[^\s$.?#].\S*$/
|
||||
|
||||
return re.test(String(value))
|
||||
}
|
||||
|
||||
export function regexValidator(value: unknown, regex: RegExp | string): boolean {
|
||||
if (isEmpty(value)) return true
|
||||
|
||||
const regEx = typeof regex === 'string' ? new RegExp(regex) : regex
|
||||
|
||||
if (Array.isArray(value)) return value.every(val => regexValidator(val, regEx))
|
||||
|
||||
return regEx.test(String(value))
|
||||
}
|
||||
Reference in New Issue
Block a user