feat(portal): migrate option consumers to relational rich shape

Aligns the portal form renderer with the post-WS-5d snapshot + resource
contract. FieldRadio, FieldSelect, FieldMultiselect, and FieldCheckboxList
now consume options as arrays of {value, label, sort_order, translations?}
objects instead of the legacy string | {label, description, value?} union.

Locale resolution: option-level translations[locale] is preferred over
the default label when the active form locale is non-default. Pages
provide the locale via providePublicFormLocale (new helper in
publicFormInjection, mirrors providePublicFormToken). Field components
inject via usePublicFormLocale, which falls back to 'nl' when no
provider is on the tree — keeps standalone component tests light.
[public_token].vue now provides schemaQuery.data.locale ?? 'nl' to all
option-bearing renderers.

TypeScript types updated: PublicFormField.options is now OptionSpec[] |
null in @form-schema/types/formBuilder. The legacy `FieldOption` union
type is gone — passing strings or {label, description} would now fail
type-check. resolveOptionLabel(option, locale) helper exported from the
same module is the single source of truth for label resolution.

The legacy per-option `description` field is dropped as part of the
type narrowing — ARCH §5.1's option-bearing field types
(RADIO/SELECT/MULTISELECT/CHECKBOX_LIST) don't model descriptions; the
parallel RegistrationFieldTemplate domain in apps/app keeps its own
description support which is orthogonal and out of WS-5d scope. The 4
migrated components no longer render the description subtitle/paragraph
(both Vuetify item slots and the radio/checkbox custom #label slots
removed).

apps/app is NOT touched in this commit — its only options-reading
components (RegistrationField*.vue) consume the legacy
registration_field_templates / registration_form_fields domain and are
out of WS-5d scope. The commit-3 secondary filter-registry scan
returned zero portal+app consumers as predicted, so commit 4 stays
portal-only.

Vitest: 102 → 111 passed (+9 new tests in FieldOptionsLocale.spec.ts
covering preference of translations[locale] over label, fallback on
missing translation, and default-locale-no-provider fall-through, for
each of the four migrated components plus a no-provider sanity test).
The 2 pre-existing failures in FieldSectionPriority.spec.ts (stale
post-WS-5b max_priorities → max_selected references) are out of WS-5d
scope; the failure baseline is unchanged.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-04-25 02:50:33 +02:00
parent bb9242fd6e
commit dd7dfe9c0b
9 changed files with 273 additions and 140 deletions

View File

