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