Files
crewli/apps/portal/tests/components/public-form/FieldOptionsLocale.spec.ts
bert.hausmans dd7dfe9c0b 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>
2026-04-25 02:50:33 +02:00

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')
})
})