@@ -10,6 +10,7 @@ declare global {
const COOKIE_MAX_AGE_1_YEAR: typeof import('./src/utils/constants')['COOKIE_MAX_AGE_1_YEAR']
const CreateUrl: typeof import('./src/@core/composable/CreateUrl')['CreateUrl']
const EffectScope: typeof import('vue')['EffectScope']
const PUBLIC_FORM_LOCALE_KEY: typeof import('./src/composables/publicFormInjection')['PUBLIC_FORM_LOCALE_KEY']
const PUBLIC_FORM_TOKEN_KEY: typeof import('./src/composables/publicFormInjection')['PUBLIC_FORM_TOKEN_KEY']
const acceptHMRUpdate: typeof import('pinia')['acceptHMRUpdate']
const alphaDashValidator: typeof import('./src/@core/utils/validators')['alphaDashValidator']
@@ -123,6 +124,7 @@ declare global {
const prefixWithPlus: typeof import('./src/@core/utils/formatters')['prefixWithPlus']
const provide: typeof import('vue')['provide']
const provideLocal: typeof import('@vueuse/core')['provideLocal']
const providePublicFormLocale: typeof import('./src/composables/publicFormInjection')['providePublicFormLocale']
const providePublicFormToken: typeof import('./src/composables/publicFormInjection')['providePublicFormToken']
const reactify: typeof import('@vueuse/core')['reactify']
const reactifyObject: typeof import('@vueuse/core')['reactifyObject']
@@ -301,6 +303,7 @@ declare global {
const usePreferredReducedMotion: typeof import('@vueuse/core')['usePreferredReducedMotion']
const usePrevious: typeof import('@vueuse/core')['usePrevious']
const useProjection: typeof import('@vueuse/math')['useProjection']
const usePublicFormLocale: typeof import('./src/composables/publicFormInjection')['usePublicFormLocale']
const usePublicFormToken: typeof import('./src/composables/publicFormInjection')['usePublicFormToken']
const useRafFn: typeof import('@vueuse/core')['useRafFn']
const useRefHistory: typeof import('@vueuse/core')['useRefHistory']
@@ -393,8 +396,8 @@ declare module 'vue' {
interface GlobalComponents {}
interface ComponentCustomProperties {
readonly EffectScope: UnwrapRef<typeof import('vue')['EffectScope']>
readonly PUBLIC_FORM_LOCALE_KEY: UnwrapRef<typeof import('./src/composables/publicFormInjection')['PUBLIC_FORM_LOCALE_KEY']>
readonly PUBLIC_FORM_TOKEN_KEY: UnwrapRef<typeof import('./src/composables/publicFormInjection')['PUBLIC_FORM_TOKEN_KEY']>
readonly acceptHMRUpdate: UnwrapRef<typeof import('pinia')['acceptHMRUpdate']>
readonly alphaDashValidator: UnwrapRef<typeof import('./src/@core/utils/validators')['alphaDashValidator']>
readonly alphaValidator: UnwrapRef<typeof import('./src/@core/utils/validators')['alphaValidator']>
readonly asyncComputed: UnwrapRef<typeof import('@vueuse/core')['asyncComputed']>
@@ -411,11 +414,8 @@ declare module 'vue' {
readonly controlledRef: UnwrapRef<typeof import('@vueuse/core')['controlledRef']>
readonly createApp: UnwrapRef<typeof import('vue')['createApp']>
readonly createEventHook: UnwrapRef<typeof import('@vueuse/core')['createEventHook']>
readonly createGenericProjection: UnwrapRef<typeof import('@vueuse/math')['createGenericProjection']>
readonly createGlobalState: UnwrapRef<typeof import('@vueuse/core')['createGlobalState']>
readonly createInjectionState: UnwrapRef<typeof import('@vueuse/core')['createInjectionState']>
readonly createPinia: UnwrapRef<typeof import('pinia')['createPinia']>
readonly createProjection: UnwrapRef<typeof import('@vueuse/math')['createProjection']>
readonly createReactiveFn: UnwrapRef<typeof import('@vueuse/core')['createReactiveFn']>
readonly createReusableTemplate: UnwrapRef<typeof import('@vueuse/core')['createReusableTemplate']>
readonly createSharedComposable: UnwrapRef<typeof import('@vueuse/core')['createSharedComposable']>
@@ -427,8 +427,6 @@ declare module 'vue' {
readonly debouncedWatch: UnwrapRef<typeof import('@vueuse/core')['debouncedWatch']>
readonly defineAsyncComponent: UnwrapRef<typeof import('vue')['defineAsyncComponent']>
readonly defineComponent: UnwrapRef<typeof import('vue')['defineComponent']>
readonly definePage: UnwrapRef<typeof import('unplugin-vue-router/runtime')['definePage']>
readonly defineStore: UnwrapRef<typeof import('pinia')['defineStore']>
readonly draftIdempotencyKey: UnwrapRef<typeof import('./src/composables/useFormDraft')['draftIdempotencyKey']>
readonly draftSubmitterStorageKey: UnwrapRef<typeof import('./src/composables/useFormDraft')['draftSubmitterStorageKey']>
readonly eagerComputed: UnwrapRef<typeof import('@vueuse/core')['eagerComputed']>
@@ -440,7 +438,6 @@ declare module 'vue' {
readonly formatDate: UnwrapRef<typeof import('./src/@core/utils/formatters')['formatDate']>
readonly formatDateToMonthShort: UnwrapRef<typeof import('./src/@core/utils/formatters')['formatDateToMonthShort']>
readonly generateDeviceFingerprint: UnwrapRef<typeof import('./src/utils/deviceFingerprint')['generateDeviceFingerprint']>
readonly getActivePinia: UnwrapRef<typeof import('pinia')['getActivePinia']>
readonly getCurrentInstance: UnwrapRef<typeof import('vue')['getCurrentInstance']>
readonly getCurrentScope: UnwrapRef<typeof import('vue')['getCurrentScope']>
readonly getDeviceName: UnwrapRef<typeof import('./src/utils/deviceFingerprint')['getDeviceName']>
@@ -462,21 +459,11 @@ declare module 'vue' {
readonly isToday: UnwrapRef<typeof import('./src/@core/utils/helpers')['isToday']>
readonly kFormatter: UnwrapRef<typeof import('./src/@core/utils/formatters')['kFormatter']>
readonly lengthValidator: UnwrapRef<typeof import('./src/@core/utils/validators')['lengthValidator']>
readonly logicAnd: UnwrapRef<typeof import('@vueuse/math')['logicAnd']>
readonly logicNot: UnwrapRef<typeof import('@vueuse/math')['logicNot']>
readonly logicOr: UnwrapRef<typeof import('@vueuse/math')['logicOr']>
readonly makeDestructurable: UnwrapRef<typeof import('@vueuse/core')['makeDestructurable']>
readonly mapActions: UnwrapRef<typeof import('pinia')['mapActions']>
readonly mapGetters: UnwrapRef<typeof import('pinia')['mapGetters']>
readonly mapState: UnwrapRef<typeof import('pinia')['mapState']>
readonly mapStores: UnwrapRef<typeof import('pinia')['mapStores']>
readonly mapWritableState: UnwrapRef<typeof import('pinia')['mapWritableState']>
readonly markRaw: UnwrapRef<typeof import('vue')['markRaw']>
readonly nextTick: UnwrapRef<typeof import('vue')['nextTick']>
readonly onActivated: UnwrapRef<typeof import('vue')['onActivated']>
readonly onBeforeMount: UnwrapRef<typeof import('vue')['onBeforeMount']>
readonly onBeforeRouteLeave: UnwrapRef<typeof import('vue-router/auto')['onBeforeRouteLeave']>
readonly onBeforeRouteUpdate: UnwrapRef<typeof import('vue-router/auto')['onBeforeRouteUpdate']>
readonly onBeforeUnmount: UnwrapRef<typeof import('vue')['onBeforeUnmount']>
readonly onBeforeUpdate: UnwrapRef<typeof import('vue')['onBeforeUpdate']>
readonly onClickOutside: UnwrapRef<typeof import('@vueuse/core')['onClickOutside']>
@@ -499,6 +486,7 @@ declare module 'vue' {
readonly prefixWithPlus: UnwrapRef<typeof import('./src/@core/utils/formatters')['prefixWithPlus']>
readonly provide: UnwrapRef<typeof import('vue')['provide']>
readonly provideLocal: UnwrapRef<typeof import('@vueuse/core')['provideLocal']>
readonly providePublicFormLocale: UnwrapRef<typeof import('./src/composables/publicFormInjection')['providePublicFormLocale']>
readonly providePublicFormToken: UnwrapRef<typeof import('./src/composables/publicFormInjection')['providePublicFormToken']>
readonly reactify: UnwrapRef<typeof import('@vueuse/core')['reactify']>
readonly reactifyObject: UnwrapRef<typeof import('@vueuse/core')['reactifyObject']>
@@ -521,12 +509,9 @@ declare module 'vue' {
readonly resolveUnref: UnwrapRef<typeof import('@vueuse/core')['resolveUnref']>
readonly resolveVuetifyTheme: UnwrapRef<typeof import('./src/@core/utils/vuetify')['resolveVuetifyTheme']>
readonly rgbaToHex: UnwrapRef<typeof import('./src/@core/utils/colorConverter')['rgbaToHex']>
readonly setActivePinia: UnwrapRef<typeof import('pinia')['setActivePinia']>
readonly setMapStoreSuffix: UnwrapRef<typeof import('pinia')['setMapStoreSuffix']>
readonly shallowReactive: UnwrapRef<typeof import('vue')['shallowReactive']>
readonly shallowReadonly: UnwrapRef<typeof import('vue')['shallowReadonly']>
readonly shallowRef: UnwrapRef<typeof import('vue')['shallowRef']>
readonly storeToRefs: UnwrapRef<typeof import('pinia')['storeToRefs']>
readonly syncRef: UnwrapRef<typeof import('@vueuse/core')['syncRef']>
readonly syncRefs: UnwrapRef<typeof import('@vueuse/core')['syncRefs']>
readonly templateRef: UnwrapRef<typeof import('@vueuse/core')['templateRef']>
@@ -547,7 +532,6 @@ declare module 'vue' {
readonly unrefElement: UnwrapRef<typeof import('@vueuse/core')['unrefElement']>
readonly until: UnwrapRef<typeof import('@vueuse/core')['until']>
readonly urlValidator: UnwrapRef<typeof import('./src/@core/utils/validators')['urlValidator']>
readonly useAbs: UnwrapRef<typeof import('@vueuse/math')['useAbs']>
readonly useActiveElement: UnwrapRef<typeof import('@vueuse/core')['useActiveElement']>
readonly useAnimate: UnwrapRef<typeof import('@vueuse/core')['useAnimate']>
readonly useArrayDifference: UnwrapRef<typeof import('@vueuse/core')['useArrayDifference']>
@@ -565,7 +549,6 @@ declare module 'vue' {
readonly useAsyncQueue: UnwrapRef<typeof import('@vueuse/core')['useAsyncQueue']>
readonly useAsyncState: UnwrapRef<typeof import('@vueuse/core')['useAsyncState']>
readonly useAttrs: UnwrapRef<typeof import('vue')['useAttrs']>
readonly useAverage: UnwrapRef<typeof import('@vueuse/math')['useAverage']>
readonly useBase64: UnwrapRef<typeof import('@vueuse/core')['useBase64']>
readonly useBattery: UnwrapRef<typeof import('@vueuse/core')['useBattery']>
readonly useBluetooth: UnwrapRef<typeof import('@vueuse/core')['useBluetooth']>
@@ -573,8 +556,6 @@ declare module 'vue' {
readonly useBroadcastChannel: UnwrapRef<typeof import('@vueuse/core')['useBroadcastChannel']>
readonly useBrowserLocation: UnwrapRef<typeof import('@vueuse/core')['useBrowserLocation']>
readonly useCached: UnwrapRef<typeof import('@vueuse/core')['useCached']>
readonly useCeil: UnwrapRef<typeof import('@vueuse/math')['useCeil']>
readonly useClamp: UnwrapRef<typeof import('@vueuse/math')['useClamp']>
readonly useClipboard: UnwrapRef<typeof import('@vueuse/core')['useClipboard']>
readonly useClipboardItems: UnwrapRef<typeof import('@vueuse/core')['useClipboardItems']>
readonly useCloned: UnwrapRef<typeof import('@vueuse/core')['useCloned']>
@@ -613,7 +594,6 @@ declare module 'vue' {
readonly useFetch: UnwrapRef<typeof import('@vueuse/core')['useFetch']>
readonly useFileDialog: UnwrapRef<typeof import('@vueuse/core')['useFileDialog']>
readonly useFileSystemAccess: UnwrapRef<typeof import('@vueuse/core')['useFileSystemAccess']>
readonly useFloor: UnwrapRef<typeof import('@vueuse/math')['useFloor']>
readonly useFocus: UnwrapRef<typeof import('@vueuse/core')['useFocus']>
readonly useFocusWithin: UnwrapRef<typeof import('@vueuse/core')['useFocusWithin']>
readonly useFormDraft: UnwrapRef<typeof import('./src/composables/useFormDraft')['useFormDraft']>
@@ -622,7 +602,6 @@ declare module 'vue' {
readonly useGamepad: UnwrapRef<typeof import('@vueuse/core')['useGamepad']>
readonly useGenerateImageVariant: UnwrapRef<typeof import('./src/@core/composable/useGenerateImageVariant')['useGenerateImageVariant']>
readonly useGeolocation: UnwrapRef<typeof import('@vueuse/core')['useGeolocation']>
readonly useI18n: UnwrapRef<typeof import('vue-i18n')['useI18n']>
readonly useId: UnwrapRef<typeof import('vue')['useId']>
readonly useIdle: UnwrapRef<typeof import('@vueuse/core')['useIdle']>
readonly useImage: UnwrapRef<typeof import('@vueuse/core')['useImage']>
@@ -635,13 +614,10 @@ declare module 'vue' {
readonly useLocalStorage: UnwrapRef<typeof import('@vueuse/core')['useLocalStorage']>
readonly useMagicKeys: UnwrapRef<typeof import('@vueuse/core')['useMagicKeys']>
readonly useManualRefHistory: UnwrapRef<typeof import('@vueuse/core')['useManualRefHistory']>
readonly useMath: UnwrapRef<typeof import('@vueuse/math')['useMath']>
readonly useMax: UnwrapRef<typeof import('@vueuse/math')['useMax']>
readonly useMediaControls: UnwrapRef<typeof import('@vueuse/core')['useMediaControls']>
readonly useMediaQuery: UnwrapRef<typeof import('@vueuse/core')['useMediaQuery']>
readonly useMemoize: UnwrapRef<typeof import('@vueuse/core')['useMemoize']>
readonly useMemory: UnwrapRef<typeof import('@vueuse/core')['useMemory']>
readonly useMin: UnwrapRef<typeof import('@vueuse/math')['useMin']>
readonly useModel: UnwrapRef<typeof import('vue')['useModel']>
readonly useMounted: UnwrapRef<typeof import('@vueuse/core')['useMounted']>
readonly useMouse: UnwrapRef<typeof import('@vueuse/core')['useMouse']>
@@ -662,22 +638,18 @@ declare module 'vue' {
readonly usePointer: UnwrapRef<typeof import('@vueuse/core')['usePointer']>
readonly usePointerLock: UnwrapRef<typeof import('@vueuse/core')['usePointerLock']>
readonly usePointerSwipe: UnwrapRef<typeof import('@vueuse/core')['usePointerSwipe']>
readonly usePrecision: UnwrapRef<typeof import('@vueuse/math')['usePrecision']>
readonly usePreferredColorScheme: UnwrapRef<typeof import('@vueuse/core')['usePreferredColorScheme']>
readonly usePreferredContrast: UnwrapRef<typeof import('@vueuse/core')['usePreferredContrast']>
readonly usePreferredDark: UnwrapRef<typeof import('@vueuse/core')['usePreferredDark']>
readonly usePreferredLanguages: UnwrapRef<typeof import('@vueuse/core')['usePreferredLanguages']>
readonly usePreferredReducedMotion: UnwrapRef<typeof import('@vueuse/core')['usePreferredReducedMotion']>
readonly usePrevious: UnwrapRef<typeof import('@vueuse/core')['usePrevious']>
readonly useProjection: UnwrapRef<typeof import('@vueuse/math')['useProjection']>
readonly usePublicFormLocale: UnwrapRef<typeof import('./src/composables/publicFormInjection')['usePublicFormLocale']>
readonly usePublicFormToken: UnwrapRef<typeof import('./src/composables/publicFormInjection')['usePublicFormToken']>
readonly useRafFn: UnwrapRef<typeof import('@vueuse/core')['useRafFn']>
readonly useRefHistory: UnwrapRef<typeof import('@vueuse/core')['useRefHistory']>
readonly useResizeObserver: UnwrapRef<typeof import('@vueuse/core')['useResizeObserver']>
readonly useResponsiveLeftSidebar: UnwrapRef<typeof import('./src/@core/composable/useResponsiveSidebar')['useResponsiveLeftSidebar']>
readonly useRound: UnwrapRef<typeof import('@vueuse/math')['useRound']>
readonly useRoute: UnwrapRef<typeof import('vue-router/auto')['useRoute']>
readonly useRouter: UnwrapRef<typeof import('vue-router/auto')['useRouter']>
readonly useScreenOrientation: UnwrapRef<typeof import('@vueuse/core')['useScreenOrientation']>
readonly useScreenSafeArea: UnwrapRef<typeof import('@vueuse/core')['useScreenSafeArea']>
readonly useScriptTag: UnwrapRef<typeof import('@vueuse/core')['useScriptTag']>
@@ -691,9 +663,9 @@ declare module 'vue' {
readonly useSpeechRecognition: UnwrapRef<typeof import('@vueuse/core')['useSpeechRecognition']>
readonly useSpeechSynthesis: UnwrapRef<typeof import('@vueuse/core')['useSpeechSynthesis']>
readonly useStepper: UnwrapRef<typeof import('@vueuse/core')['useStepper']>
readonly useStorage: UnwrapRef<typeof import('@vueuse/core')['useStorage']>
readonly useStorageAsync: UnwrapRef<typeof import('@vueuse/core')['useStorageAsync']>
readonly useStyleTag: UnwrapRef<typeof import('@vueuse/core')['useStyleTag']>
readonly useSum: UnwrapRef<typeof import('@vueuse/math')['useSum']>
readonly useSupported: UnwrapRef<typeof import('@vueuse/core')['useSupported']>
readonly useSwipe: UnwrapRef<typeof import('@vueuse/core')['useSwipe']>
readonly useTemplateRef: UnwrapRef<typeof import('vue')['useTemplateRef']>
@@ -714,7 +686,6 @@ declare module 'vue' {
readonly useToString: UnwrapRef<typeof import('@vueuse/core')['useToString']>
readonly useToggle: UnwrapRef<typeof import('@vueuse/core')['useToggle']>
readonly useTransition: UnwrapRef<typeof import('@vueuse/core')['useTransition']>
readonly useTrunc: UnwrapRef<typeof import('@vueuse/math')['useTrunc']>
readonly useUrlSearchParams: UnwrapRef<typeof import('@vueuse/core')['useUrlSearchParams']>
readonly useUserMedia: UnwrapRef<typeof import('@vueuse/core')['useUserMedia']>
readonly useVModel: UnwrapRef<typeof import('@vueuse/core')['useVModel']>

View File

@@ -1,6 +1,8 @@
<script setup lang="ts">
import type { FieldOption, PublicFormField } from '@form-schema/types/formBuilder'
import { resolveOptionLabel } from '@form-schema/types/formBuilder'
import type { OptionSpec, PublicFormField } from '@form-schema/types/formBuilder'
import { getValidatorsForField, runValidators } from '@form-schema/utils/formValidation'
import { usePublicFormLocale } from '@/composables/publicFormInjection'
const props = defineProps<{
field: PublicFormField
@@ -13,20 +15,19 @@ const emit = defineEmits<{
(e: 'blur'): void
}>()
interface NormalizedOption {
label: string
description: string | null
const locale = usePublicFormLocale()
interface RenderOption {
value: string
label: 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 options = computed<RenderOption[]>(() =>
(props.field.options ?? []).map((opt: OptionSpec): RenderOption => ({
value: opt.value,
label: resolveOptionLabel(opt, locale.value),
})),
)
const selected = computed<string[]>(() =>
Array.isArray(props.modelValue) ? (props.modelValue as unknown[]).map(String) : [],
@@ -81,22 +82,11 @@ function toggle(value: string, checked: boolean | null): void {
v-for="opt in options"
:key="opt.value"
:model-value="isChecked(opt.value)"
:label="opt.label"
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"

View File

@@ -1,6 +1,8 @@
<script setup lang="ts">
import type { FieldOption, PublicFormField } from '@form-schema/types/formBuilder'
import { resolveOptionLabel } from '@form-schema/types/formBuilder'
import type { OptionSpec, PublicFormField } from '@form-schema/types/formBuilder'
import { getValidatorsForField } from '@form-schema/utils/formValidation'
import { usePublicFormLocale } from '@/composables/publicFormInjection'
const props = defineProps<{
field: PublicFormField
@@ -13,20 +15,19 @@ const emit = defineEmits<{
(e: 'blur'): void
}>()
interface NormalizedOption {
title: string
description?: string | null
const locale = usePublicFormLocale()
interface RenderOption {
value: string
title: 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 items = computed<RenderOption[]>(() =>
(props.field.options ?? []).map((opt: OptionSpec): RenderOption => ({
value: opt.value,
title: resolveOptionLabel(opt, locale.value),
})),
)
const rules = computed(() => getValidatorsForField(props.field))
const model = computed({
@@ -52,16 +53,5 @@ const model = computed({
: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>

View File

@@ -1,6 +1,8 @@
<script setup lang="ts">
import type { FieldOption, PublicFormField } from '@form-schema/types/formBuilder'
import { resolveOptionLabel } from '@form-schema/types/formBuilder'
import type { OptionSpec, PublicFormField } from '@form-schema/types/formBuilder'
import { getValidatorsForField } from '@form-schema/utils/formValidation'
import { usePublicFormLocale } from '@/composables/publicFormInjection'
const props = defineProps<{
field: PublicFormField
@@ -13,22 +15,18 @@ const emit = defineEmits<{
(e: 'blur'): void
}>()
interface NormalizedOption {
label: string
description?: string | null
const locale = usePublicFormLocale()
interface RenderOption {
value: string
title: 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 options = computed<RenderOption[]>(() =>
(props.field.options ?? []).map((opt: OptionSpec): RenderOption => ({
value: opt.value,
title: resolveOptionLabel(opt, locale.value),
})),
)
const rules = computed(() => getValidatorsForField(props.field))
@@ -67,19 +65,8 @@ const model = computed({
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>
:label="opt.title"
/>
</VRadioGroup>
</div>
</template>

View File

@@ -1,6 +1,8 @@
<script setup lang="ts">
import type { FieldOption, PublicFormField } from '@form-schema/types/formBuilder'
import { resolveOptionLabel } from '@form-schema/types/formBuilder'
import type { OptionSpec, PublicFormField } from '@form-schema/types/formBuilder'
import { getValidatorsForField } from '@form-schema/utils/formValidation'
import { usePublicFormLocale } from '@/composables/publicFormInjection'
const props = defineProps<{
field: PublicFormField
@@ -13,20 +15,19 @@ const emit = defineEmits<{
(e: 'blur'): void
}>()
interface NormalizedOption {
title: string
description?: string | null
const locale = usePublicFormLocale()
interface RenderOption {
value: string
title: 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 items = computed<RenderOption[]>(() =>
(props.field.options ?? []).map((opt: OptionSpec): RenderOption => ({
value: opt.value,
title: resolveOptionLabel(opt, locale.value),
})),
)
const rules = computed(() => getValidatorsForField(props.field))
const model = computed({
@@ -50,16 +51,5 @@ const model = computed({
: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>

View File

@@ -1,5 +1,5 @@
import type { InjectionKey, Ref } from 'vue'
import { inject, provide } from 'vue'
import { computed, inject, provide } from 'vue'
// Page-level provide/inject for the public form token. Sibling-endpoint
// fetches (time-slots, sections) read it instead of receiving it as a
@@ -19,3 +19,19 @@ export function usePublicFormToken(): Ref<string> {
return token
}
// Page-level provide/inject for the active form locale. Used by
// option-bearing field renderers (FieldRadio / FieldSelect /
// FieldMultiselect / FieldCheckboxList) to resolve per-option
// translations[locale] over the default option label (WS-5d §17.6).
// Falls back to 'nl' (Crewli's default schema locale) when no provider
// is on the tree — keeps standalone component tests light.
export const PUBLIC_FORM_LOCALE_KEY: InjectionKey<Ref<string>> = Symbol('PublicFormLocale')
export function providePublicFormLocale(locale: Ref<string>): void {
provide(PUBLIC_FORM_LOCALE_KEY, locale)
}
export function usePublicFormLocale(): Ref<string> {
return inject(PUBLIC_FORM_LOCALE_KEY, computed(() => 'nl'))
}

View File

@@ -11,7 +11,7 @@ import { usePublicFormTimeSlots } from '@/composables/api/usePublicFormTimeSlots
import { useFormDraft } from '@/composables/useFormDraft'
import { isStepValid, useFormSteps } from '@form-schema/composables/useFormSteps'
import { formatFieldValue } from '@form-schema/composables/formatFieldValue'
import { providePublicFormToken } from '@/composables/publicFormInjection'
import { providePublicFormLocale, providePublicFormToken } from '@/composables/publicFormInjection'
import { FormFieldType } from '@form-schema/types/formBuilder'
import type { FormErrorCode, PublicFormField } from '@form-schema/types/formBuilder'
@@ -40,6 +40,13 @@ providePublicFormToken(token)
const schemaQuery = useFetchPublicFormSchema(tokenRef)
// Surface the schema's locale to option-bearing field renderers so they
// can resolve per-option translations[locale] over the default label.
// Defaults to 'nl' until the schema resolves.
const formLocale = computed<string>(() => schemaQuery.data.value?.locale ?? 'nl')
providePublicFormLocale(formLocale)
// Sibling endpoints — fetched at page level so the review step and
// FormConfirmation can human-label AVAILABILITY_PICKER /
// SECTION_PRIORITY values via formatFieldValue. Shares the same

View File

@@ -0,0 +1,162 @@
import { mount } from '@vue/test-utils'
import { describe, expect, it } from 'vitest'
import { computed, defineComponent, h, ref } from 'vue'
import FieldCheckboxList from '@/components/public-form/FieldCheckboxList.vue'
import FieldMultiselect from '@/components/public-form/FieldMultiselect.vue'
import FieldRadio from '@/components/public-form/FieldRadio.vue'
import FieldSelect from '@/components/public-form/FieldSelect.vue'
import { providePublicFormLocale } from '@/composables/publicFormInjection'
import { FormFieldType } from '@form-schema/types/formBuilder'
import type { OptionSpec, PublicFormField } from '@form-schema/types/formBuilder'
const stubs = {
VRadioGroup: { name: 'VRadioGroup', template: '<div class="v-radio-group-stub"><slot/></div>' },
VRadio: {
name: 'VRadio',
props: ['value', 'label'],
template: '<div class="v-radio-stub" :data-value="value" :data-label="label" />',
},
VCheckbox: {
name: 'VCheckbox',
props: ['modelValue', 'label'],
template: '<div class="v-checkbox-stub" :data-label="label" />',
},
AppSelect: {
name: 'AppSelect',
props: ['modelValue', 'items', 'itemTitle', 'itemValue', 'label'],
template: `<div class="app-select-stub">
<span
v-for="item in items"
:key="item.value"
class="stub-item"
:data-value="item.value"
:data-title="item.title"
>{{ item.title }}</span>
</div>`,
},
}
function fieldOf(field_type: PublicFormField['field_type'], options: OptionSpec[]): PublicFormField {
return {
id: 'f_1',
slug: 'choice',
field_type,
label: 'Choice',
help_text: null,
options,
available_tags: null,
validation_rules: null,
is_required: false,
display_width: 'full',
conditional_logic: null,
sort_order: 1,
form_schema_section_id: null,
}
}
function harness(component: any, field: PublicFormField, locale: string) {
// Tiny wrapper that calls providePublicFormLocale before rendering the
// target component, mimicking what [public_token].vue does at the page
// root.
const Wrapper = defineComponent({
setup() {
providePublicFormLocale(ref(locale))
return () => h(component, { field, modelValue: null })
},
})
return mount(Wrapper, { global: { stubs } })
}
const optionsSample: OptionSpec[] = [
{ value: 'red', label: 'Red', sort_order: 0, translations: { nl: 'Rood', de: 'Rot' } },
{ value: 'green', label: 'Green', sort_order: 1 }, // no translations — fallback
]
describe('Option-bearing field locale resolution', () => {
describe('FieldRadio', () => {
it('prefers translations[locale] over the default label', () => {
const wrapper = harness(FieldRadio, fieldOf(FormFieldType.RADIO, optionsSample), 'nl')
const radios = wrapper.findAll('.v-radio-stub')
expect(radios.length).toBe(2)
expect(radios[0].attributes('data-label')).toBe('Rood')
})
it('falls back to label when translation is missing for the current locale', () => {
const wrapper = harness(FieldRadio, fieldOf(FormFieldType.RADIO, optionsSample), 'nl')
const radios = wrapper.findAll('.v-radio-stub')
expect(radios[1].attributes('data-label')).toBe('Green')
})
})
describe('FieldSelect', () => {
it('emits translated title for matching locale', () => {
const wrapper = harness(FieldSelect, fieldOf(FormFieldType.SELECT, optionsSample), 'de')
const items = wrapper.findAll('.stub-item')
expect(items[0].attributes('data-title')).toBe('Rot')
})
it('falls back to label on missing translation', () => {
const wrapper = harness(FieldSelect, fieldOf(FormFieldType.SELECT, optionsSample), 'de')
const items = wrapper.findAll('.stub-item')
expect(items[1].attributes('data-title')).toBe('Green')
})
})
describe('FieldMultiselect', () => {
it('emits translated title for matching locale', () => {
const wrapper = harness(FieldMultiselect, fieldOf(FormFieldType.MULTISELECT, optionsSample), 'nl')
const items = wrapper.findAll('.stub-item')
expect(items[0].attributes('data-title')).toBe('Rood')
})
it('falls back to label on missing translation', () => {
const wrapper = harness(FieldMultiselect, fieldOf(FormFieldType.MULTISELECT, optionsSample), 'nl')
const items = wrapper.findAll('.stub-item')
expect(items[1].attributes('data-title')).toBe('Green')
})
})
describe('FieldCheckboxList', () => {
it('emits translated label for matching locale', () => {
const wrapper = harness(FieldCheckboxList, fieldOf(FormFieldType.CHECKBOX_LIST, optionsSample), 'nl')
const checkboxes = wrapper.findAll('.v-checkbox-stub')
expect(checkboxes[0].attributes('data-label')).toBe('Rood')
})
it('falls back to label on missing translation', () => {
const wrapper = harness(FieldCheckboxList, fieldOf(FormFieldType.CHECKBOX_LIST, optionsSample), 'nl')
const checkboxes = wrapper.findAll('.v-checkbox-stub')
expect(checkboxes[1].attributes('data-label')).toBe('Green')
})
})
it('default-locale fallback (no provider on the tree) uses the option label as-is', () => {
// No providePublicFormLocale in the wrapper — usePublicFormLocale
// defaults to 'nl', which doesn't appear in options that only carry
// 'de' translations, so we get the raw label.
const Wrapper = defineComponent({
setup() {
return () => h(FieldRadio, {
field: fieldOf(FormFieldType.RADIO, [
{ value: 'a', label: 'Apple', sort_order: 0, translations: { de: 'Apfel' } },
]),
modelValue: null,
})
},
})
const wrapper = mount(Wrapper, { global: { stubs } })
const radios = wrapper.findAll('.v-radio-stub')
expect(radios[0].attributes('data-label')).toBe('Apple')
})
})

View File

@@ -72,7 +72,19 @@ export interface FormFieldValidationRules {
[key: string]: unknown
}
export type FieldOption = string | { label: string; description?: string | null; value?: string | number | null }
// Post-WS-5d rich-shape option contract. Per ARCH-FORM-BUILDER §17.6,
// every RADIO/SELECT/MULTISELECT/CHECKBOX_LIST option carries
// value+label+sort_order on the row, and per-locale translations as a
// JSON bag indexed by BCP-47 locale (e.g. "nl", "en_GB"). The legacy
// `description` field is gone — ARCH §5.1's option-bearing field types
// don't model descriptions; that lives on the parallel
// RegistrationFieldTemplate domain in apps/app and is out of scope.
export interface OptionSpec {
value: string
label: string
sort_order: number
translations?: Record<string, string>
}
export interface AvailableTag {
id: string
@@ -86,7 +98,7 @@ export interface PublicFormField {
field_type: FormFieldType
label: string
help_text: string | null
options: FieldOption[] | null
options: OptionSpec[] | null
available_tags: AvailableTag[] | null
validation_rules: FormFieldValidationRules | null
is_required: boolean
@@ -96,6 +108,14 @@ export interface PublicFormField {
form_schema_section_id: string | null
}
/**
* Resolve an option's display label for a given locale. Falls back to
* the default label when no translation exists for that locale.
*/
export function resolveOptionLabel(option: OptionSpec, locale: string): string {
return option.translations?.[locale] ?? option.label
}
export interface PublicFormSection {
id: string
slug: string