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:
40
apps/portal/src/components/public-form/FieldBoolean.vue
Normal file
40
apps/portal/src/components/public-form/FieldBoolean.vue
Normal file
@@ -0,0 +1,40 @@
|
||||
<script setup lang="ts">
|
||||
import type { PublicFormField } from '@/types/formBuilder'
|
||||
|
||||
const props = defineProps<{
|
||||
field: PublicFormField
|
||||
modelValue: unknown
|
||||
errorMessages?: string[]
|
||||
}>()
|
||||
|
||||
const emit = defineEmits<{
|
||||
(e: 'update:modelValue', v: boolean): void
|
||||
(e: 'blur'): void
|
||||
}>()
|
||||
|
||||
const model = computed({
|
||||
get: () => Boolean(props.modelValue),
|
||||
set: (v: boolean) => emit('update:modelValue', v),
|
||||
})
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div>
|
||||
<VSwitch
|
||||
v-model="model"
|
||||
inset
|
||||
color="primary"
|
||||
density="comfortable"
|
||||
hide-details="auto"
|
||||
:label="field.label + (field.is_required ? ' *' : '')"
|
||||
:error-messages="errorMessages"
|
||||
@update:model-value="emit('blur')"
|
||||
/>
|
||||
<p
|
||||
v-if="field.help_text"
|
||||
class="text-caption text-medium-emphasis mt-1 mb-0"
|
||||
>
|
||||
{{ field.help_text }}
|
||||
</p>
|
||||
</div>
|
||||
</template>
|
||||
107
apps/portal/src/components/public-form/FieldCheckboxList.vue
Normal file
107
apps/portal/src/components/public-form/FieldCheckboxList.vue
Normal file
@@ -0,0 +1,107 @@
|
||||
<script setup lang="ts">
|
||||
import type { FieldOption, PublicFormField } from '@/types/formBuilder'
|
||||
import { getValidatorsForField, runValidators } from '@/utils/formValidation'
|
||||
|
||||
const props = defineProps<{
|
||||
field: PublicFormField
|
||||
modelValue: unknown
|
||||
errorMessages?: string[]
|
||||
}>()
|
||||
|
||||
const emit = defineEmits<{
|
||||
(e: 'update:modelValue', v: string[]): void
|
||||
(e: 'blur'): void
|
||||
}>()
|
||||
|
||||
interface NormalizedOption {
|
||||
label: string
|
||||
description: string | null
|
||||
value: string
|
||||
}
|
||||
|
||||
function normalize(opt: FieldOption): NormalizedOption {
|
||||
if (typeof opt === 'string') return { label: opt, description: null, value: opt }
|
||||
const value = opt.value != null ? String(opt.value) : opt.label
|
||||
|
||||
return { label: opt.label, description: opt.description ?? null, value }
|
||||
}
|
||||
|
||||
const options = computed<NormalizedOption[]>(() => (props.field.options ?? []).map(normalize))
|
||||
|
||||
const selected = computed<string[]>(() =>
|
||||
Array.isArray(props.modelValue) ? (props.modelValue as unknown[]).map(String) : [],
|
||||
)
|
||||
|
||||
const rules = computed(() => getValidatorsForField(props.field))
|
||||
const clientError = computed(() => {
|
||||
const res = runValidators(rules.value, selected.value)
|
||||
|
||||
return res === true ? null : res
|
||||
})
|
||||
const displayedErrors = computed(() => {
|
||||
if (props.errorMessages && props.errorMessages.length > 0) return props.errorMessages
|
||||
if (clientError.value) return [clientError.value]
|
||||
|
||||
return []
|
||||
})
|
||||
|
||||
function isChecked(value: string): boolean {
|
||||
return selected.value.includes(value)
|
||||
}
|
||||
|
||||
function toggle(value: string, checked: boolean | null): void {
|
||||
const next = [...selected.value]
|
||||
const idx = next.indexOf(value)
|
||||
if (checked) {
|
||||
if (idx === -1) next.push(value)
|
||||
}
|
||||
else if (idx !== -1) {
|
||||
next.splice(idx, 1)
|
||||
}
|
||||
emit('update:modelValue', next)
|
||||
emit('blur')
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div>
|
||||
<div class="text-body-2 mb-1 text-high-emphasis">
|
||||
{{ field.label }}<span
|
||||
v-if="field.is_required"
|
||||
class="text-error"
|
||||
> *</span>
|
||||
</div>
|
||||
<p
|
||||
v-if="field.help_text"
|
||||
class="text-caption text-medium-emphasis mb-2"
|
||||
>
|
||||
{{ field.help_text }}
|
||||
</p>
|
||||
<VCheckbox
|
||||
v-for="opt in options"
|
||||
:key="opt.value"
|
||||
:model-value="isChecked(opt.value)"
|
||||
density="comfortable"
|
||||
hide-details
|
||||
@update:model-value="(v: boolean | null) => toggle(opt.value, v)"
|
||||
>
|
||||
<template #label>
|
||||
<div>
|
||||
<span class="text-body-1">{{ opt.label }}</span>
|
||||
<p
|
||||
v-if="opt.description"
|
||||
class="text-caption text-medium-emphasis mt-1 mb-0"
|
||||
>
|
||||
{{ opt.description }}
|
||||
</p>
|
||||
</div>
|
||||
</template>
|
||||
</VCheckbox>
|
||||
<div
|
||||
v-if="displayedErrors.length"
|
||||
class="text-caption text-error mt-1"
|
||||
>
|
||||
{{ displayedErrors[0] }}
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
37
apps/portal/src/components/public-form/FieldDate.vue
Normal file
37
apps/portal/src/components/public-form/FieldDate.vue
Normal file
@@ -0,0 +1,37 @@
|
||||
<script setup lang="ts">
|
||||
import type { PublicFormField } from '@/types/formBuilder'
|
||||
import { getValidatorsForField } from '@/utils/formValidation'
|
||||
|
||||
const props = defineProps<{
|
||||
field: PublicFormField
|
||||
modelValue: unknown
|
||||
errorMessages?: string[]
|
||||
}>()
|
||||
|
||||
const emit = defineEmits<{
|
||||
(e: 'update:modelValue', v: string): void
|
||||
(e: 'blur'): void
|
||||
}>()
|
||||
|
||||
const rules = computed(() => getValidatorsForField(props.field))
|
||||
const model = computed({
|
||||
get: () => (props.modelValue ?? '') as string,
|
||||
set: (v: string) => emit('update:modelValue', v),
|
||||
})
|
||||
|
||||
const config = { dateFormat: 'Y-m-d', allowInput: true }
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<AppDateTimePicker
|
||||
v-model="model"
|
||||
:label="field.label + (field.is_required ? ' *' : '')"
|
||||
:hint="field.help_text ?? undefined"
|
||||
persistent-hint
|
||||
prepend-inner-icon="tabler-calendar"
|
||||
:config="config"
|
||||
:rules="rules"
|
||||
:error-messages="errorMessages"
|
||||
@blur="emit('blur')"
|
||||
/>
|
||||
</template>
|
||||
38
apps/portal/src/components/public-form/FieldEmail.vue
Normal file
38
apps/portal/src/components/public-form/FieldEmail.vue
Normal file
@@ -0,0 +1,38 @@
|
||||
<script setup lang="ts">
|
||||
import type { PublicFormField } from '@/types/formBuilder'
|
||||
import { getValidatorsForField } from '@/utils/formValidation'
|
||||
|
||||
const props = defineProps<{
|
||||
field: PublicFormField
|
||||
modelValue: unknown
|
||||
errorMessages?: string[]
|
||||
}>()
|
||||
|
||||
const emit = defineEmits<{
|
||||
(e: 'update:modelValue', v: string): void
|
||||
(e: 'blur'): void
|
||||
}>()
|
||||
|
||||
const rules = computed(() => getValidatorsForField(props.field))
|
||||
const model = computed({
|
||||
get: () => (props.modelValue ?? '') as string,
|
||||
set: (v: string) => emit('update:modelValue', v),
|
||||
})
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<AppTextField
|
||||
v-model="model"
|
||||
type="email"
|
||||
inputmode="email"
|
||||
autocomplete="email"
|
||||
:label="field.label"
|
||||
:hint="field.help_text ?? undefined"
|
||||
persistent-hint
|
||||
prepend-inner-icon="tabler-mail"
|
||||
:rules="rules"
|
||||
:error-messages="errorMessages"
|
||||
:required="field.is_required"
|
||||
@blur="emit('blur')"
|
||||
/>
|
||||
</template>
|
||||
22
apps/portal/src/components/public-form/FieldHeading.vue
Normal file
22
apps/portal/src/components/public-form/FieldHeading.vue
Normal file
@@ -0,0 +1,22 @@
|
||||
<script setup lang="ts">
|
||||
import type { PublicFormField } from '@/types/formBuilder'
|
||||
|
||||
defineProps<{
|
||||
field: PublicFormField
|
||||
}>()
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div>
|
||||
<h3 class="text-h6 mt-6 mb-2">
|
||||
{{ field.label }}
|
||||
</h3>
|
||||
<p
|
||||
v-if="field.help_text"
|
||||
class="text-body-2 text-medium-emphasis mb-4"
|
||||
>
|
||||
{{ field.help_text }}
|
||||
</p>
|
||||
<VDivider class="mb-2" />
|
||||
</div>
|
||||
</template>
|
||||
67
apps/portal/src/components/public-form/FieldMultiselect.vue
Normal file
67
apps/portal/src/components/public-form/FieldMultiselect.vue
Normal file
@@ -0,0 +1,67 @@
|
||||
<script setup lang="ts">
|
||||
import type { FieldOption, PublicFormField } from '@/types/formBuilder'
|
||||
import { getValidatorsForField } from '@/utils/formValidation'
|
||||
|
||||
const props = defineProps<{
|
||||
field: PublicFormField
|
||||
modelValue: unknown
|
||||
errorMessages?: string[]
|
||||
}>()
|
||||
|
||||
const emit = defineEmits<{
|
||||
(e: 'update:modelValue', v: string[]): void
|
||||
(e: 'blur'): void
|
||||
}>()
|
||||
|
||||
interface NormalizedOption {
|
||||
title: string
|
||||
description?: string | null
|
||||
value: string
|
||||
}
|
||||
|
||||
function normalize(opt: FieldOption): NormalizedOption {
|
||||
if (typeof opt === 'string') return { title: opt, value: opt }
|
||||
const value = opt.value != null ? String(opt.value) : opt.label
|
||||
|
||||
return { title: opt.label, description: opt.description ?? null, value }
|
||||
}
|
||||
|
||||
const items = computed<NormalizedOption[]>(() => (props.field.options ?? []).map(normalize))
|
||||
const rules = computed(() => getValidatorsForField(props.field))
|
||||
|
||||
const model = computed({
|
||||
get: () => (Array.isArray(props.modelValue) ? props.modelValue as string[] : []),
|
||||
set: (v: string[]) => emit('update:modelValue', v),
|
||||
})
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<AppSelect
|
||||
v-model="model"
|
||||
multiple
|
||||
chips
|
||||
closable-chips
|
||||
:items="items"
|
||||
item-title="title"
|
||||
item-value="value"
|
||||
:label="field.label"
|
||||
:hint="field.help_text ?? undefined"
|
||||
persistent-hint
|
||||
:rules="rules"
|
||||
:error-messages="errorMessages"
|
||||
:required="field.is_required"
|
||||
@update:model-value="emit('blur')"
|
||||
@blur="emit('blur')"
|
||||
>
|
||||
<template #item="{ props: itemProps, item }">
|
||||
<VListItem v-bind="itemProps">
|
||||
<template
|
||||
v-if="item.raw.description"
|
||||
#subtitle
|
||||
>
|
||||
{{ item.raw.description }}
|
||||
</template>
|
||||
</VListItem>
|
||||
</template>
|
||||
</AppSelect>
|
||||
</template>
|
||||
54
apps/portal/src/components/public-form/FieldNumber.vue
Normal file
54
apps/portal/src/components/public-form/FieldNumber.vue
Normal file
@@ -0,0 +1,54 @@
|
||||
<script setup lang="ts">
|
||||
import type { PublicFormField } from '@/types/formBuilder'
|
||||
import { getValidatorsForField } from '@/utils/formValidation'
|
||||
|
||||
const props = defineProps<{
|
||||
field: PublicFormField
|
||||
modelValue: unknown
|
||||
errorMessages?: string[]
|
||||
}>()
|
||||
|
||||
const emit = defineEmits<{
|
||||
(e: 'update:modelValue', v: number | null): void
|
||||
(e: 'blur'): void
|
||||
}>()
|
||||
|
||||
const rules = computed(() => getValidatorsForField(props.field))
|
||||
|
||||
const inputValue = computed<string>(() => {
|
||||
const v = props.modelValue
|
||||
if (v === null || v === undefined) return ''
|
||||
if (typeof v === 'number') return Number.isFinite(v) ? String(v) : ''
|
||||
|
||||
return String(v)
|
||||
})
|
||||
|
||||
function onUpdate(raw: string | number | null) {
|
||||
const s = raw === null || raw === undefined ? '' : String(raw)
|
||||
if (s === '' || s === '-') {
|
||||
emit('update:modelValue', null)
|
||||
|
||||
return
|
||||
}
|
||||
const n = Number(s)
|
||||
emit('update:modelValue', Number.isFinite(n) ? n : null)
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<AppTextField
|
||||
:model-value="inputValue"
|
||||
type="number"
|
||||
inputmode="decimal"
|
||||
:label="field.label"
|
||||
:hint="field.help_text ?? undefined"
|
||||
persistent-hint
|
||||
:min="field.validation_rules?.min ?? undefined"
|
||||
:max="field.validation_rules?.max ?? undefined"
|
||||
:rules="rules"
|
||||
:error-messages="errorMessages"
|
||||
:required="field.is_required"
|
||||
@update:model-value="onUpdate"
|
||||
@blur="emit('blur')"
|
||||
/>
|
||||
</template>
|
||||
13
apps/portal/src/components/public-form/FieldParagraph.vue
Normal file
13
apps/portal/src/components/public-form/FieldParagraph.vue
Normal file
@@ -0,0 +1,13 @@
|
||||
<script setup lang="ts">
|
||||
import type { PublicFormField } from '@/types/formBuilder'
|
||||
|
||||
defineProps<{
|
||||
field: PublicFormField
|
||||
}>()
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<p class="text-body-1 text-medium-emphasis mb-4">
|
||||
{{ field.label }}
|
||||
</p>
|
||||
</template>
|
||||
39
apps/portal/src/components/public-form/FieldPhone.vue
Normal file
39
apps/portal/src/components/public-form/FieldPhone.vue
Normal file
@@ -0,0 +1,39 @@
|
||||
<script setup lang="ts">
|
||||
import type { PublicFormField } from '@/types/formBuilder'
|
||||
import { getValidatorsForField } from '@/utils/formValidation'
|
||||
|
||||
const props = defineProps<{
|
||||
field: PublicFormField
|
||||
modelValue: unknown
|
||||
errorMessages?: string[]
|
||||
}>()
|
||||
|
||||
const emit = defineEmits<{
|
||||
(e: 'update:modelValue', v: string): void
|
||||
(e: 'blur'): void
|
||||
}>()
|
||||
|
||||
const rules = computed(() => getValidatorsForField(props.field))
|
||||
const model = computed({
|
||||
get: () => (props.modelValue ?? '') as string,
|
||||
set: (v: string) => emit('update:modelValue', v),
|
||||
})
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<AppTextField
|
||||
v-model="model"
|
||||
type="tel"
|
||||
inputmode="tel"
|
||||
autocomplete="tel"
|
||||
placeholder="+31 6 12345678"
|
||||
:label="field.label"
|
||||
:hint="field.help_text ?? undefined"
|
||||
persistent-hint
|
||||
prepend-inner-icon="tabler-phone"
|
||||
:rules="rules"
|
||||
:error-messages="errorMessages"
|
||||
:required="field.is_required"
|
||||
@blur="emit('blur')"
|
||||
/>
|
||||
</template>
|
||||
85
apps/portal/src/components/public-form/FieldRadio.vue
Normal file
85
apps/portal/src/components/public-form/FieldRadio.vue
Normal file
@@ -0,0 +1,85 @@
|
||||
<script setup lang="ts">
|
||||
import type { FieldOption, PublicFormField } from '@/types/formBuilder'
|
||||
import { getValidatorsForField } from '@/utils/formValidation'
|
||||
|
||||
const props = defineProps<{
|
||||
field: PublicFormField
|
||||
modelValue: unknown
|
||||
errorMessages?: string[]
|
||||
}>()
|
||||
|
||||
const emit = defineEmits<{
|
||||
(e: 'update:modelValue', v: string): void
|
||||
(e: 'blur'): void
|
||||
}>()
|
||||
|
||||
interface NormalizedOption {
|
||||
label: string
|
||||
description?: string | null
|
||||
value: string
|
||||
}
|
||||
|
||||
function normalize(opt: FieldOption): NormalizedOption {
|
||||
if (typeof opt === 'string') return { label: opt, value: opt }
|
||||
const base = { label: opt.label, description: opt.description ?? null }
|
||||
const value = opt.value != null ? String(opt.value) : opt.label
|
||||
|
||||
return { ...base, value }
|
||||
}
|
||||
|
||||
const options = computed<NormalizedOption[]>(() =>
|
||||
(props.field.options ?? []).map(normalize),
|
||||
)
|
||||
|
||||
const rules = computed(() => getValidatorsForField(props.field))
|
||||
|
||||
const model = computed({
|
||||
get: () => (props.modelValue ?? '') as string,
|
||||
set: (v: string) => {
|
||||
emit('update:modelValue', v)
|
||||
emit('blur')
|
||||
},
|
||||
})
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div>
|
||||
<div class="text-body-2 mb-1 text-high-emphasis">
|
||||
{{ field.label }}<span
|
||||
v-if="field.is_required"
|
||||
class="text-error"
|
||||
> *</span>
|
||||
</div>
|
||||
<p
|
||||
v-if="field.help_text"
|
||||
class="text-caption text-medium-emphasis mb-2"
|
||||
>
|
||||
{{ field.help_text }}
|
||||
</p>
|
||||
<VRadioGroup
|
||||
v-model="model"
|
||||
density="comfortable"
|
||||
:rules="rules"
|
||||
:error-messages="errorMessages"
|
||||
hide-details="auto"
|
||||
>
|
||||
<VRadio
|
||||
v-for="opt in options"
|
||||
:key="opt.value"
|
||||
:value="opt.value"
|
||||
>
|
||||
<template #label>
|
||||
<div>
|
||||
<span class="text-body-1">{{ opt.label }}</span>
|
||||
<p
|
||||
v-if="opt.description"
|
||||
class="text-disabled text-caption mt-1 mb-0"
|
||||
>
|
||||
{{ opt.description }}
|
||||
</p>
|
||||
</div>
|
||||
</template>
|
||||
</VRadio>
|
||||
</VRadioGroup>
|
||||
</div>
|
||||
</template>
|
||||
208
apps/portal/src/components/public-form/FieldRenderer.vue
Normal file
208
apps/portal/src/components/public-form/FieldRenderer.vue
Normal file
@@ -0,0 +1,208 @@
|
||||
<script setup lang="ts">
|
||||
import FieldBoolean from './FieldBoolean.vue'
|
||||
import FieldCheckboxList from './FieldCheckboxList.vue'
|
||||
import FieldDate from './FieldDate.vue'
|
||||
import FieldEmail from './FieldEmail.vue'
|
||||
import FieldHeading from './FieldHeading.vue'
|
||||
import FieldMultiselect from './FieldMultiselect.vue'
|
||||
import FieldNumber from './FieldNumber.vue'
|
||||
import FieldParagraph from './FieldParagraph.vue'
|
||||
import FieldPhone from './FieldPhone.vue'
|
||||
import FieldRadio from './FieldRadio.vue'
|
||||
import FieldSelect from './FieldSelect.vue'
|
||||
import FieldText from './FieldText.vue'
|
||||
import FieldTextarea from './FieldTextarea.vue'
|
||||
import FieldUrl from './FieldUrl.vue'
|
||||
import { evaluateConditionalLogic } from '@/composables/useConditionalLogic'
|
||||
import { FormFieldType } from '@/types/formBuilder'
|
||||
import type { FormFieldDisplayWidth, FormValues, PublicFormField } from '@/types/formBuilder'
|
||||
|
||||
const props = defineProps<{
|
||||
field: PublicFormField
|
||||
modelValue: unknown
|
||||
allValues: FormValues
|
||||
errorMessages?: string[]
|
||||
}>()
|
||||
|
||||
const emit = defineEmits<{
|
||||
(e: 'update:modelValue', v: unknown): void
|
||||
(e: 'blur'): void
|
||||
}>()
|
||||
|
||||
const visible = computed(() => evaluateConditionalLogic(props.field.conditional_logic, props.allValues))
|
||||
|
||||
function colsFor(width: FormFieldDisplayWidth): number {
|
||||
switch (width) {
|
||||
case 'half': return 6
|
||||
case 'third': return 4
|
||||
case 'quarter': return 3
|
||||
case 'full':
|
||||
default:
|
||||
return 12
|
||||
}
|
||||
}
|
||||
|
||||
const smCols = computed(() => colsFor(props.field.display_width))
|
||||
|
||||
const isStubbed = computed(() =>
|
||||
props.field.field_type === FormFieldType.TAG_PICKER
|
||||
|| props.field.field_type === FormFieldType.AVAILABILITY_PICKER
|
||||
|| props.field.field_type === FormFieldType.SECTION_PRIORITY
|
||||
|| props.field.field_type === FormFieldType.FILE_UPLOAD
|
||||
|| props.field.field_type === FormFieldType.IMAGE_UPLOAD
|
||||
|| props.field.field_type === FormFieldType.SIGNATURE
|
||||
|| props.field.field_type === FormFieldType.TABLE_ROWS
|
||||
|| props.field.field_type === FormFieldType.DATETIME,
|
||||
)
|
||||
|
||||
function onUpdate(v: unknown): void {
|
||||
emit('update:modelValue', v)
|
||||
}
|
||||
|
||||
function onBlur(): void {
|
||||
emit('blur')
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<VCol
|
||||
v-if="visible"
|
||||
cols="12"
|
||||
:sm="smCols"
|
||||
>
|
||||
<div :id="`ff-${field.slug}`">
|
||||
<template v-if="isStubbed">
|
||||
<VAlert
|
||||
type="info"
|
||||
variant="tonal"
|
||||
density="comfortable"
|
||||
>
|
||||
<div class="text-subtitle-2 mb-1">
|
||||
{{ field.label }}
|
||||
</div>
|
||||
<div class="text-body-2">
|
||||
Dit veldtype wordt binnenkort ondersteund.
|
||||
</div>
|
||||
</VAlert>
|
||||
</template>
|
||||
|
||||
<FieldText
|
||||
v-else-if="field.field_type === FormFieldType.TEXT"
|
||||
:field="field"
|
||||
:model-value="modelValue"
|
||||
:error-messages="errorMessages"
|
||||
@update:model-value="onUpdate"
|
||||
@blur="onBlur"
|
||||
/>
|
||||
|
||||
<FieldTextarea
|
||||
v-else-if="field.field_type === FormFieldType.TEXTAREA"
|
||||
:field="field"
|
||||
:model-value="modelValue"
|
||||
:error-messages="errorMessages"
|
||||
@update:model-value="onUpdate"
|
||||
@blur="onBlur"
|
||||
/>
|
||||
|
||||
<FieldEmail
|
||||
v-else-if="field.field_type === FormFieldType.EMAIL"
|
||||
:field="field"
|
||||
:model-value="modelValue"
|
||||
:error-messages="errorMessages"
|
||||
@update:model-value="onUpdate"
|
||||
@blur="onBlur"
|
||||
/>
|
||||
|
||||
<FieldPhone
|
||||
v-else-if="field.field_type === FormFieldType.PHONE"
|
||||
:field="field"
|
||||
:model-value="modelValue"
|
||||
:error-messages="errorMessages"
|
||||
@update:model-value="onUpdate"
|
||||
@blur="onBlur"
|
||||
/>
|
||||
|
||||
<FieldNumber
|
||||
v-else-if="field.field_type === FormFieldType.NUMBER"
|
||||
:field="field"
|
||||
:model-value="modelValue"
|
||||
:error-messages="errorMessages"
|
||||
@update:model-value="onUpdate"
|
||||
@blur="onBlur"
|
||||
/>
|
||||
|
||||
<FieldDate
|
||||
v-else-if="field.field_type === FormFieldType.DATE"
|
||||
:field="field"
|
||||
:model-value="modelValue"
|
||||
:error-messages="errorMessages"
|
||||
@update:model-value="onUpdate"
|
||||
@blur="onBlur"
|
||||
/>
|
||||
|
||||
<FieldBoolean
|
||||
v-else-if="field.field_type === FormFieldType.BOOLEAN"
|
||||
:field="field"
|
||||
:model-value="modelValue"
|
||||
:error-messages="errorMessages"
|
||||
@update:model-value="onUpdate"
|
||||
@blur="onBlur"
|
||||
/>
|
||||
|
||||
<FieldRadio
|
||||
v-else-if="field.field_type === FormFieldType.RADIO"
|
||||
:field="field"
|
||||
:model-value="modelValue"
|
||||
:error-messages="errorMessages"
|
||||
@update:model-value="onUpdate"
|
||||
@blur="onBlur"
|
||||
/>
|
||||
|
||||
<FieldSelect
|
||||
v-else-if="field.field_type === FormFieldType.SELECT"
|
||||
:field="field"
|
||||
:model-value="modelValue"
|
||||
:error-messages="errorMessages"
|
||||
@update:model-value="onUpdate"
|
||||
@blur="onBlur"
|
||||
/>
|
||||
|
||||
<FieldMultiselect
|
||||
v-else-if="field.field_type === FormFieldType.MULTISELECT"
|
||||
:field="field"
|
||||
:model-value="modelValue"
|
||||
:error-messages="errorMessages"
|
||||
@update:model-value="onUpdate"
|
||||
@blur="onBlur"
|
||||
/>
|
||||
|
||||
<FieldCheckboxList
|
||||
v-else-if="field.field_type === FormFieldType.CHECKBOX_LIST"
|
||||
:field="field"
|
||||
:model-value="modelValue"
|
||||
:error-messages="errorMessages"
|
||||
@update:model-value="onUpdate"
|
||||
@blur="onBlur"
|
||||
/>
|
||||
|
||||
<FieldHeading
|
||||
v-else-if="field.field_type === FormFieldType.HEADING"
|
||||
:field="field"
|
||||
/>
|
||||
|
||||
<FieldParagraph
|
||||
v-else-if="field.field_type === FormFieldType.PARAGRAPH"
|
||||
:field="field"
|
||||
/>
|
||||
|
||||
<FieldUrl
|
||||
v-else-if="field.field_type === FormFieldType.URL"
|
||||
:field="field"
|
||||
:model-value="modelValue"
|
||||
:error-messages="errorMessages"
|
||||
@update:model-value="onUpdate"
|
||||
@blur="onBlur"
|
||||
/>
|
||||
</div>
|
||||
</VCol>
|
||||
</template>
|
||||
65
apps/portal/src/components/public-form/FieldSelect.vue
Normal file
65
apps/portal/src/components/public-form/FieldSelect.vue
Normal file
@@ -0,0 +1,65 @@
|
||||
<script setup lang="ts">
|
||||
import type { FieldOption, PublicFormField } from '@/types/formBuilder'
|
||||
import { getValidatorsForField } from '@/utils/formValidation'
|
||||
|
||||
const props = defineProps<{
|
||||
field: PublicFormField
|
||||
modelValue: unknown
|
||||
errorMessages?: string[]
|
||||
}>()
|
||||
|
||||
const emit = defineEmits<{
|
||||
(e: 'update:modelValue', v: string | null): void
|
||||
(e: 'blur'): void
|
||||
}>()
|
||||
|
||||
interface NormalizedOption {
|
||||
title: string
|
||||
description?: string | null
|
||||
value: string
|
||||
}
|
||||
|
||||
function normalize(opt: FieldOption): NormalizedOption {
|
||||
if (typeof opt === 'string') return { title: opt, value: opt }
|
||||
const value = opt.value != null ? String(opt.value) : opt.label
|
||||
|
||||
return { title: opt.label, description: opt.description ?? null, value }
|
||||
}
|
||||
|
||||
const items = computed<NormalizedOption[]>(() => (props.field.options ?? []).map(normalize))
|
||||
const rules = computed(() => getValidatorsForField(props.field))
|
||||
|
||||
const model = computed({
|
||||
get: () => (props.modelValue ?? null) as string | null,
|
||||
set: (v: string | null) => emit('update:modelValue', v),
|
||||
})
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<AppSelect
|
||||
v-model="model"
|
||||
:items="items"
|
||||
item-title="title"
|
||||
item-value="value"
|
||||
:label="field.label"
|
||||
:hint="field.help_text ?? undefined"
|
||||
persistent-hint
|
||||
:clearable="!field.is_required"
|
||||
:rules="rules"
|
||||
:error-messages="errorMessages"
|
||||
:required="field.is_required"
|
||||
@update:model-value="emit('blur')"
|
||||
@blur="emit('blur')"
|
||||
>
|
||||
<template #item="{ props: itemProps, item }">
|
||||
<VListItem v-bind="itemProps">
|
||||
<template
|
||||
v-if="item.raw.description"
|
||||
#subtitle
|
||||
>
|
||||
{{ item.raw.description }}
|
||||
</template>
|
||||
</VListItem>
|
||||
</template>
|
||||
</AppSelect>
|
||||
</template>
|
||||
35
apps/portal/src/components/public-form/FieldText.vue
Normal file
35
apps/portal/src/components/public-form/FieldText.vue
Normal file
@@ -0,0 +1,35 @@
|
||||
<script setup lang="ts">
|
||||
import type { PublicFormField } from '@/types/formBuilder'
|
||||
import { getValidatorsForField } from '@/utils/formValidation'
|
||||
|
||||
const props = defineProps<{
|
||||
field: PublicFormField
|
||||
modelValue: unknown
|
||||
errorMessages?: string[]
|
||||
}>()
|
||||
|
||||
const emit = defineEmits<{
|
||||
(e: 'update:modelValue', v: string): void
|
||||
(e: 'blur'): void
|
||||
}>()
|
||||
|
||||
const rules = computed(() => getValidatorsForField(props.field))
|
||||
const model = computed({
|
||||
get: () => (props.modelValue ?? '') as string,
|
||||
set: (v: string) => emit('update:modelValue', v),
|
||||
})
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<AppTextField
|
||||
v-model="model"
|
||||
:label="field.label"
|
||||
:hint="field.help_text ?? undefined"
|
||||
persistent-hint
|
||||
:rules="rules"
|
||||
:error-messages="errorMessages"
|
||||
:required="field.is_required"
|
||||
:maxlength="field.validation_rules?.max ?? undefined"
|
||||
@blur="emit('blur')"
|
||||
/>
|
||||
</template>
|
||||
37
apps/portal/src/components/public-form/FieldTextarea.vue
Normal file
37
apps/portal/src/components/public-form/FieldTextarea.vue
Normal file
@@ -0,0 +1,37 @@
|
||||
<script setup lang="ts">
|
||||
import type { PublicFormField } from '@/types/formBuilder'
|
||||
import { getValidatorsForField } from '@/utils/formValidation'
|
||||
|
||||
const props = defineProps<{
|
||||
field: PublicFormField
|
||||
modelValue: unknown
|
||||
errorMessages?: string[]
|
||||
}>()
|
||||
|
||||
const emit = defineEmits<{
|
||||
(e: 'update:modelValue', v: string): void
|
||||
(e: 'blur'): void
|
||||
}>()
|
||||
|
||||
const rules = computed(() => getValidatorsForField(props.field))
|
||||
const model = computed({
|
||||
get: () => (props.modelValue ?? '') as string,
|
||||
set: (v: string) => emit('update:modelValue', v),
|
||||
})
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<AppTextarea
|
||||
v-model="model"
|
||||
:label="field.label"
|
||||
:hint="field.help_text ?? undefined"
|
||||
persistent-hint
|
||||
:rows="4"
|
||||
auto-grow
|
||||
:rules="rules"
|
||||
:error-messages="errorMessages"
|
||||
:required="field.is_required"
|
||||
:maxlength="field.validation_rules?.max ?? undefined"
|
||||
@blur="emit('blur')"
|
||||
/>
|
||||
</template>
|
||||
38
apps/portal/src/components/public-form/FieldUrl.vue
Normal file
38
apps/portal/src/components/public-form/FieldUrl.vue
Normal file
@@ -0,0 +1,38 @@
|
||||
<script setup lang="ts">
|
||||
import type { PublicFormField } from '@/types/formBuilder'
|
||||
import { getValidatorsForField } from '@/utils/formValidation'
|
||||
|
||||
const props = defineProps<{
|
||||
field: PublicFormField
|
||||
modelValue: unknown
|
||||
errorMessages?: string[]
|
||||
}>()
|
||||
|
||||
const emit = defineEmits<{
|
||||
(e: 'update:modelValue', v: string): void
|
||||
(e: 'blur'): void
|
||||
}>()
|
||||
|
||||
const rules = computed(() => getValidatorsForField(props.field))
|
||||
const model = computed({
|
||||
get: () => (props.modelValue ?? '') as string,
|
||||
set: (v: string) => emit('update:modelValue', v),
|
||||
})
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<AppTextField
|
||||
v-model="model"
|
||||
type="url"
|
||||
inputmode="url"
|
||||
placeholder="https://..."
|
||||
:label="field.label"
|
||||
:hint="field.help_text ?? undefined"
|
||||
persistent-hint
|
||||
prepend-inner-icon="tabler-link"
|
||||
:rules="rules"
|
||||
:error-messages="errorMessages"
|
||||
:required="field.is_required"
|
||||
@blur="emit('blur')"
|
||||
/>
|
||||
</template>
|
||||
114
apps/portal/src/components/public-form/FormConfirmation.vue
Normal file
114
apps/portal/src/components/public-form/FormConfirmation.vue
Normal file
@@ -0,0 +1,114 @@
|
||||
<script setup lang="ts">
|
||||
import type { FormStep } from '@/composables/useFormSteps'
|
||||
import { FormFieldType } from '@/types/formBuilder'
|
||||
import type { FormValues, PublicFormField } from '@/types/formBuilder'
|
||||
|
||||
const props = defineProps<{
|
||||
steps: FormStep[]
|
||||
values: FormValues
|
||||
submitterName?: string
|
||||
submitterEmail?: string
|
||||
}>()
|
||||
|
||||
function displayValue(field: PublicFormField): string {
|
||||
const v = props.values[field.slug]
|
||||
if (v === null || v === undefined || v === '') return '—'
|
||||
if (Array.isArray(v)) return v.length > 0 ? v.map(String).join(', ') : '—'
|
||||
if (typeof v === 'boolean') return v ? 'Ja' : 'Nee'
|
||||
|
||||
return String(v)
|
||||
}
|
||||
|
||||
function isAnswerable(field: PublicFormField): boolean {
|
||||
return field.field_type !== FormFieldType.HEADING
|
||||
&& field.field_type !== FormFieldType.PARAGRAPH
|
||||
}
|
||||
|
||||
function answerableFields(step: FormStep): PublicFormField[] {
|
||||
return step.fields.filter(isAnswerable)
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="d-flex justify-center pa-4">
|
||||
<VCard
|
||||
flat
|
||||
:max-width="720"
|
||||
class="w-100"
|
||||
>
|
||||
<VCardText class="text-center pa-8">
|
||||
<VIcon
|
||||
icon="tabler-check"
|
||||
color="success"
|
||||
size="64"
|
||||
class="mb-4"
|
||||
/>
|
||||
<h2 class="text-h4 mb-2">
|
||||
Bedankt voor je inzending!
|
||||
</h2>
|
||||
<p class="text-body-1 text-medium-emphasis mb-0">
|
||||
We nemen zo snel mogelijk contact op.
|
||||
</p>
|
||||
</VCardText>
|
||||
|
||||
<VDivider />
|
||||
|
||||
<VCardText class="pa-6">
|
||||
<h3 class="text-subtitle-1 font-weight-medium mb-3">
|
||||
Contactgegevens
|
||||
</h3>
|
||||
<VRow>
|
||||
<VCol
|
||||
cols="12"
|
||||
sm="6"
|
||||
>
|
||||
<p class="text-caption text-medium-emphasis mb-1">
|
||||
Naam
|
||||
</p>
|
||||
<p class="text-body-2 mb-0">
|
||||
{{ submitterName || '—' }}
|
||||
</p>
|
||||
</VCol>
|
||||
<VCol
|
||||
cols="12"
|
||||
sm="6"
|
||||
>
|
||||
<p class="text-caption text-medium-emphasis mb-1">
|
||||
E-mailadres
|
||||
</p>
|
||||
<p class="text-body-2 mb-0">
|
||||
{{ submitterEmail || '—' }}
|
||||
</p>
|
||||
</VCol>
|
||||
</VRow>
|
||||
|
||||
<template
|
||||
v-for="step in steps"
|
||||
:key="step.key"
|
||||
>
|
||||
<template v-if="step.kind !== 'submitter' && step.kind !== 'review' && answerableFields(step).length > 0">
|
||||
<VDivider class="my-5" />
|
||||
<h3 class="text-subtitle-1 font-weight-medium mb-3">
|
||||
{{ step.title }}
|
||||
</h3>
|
||||
<VRow>
|
||||
<VCol
|
||||
v-for="field in answerableFields(step)"
|
||||
:key="field.id"
|
||||
cols="12"
|
||||
sm="6"
|
||||
>
|
||||
<p class="text-caption text-medium-emphasis mb-1">
|
||||
{{ field.label }}
|
||||
</p>
|
||||
<p class="text-body-2 mb-0">
|
||||
{{ displayValue(field) }}
|
||||
</p>
|
||||
</VCol>
|
||||
</VRow>
|
||||
</template>
|
||||
</template>
|
||||
</VCardText>
|
||||
</VCard>
|
||||
</div>
|
||||
</template>
|
||||
118
apps/portal/src/components/public-form/FormErrorState.vue
Normal file
118
apps/portal/src/components/public-form/FormErrorState.vue
Normal file
@@ -0,0 +1,118 @@
|
||||
<script setup lang="ts">
|
||||
import type { FormErrorCode } from '@/types/formBuilder'
|
||||
|
||||
const props = defineProps<{
|
||||
errorCode?: FormErrorCode | string
|
||||
message?: string
|
||||
showRetry?: boolean
|
||||
}>()
|
||||
|
||||
const emit = defineEmits<{
|
||||
(e: 'retry'): void
|
||||
}>()
|
||||
|
||||
interface Copy {
|
||||
heading: string
|
||||
body: string
|
||||
icon: string
|
||||
iconColor: string
|
||||
terminal: boolean
|
||||
}
|
||||
|
||||
const COPY: Record<string, Copy> = {
|
||||
TOKEN_EXPIRED: {
|
||||
heading: 'Deze link is verlopen',
|
||||
body: 'Deze link is verlopen. Vraag de organisatie om een nieuwe.',
|
||||
icon: 'tabler-clock-off',
|
||||
iconColor: 'warning',
|
||||
terminal: true,
|
||||
},
|
||||
TOKEN_REVOKED: {
|
||||
heading: 'Deze link is ingetrokken',
|
||||
body: 'Deze link is ingetrokken. Vraag de organisatie om een nieuwe.',
|
||||
icon: 'tabler-lock',
|
||||
iconColor: 'warning',
|
||||
terminal: true,
|
||||
},
|
||||
SCHEMA_UNPUBLISHED: {
|
||||
heading: 'Formulier niet open',
|
||||
body: 'Dit formulier is op dit moment niet open voor inzendingen.',
|
||||
icon: 'tabler-file-off',
|
||||
iconColor: 'warning',
|
||||
terminal: true,
|
||||
},
|
||||
SCHEMA_NOT_FOUND: {
|
||||
heading: 'Formulier niet gevonden',
|
||||
body: 'Dit formulier bestaat niet.',
|
||||
icon: 'tabler-alert-circle',
|
||||
iconColor: 'error',
|
||||
terminal: true,
|
||||
},
|
||||
SUBMISSION_ALREADY_SUBMITTED: {
|
||||
heading: 'Al verstuurd',
|
||||
body: 'Je hebt dit formulier al verstuurd. Bedankt!',
|
||||
icon: 'tabler-check',
|
||||
iconColor: 'success',
|
||||
terminal: true,
|
||||
},
|
||||
RATE_LIMITED: {
|
||||
heading: 'Even geduld',
|
||||
body: 'Te veel verzoeken. Probeer het over een minuut opnieuw.',
|
||||
icon: 'tabler-hourglass',
|
||||
iconColor: 'warning',
|
||||
terminal: false,
|
||||
},
|
||||
}
|
||||
|
||||
const DEFAULT_COPY: Copy = {
|
||||
heading: 'Er ging iets mis',
|
||||
body: 'Er ging iets mis. Probeer het opnieuw of neem contact op met de organisatie.',
|
||||
icon: 'tabler-alert-circle',
|
||||
iconColor: 'error',
|
||||
terminal: false,
|
||||
}
|
||||
|
||||
const copy = computed<Copy>(() => {
|
||||
const code = props.errorCode
|
||||
if (code && code in COPY) return COPY[code]
|
||||
|
||||
return DEFAULT_COPY
|
||||
})
|
||||
|
||||
const bodyText = computed(() => props.message ?? copy.value.body)
|
||||
const canRetry = computed(() => props.showRetry !== false && !copy.value.terminal)
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div
|
||||
class="d-flex align-center justify-center pa-4"
|
||||
style="min-block-size: 60vh;"
|
||||
>
|
||||
<VCard
|
||||
flat
|
||||
:max-width="480"
|
||||
class="text-center pa-8"
|
||||
>
|
||||
<VIcon
|
||||
:icon="copy.icon"
|
||||
:color="copy.iconColor"
|
||||
size="64"
|
||||
class="mb-4"
|
||||
/>
|
||||
<h2 class="text-h5 mb-2">
|
||||
{{ copy.heading }}
|
||||
</h2>
|
||||
<p class="text-body-1 text-medium-emphasis mb-6">
|
||||
{{ bodyText }}
|
||||
</p>
|
||||
<VBtn
|
||||
v-if="canRetry"
|
||||
variant="tonal"
|
||||
color="primary"
|
||||
@click="emit('retry')"
|
||||
>
|
||||
Opnieuw proberen
|
||||
</VBtn>
|
||||
</VCard>
|
||||
</div>
|
||||
</template>
|
||||
40
apps/portal/src/components/public-form/FormStepper.vue
Normal file
40
apps/portal/src/components/public-form/FormStepper.vue
Normal file
@@ -0,0 +1,40 @@
|
||||
<script setup lang="ts">
|
||||
import { useDisplay } from 'vuetify'
|
||||
import type { FormStep } from '@/composables/useFormSteps'
|
||||
|
||||
const props = defineProps<{
|
||||
steps: FormStep[]
|
||||
currentStep: number
|
||||
isActiveStepValid: boolean
|
||||
}>()
|
||||
|
||||
const emit = defineEmits<{
|
||||
(e: 'update:currentStep', v: number): void
|
||||
}>()
|
||||
|
||||
const { mdAndUp } = useDisplay()
|
||||
|
||||
const direction = computed<'horizontal' | 'vertical'>(() => (mdAndUp.value ? 'horizontal' : 'vertical'))
|
||||
|
||||
const items = computed(() => props.steps.map(s => ({
|
||||
title: s.title,
|
||||
subtitle: s.subtitle,
|
||||
})))
|
||||
|
||||
function go(value: number): void {
|
||||
if (value > props.currentStep && !props.isActiveStepValid) return
|
||||
if (value < 0 || value >= props.steps.length) return
|
||||
emit('update:currentStep', value)
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<AppStepper
|
||||
:items="items"
|
||||
:current-step="currentStep"
|
||||
:direction="direction"
|
||||
:is-active-step-valid="isActiveStepValid"
|
||||
align="start"
|
||||
@update:current-step="go"
|
||||
/>
|
||||
</template>
|
||||
80
apps/portal/src/components/public-form/SubmitterDetails.vue
Normal file
80
apps/portal/src/components/public-form/SubmitterDetails.vue
Normal file
@@ -0,0 +1,80 @@
|
||||
<script setup lang="ts">
|
||||
import { emailValidator, requiredValidator } from '@core/utils/validators'
|
||||
|
||||
const props = defineProps<{
|
||||
name: string
|
||||
email: string
|
||||
errors?: { name?: string; email?: string }
|
||||
}>()
|
||||
|
||||
const emit = defineEmits<{
|
||||
(e: 'update:name', v: string): void
|
||||
(e: 'update:email', v: string): void
|
||||
(e: 'blur'): void
|
||||
}>()
|
||||
|
||||
const nameModel = computed({
|
||||
get: () => props.name,
|
||||
set: v => emit('update:name', v),
|
||||
})
|
||||
const emailModel = computed({
|
||||
get: () => props.email,
|
||||
set: v => emit('update:email', v),
|
||||
})
|
||||
|
||||
const nameRules = [
|
||||
(v: unknown) => (requiredValidator(v) === true ? true : 'Vul je naam in.'),
|
||||
(v: unknown) => (v === null || v === undefined || String(v).length <= 150 ? true : 'Maximaal 150 tekens.'),
|
||||
]
|
||||
const emailRules = [
|
||||
(v: unknown) => (requiredValidator(v) === true ? true : 'Vul je e-mailadres in.'),
|
||||
(v: unknown) => (emailValidator(v) === true ? true : 'Vul een geldig e-mailadres in.'),
|
||||
(v: unknown) => (v === null || v === undefined || String(v).length <= 255 ? true : 'Maximaal 255 tekens.'),
|
||||
]
|
||||
|
||||
const nameError = computed(() => props.errors?.name ? [props.errors.name] : [])
|
||||
const emailError = computed(() => props.errors?.email ? [props.errors.email] : [])
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<VRow>
|
||||
<VCol
|
||||
cols="12"
|
||||
sm="6"
|
||||
>
|
||||
<AppTextField
|
||||
v-model="nameModel"
|
||||
label="Naam *"
|
||||
placeholder="Voor- en achternaam"
|
||||
autocomplete="name"
|
||||
:rules="nameRules"
|
||||
:error-messages="nameError"
|
||||
:maxlength="150"
|
||||
@blur="emit('blur')"
|
||||
/>
|
||||
</VCol>
|
||||
<VCol
|
||||
cols="12"
|
||||
sm="6"
|
||||
>
|
||||
<AppTextField
|
||||
v-model="emailModel"
|
||||
type="email"
|
||||
inputmode="email"
|
||||
autocomplete="email"
|
||||
label="E-mailadres *"
|
||||
placeholder="je@email.nl"
|
||||
prepend-inner-icon="tabler-mail"
|
||||
:rules="emailRules"
|
||||
:error-messages="emailError"
|
||||
:maxlength="255"
|
||||
@blur="emit('blur')"
|
||||
/>
|
||||
</VCol>
|
||||
<VCol cols="12">
|
||||
<p class="text-caption text-medium-emphasis mb-0">
|
||||
We gebruiken je gegevens alleen om contact op te nemen over deze aanvraag.
|
||||
</p>
|
||||
</VCol>
|
||||
</VRow>
|
||||
</template>
|
||||
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
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
149
apps/portal/src/utils/formValidation.ts
Normal file
149
apps/portal/src/utils/formValidation.ts
Normal 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
|
||||
}
|
||||
Reference in New Issue
Block a user