diff --git a/resources/js/components/fieldtypes/DictionaryFieldtype.vue b/resources/js/components/fieldtypes/DictionaryFieldtype.vue index bed37bd9c32..3a695d07daf 100644 --- a/resources/js/components/fieldtypes/DictionaryFieldtype.vue +++ b/resources/js/components/fieldtypes/DictionaryFieldtype.vue @@ -89,7 +89,17 @@ export default { }, normalizedOptions() { - return this.normalizeInputOptions(this.options); + const options = this.normalizeInputOptions(this.options); + + // Multi-select renders its selections from the slot below, so the options can be left alone. + if (this.multiple) return options; + + // In single mode the Combobox resolves the selected label from these options, and the fetched + // options may not include the stored value (API-backed dictionaries only return a page of + // results), so merge the selected options in or it would display the raw value instead. + const values = new Set(options.map((option) => option.value)); + + return [...options, ...this.selectedOptions.filter((option) => !values.has(option.value))]; }, selectedOptions() { diff --git a/resources/js/tests/components/fieldtypes/DictionaryFieldtype.test.js b/resources/js/tests/components/fieldtypes/DictionaryFieldtype.test.js new file mode 100644 index 00000000000..74fdc0364e0 --- /dev/null +++ b/resources/js/tests/components/fieldtypes/DictionaryFieldtype.test.js @@ -0,0 +1,71 @@ +import { mount, flushPromises } from '@vue/test-utils'; +import { describe, expect, test, vi } from 'vitest'; + +window.__ = (key) => key; +window.__n = (key) => key; +window.utf8btoa = (string) => btoa(string); + +import DictionaryFieldtype from '@/components/fieldtypes/DictionaryFieldtype.vue'; + +function mountFieldtype({ value, maxItems, selectedOptions, fetchedOptions, shallow = false }) { + return mount(DictionaryFieldtype, { + shallow, + props: { + handle: 'country', + value, + config: { max_items: maxItems }, + meta: { url: '/cp/fieldtypes/dictionaries/partial_countries', selectedOptions }, + }, + global: { + mocks: { + $axios: { get: vi.fn(() => Promise.resolve({ data: { data: fetchedOptions } })) }, + }, + }, + }); +} + +describe('DictionaryFieldtype options', () => { + // API-backed dictionaries only return a page of options, which may not include the stored value. + // The preloaded selected options must be merged into the options passed to the Combobox so it can + // resolve their labels — otherwise a single-select field displays the raw id instead. + // https://github.com/statamic/cms/issues/14835 + test('single-select renders the label of a stored value missing from the fetched options', async () => { + const fieldtype = mountFieldtype({ + value: 'de', + maxItems: 1, + selectedOptions: [{ value: 'de', label: 'Germany', invalid: false }], + fetchedOptions: [], + }); + await flushPromises(); + + const selected = fieldtype.find('[data-ui-combobox-selected-option]'); + expect(selected.text()).toContain('Germany'); + expect(selected.text()).not.toContain('de'); + }); + + test('selected options already present in the fetched options are not duplicated', async () => { + const fieldtype = mountFieldtype({ + value: 'de', + maxItems: 1, + selectedOptions: [{ value: 'de', label: 'Germany', invalid: false }], + fetchedOptions: [{ value: 'de', label: 'Germany' }], + shallow: true, + }); + await flushPromises(); + + expect(fieldtype.vm.normalizedOptions).toEqual([{ value: 'de', label: 'Germany' }]); + }); + + test('multi-select options are not polluted with selected options', async () => { + const fieldtype = mountFieldtype({ + value: ['de'], + maxItems: null, + selectedOptions: [{ value: 'de', label: 'Germany', invalid: false }], + fetchedOptions: [{ value: 'ca', label: 'Canada' }], + shallow: true, + }); + await flushPromises(); + + expect(fieldtype.vm.normalizedOptions).toEqual([{ value: 'ca', label: 'Canada' }]); + }); +});