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>
163 lines
5.7 KiB
TypeScript
163 lines
5.7 KiB
TypeScript
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')
|
|
})
|
|
})
|