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:
43
apps/portal/auto-imports.d.ts
vendored
43
apps/portal/auto-imports.d.ts
vendored
@@ -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']>
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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'))
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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')
|
||||
})
|
||||
})
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user