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,19 @@
# @form-schema
Shared schema contract and schema-driven behaviors for Crewli form rendering. Consumed by apps/portal (public submission) and apps/app (organizer builder + submissions review).
## What lives here
- `types/` — TypeScript types and enum constants mirroring backend `FormBuilder` enums and `PublicForm(Schema|Submission)Resource`
- `utils/formValidation.ts` — validation rule builders driven by `FormFieldValidationRules`
- `composables/useConditionalLogic.ts` — evaluate `conditional_logic.show_when` against field values
- `composables/useFormSteps.ts` — step navigation logic for multi-step forms
- `composables/formatFieldValue.ts` — render a stored submission value as a human-readable string
## What does NOT live here
Vue components. Each app renders its own UI. Portal has full-fidelity submit components; app has builder-preview and submissions-review components. Sharing renderers would couple the two apps' visual styles, which we explicitly want to avoid.
## Contract stability
This is an alias-only shared directory inside the monorepo — no npm package, no semver. Breaking changes (new field type, new validation key, new conditional operator) require updating both apps in the same PR. TypeScript will flag missing cases in dispatchers.

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
}