refactor(form-schema): extract schema types and schema-driven behaviors to shared package

Moves formBuilder types, formValidation, useConditionalLogic, useFormSteps,
and formatFieldValue from apps/portal/src to packages/form-schema/src.
Adds @form-schema path alias to both apps/portal and apps/app.
Vue field components remain per-app to allow independent visual evolution.
Behavior-neutral: all 35 Vitest tests green.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-04-23 23:57:39 +02:00
parent b6a3a17b0a
commit dda60ed5e4
48 changed files with 114 additions and 83 deletions

View 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
}

View File

@@ -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)
}

View 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
}

View File

@@ -0,0 +1,222 @@
// 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
}
export type FieldOption = string | { label: string; description?: string | null; value?: string | number | null }
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: FieldOption[] | 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
}
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
}

View File

@@ -0,0 +1,149 @@
import { emailValidator, regexValidator, requiredValidator, urlValidator } from '@core/utils/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
}