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:
138
packages/form-schema/src/composables/useFormSteps.ts
Normal file
138
packages/form-schema/src/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
|
||||
}
|
||||
Reference in New Issue
Block a user