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:
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user