feat(portal): public-form component architecture
Replace monolithic register/[eventSlug].vue with composable field renderer, conditional-logic engine, stepper, and per-field components driven by Form Builder schema. Adds flatpickr for date fields. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
110
apps/portal/src/composables/api/usePublicForm.ts
Normal file
110
apps/portal/src/composables/api/usePublicForm.ts
Normal file
@@ -0,0 +1,110 @@
|
||||
import { useMutation, useQuery } from '@tanstack/vue-query'
|
||||
import type { AxiosError } from 'axios'
|
||||
import type { MaybeRefOrGetter } from 'vue'
|
||||
import { apiClient } from '@/lib/axios'
|
||||
import type {
|
||||
PublicFormErrorBody,
|
||||
PublicFormSchema,
|
||||
PublicFormSubmission,
|
||||
SaveDraftBody,
|
||||
StartDraftBody,
|
||||
SubmitBody,
|
||||
} from '@/types/formBuilder'
|
||||
|
||||
interface ApiResponse<T> {
|
||||
data: T
|
||||
message?: string
|
||||
success?: boolean
|
||||
}
|
||||
|
||||
/**
|
||||
* The backend standardises public-form errors as
|
||||
* { message, code?, errors? }
|
||||
* See api/app/Exceptions/FormBuilder/PublicFormApiException.php.
|
||||
*/
|
||||
export type PublicFormAxiosError = AxiosError<PublicFormErrorBody>
|
||||
|
||||
const TERMINAL_STATUSES = new Set([404, 410, 409])
|
||||
|
||||
export function useFetchPublicFormSchema(token: MaybeRefOrGetter<string | null | undefined>) {
|
||||
return useQuery({
|
||||
queryKey: ['public-form-schema', () => toValue(token)],
|
||||
queryFn: async (): Promise<PublicFormSchema> => {
|
||||
const t = toValue(token)
|
||||
if (!t) throw new Error('Missing public_token')
|
||||
const { data } = await apiClient.get<ApiResponse<PublicFormSchema>>(`/public/forms/${t}`)
|
||||
|
||||
return data.data
|
||||
},
|
||||
enabled: () => !!toValue(token),
|
||||
retry: (failureCount, error) => {
|
||||
const status = (error as PublicFormAxiosError | undefined)?.response?.status
|
||||
if (status && TERMINAL_STATUSES.has(status)) return false
|
||||
|
||||
return failureCount < 1
|
||||
},
|
||||
staleTime: 1000 * 60 * 5,
|
||||
})
|
||||
}
|
||||
|
||||
export function useCreateFormDraft(token: MaybeRefOrGetter<string | null | undefined>) {
|
||||
return useMutation({
|
||||
mutationFn: async (body: StartDraftBody): Promise<PublicFormSubmission> => {
|
||||
const t = toValue(token)
|
||||
if (!t) throw new Error('Missing public_token')
|
||||
const { data } = await apiClient.post<ApiResponse<PublicFormSubmission>>(
|
||||
`/public/forms/${t}/submissions`,
|
||||
body,
|
||||
)
|
||||
|
||||
return data.data
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
export function useSaveFormDraft(token: MaybeRefOrGetter<string | null | undefined>) {
|
||||
return useMutation({
|
||||
mutationFn: async ({ submissionId, body }: { submissionId: string; body: SaveDraftBody }): Promise<PublicFormSubmission> => {
|
||||
const t = toValue(token)
|
||||
if (!t) throw new Error('Missing public_token')
|
||||
const { data } = await apiClient.put<ApiResponse<PublicFormSubmission>>(
|
||||
`/public/forms/${t}/submissions/${submissionId}`,
|
||||
body,
|
||||
)
|
||||
|
||||
return data.data
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
export function useSubmitForm(token: MaybeRefOrGetter<string | null | undefined>) {
|
||||
return useMutation({
|
||||
mutationFn: async ({ submissionId, body }: { submissionId: string; body: SubmitBody }): Promise<PublicFormSubmission> => {
|
||||
const t = toValue(token)
|
||||
if (!t) throw new Error('Missing public_token')
|
||||
const { data } = await apiClient.post<ApiResponse<PublicFormSubmission>>(
|
||||
`/public/forms/${t}/submissions/${submissionId}/submit`,
|
||||
body,
|
||||
)
|
||||
|
||||
return data.data
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
export function extractErrorBody(err: unknown): PublicFormErrorBody | null {
|
||||
const axiosErr = err as PublicFormAxiosError | undefined
|
||||
const body = axiosErr?.response?.data
|
||||
if (body && typeof body === 'object' && 'message' in body) return body as PublicFormErrorBody
|
||||
|
||||
return null
|
||||
}
|
||||
|
||||
export function extractRetryAfterSeconds(err: unknown): number | null {
|
||||
const axiosErr = err as PublicFormAxiosError | undefined
|
||||
const raw = axiosErr?.response?.headers?.['retry-after']
|
||||
if (!raw) return null
|
||||
const n = Number(raw)
|
||||
|
||||
return Number.isFinite(n) && n >= 0 ? n : null
|
||||
}
|
||||
127
apps/portal/src/composables/useConditionalLogic.ts
Normal file
127
apps/portal/src/composables/useConditionalLogic.ts
Normal 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)
|
||||
}
|
||||
138
apps/portal/src/composables/useFormSteps.ts
Normal file
138
apps/portal/src/composables/useFormSteps.ts
Normal file
@@ -0,0 +1,138 @@
|
||||
import { computed } from 'vue'
|
||||
import type { ComputedRef, Ref } from 'vue'
|
||||
import { evaluateConditionalLogic } from '@/composables/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