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

@@ -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