From 12abc9c16d812b18265e03ec8a6a784776762eb3 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Joachim=20R=C3=BCtter?= Date: Mon, 6 Jul 2026 22:55:29 +0000 Subject: [PATCH] Fix relationship-based fields showing raw ids after saving When a relationship/terms field is taggable and a newly typed term gets selected, the id it's stored under can change once the entry is saved (e.g. a typed "Bob" gets normalized to a taxonomy::slug id like tags::bob). Two things combine to turn that into a permanently wrong label: - RelationshipInput never refetches its item data when the value changes to reference an id it has no cached title for, so it falls back to displaying the raw id. - Even with correct item data, the underlying Combobox always shows its plain search input for taggable fields, and derives that input's label from its own fetched options list rather than from the field's up-to-date items, so a selected id missing from that list also renders as the raw id. Refetch item data whenever the value references an id missing from it, and make sure the combobox's own option list always includes the currently selected item so its label lookup can resolve it. Fixes #14785 Co-Authored-By: Claude Opus 4.8 (1M context) --- .../inputs/relationship/RelationshipInput.vue | 10 ++++ .../inputs/relationship/SelectField.vue | 10 +++- .../relationship/RelationshipInput.test.js | 38 +++++++++++++ .../inputs/relationship/SelectField.test.js | 53 +++++++++++++++++++ 4 files changed, 110 insertions(+), 1 deletion(-) create mode 100644 resources/js/tests/components/inputs/relationship/SelectField.test.js diff --git a/resources/js/components/inputs/relationship/RelationshipInput.vue b/resources/js/components/inputs/relationship/RelationshipInput.vue index 7d010f0c4eb..a6bb3dd7d9c 100644 --- a/resources/js/components/inputs/relationship/RelationshipInput.vue +++ b/resources/js/components/inputs/relationship/RelationshipInput.vue @@ -300,6 +300,16 @@ export default { this.$emit('item-data-updated', data); }, + value(value) { + if (this.initializing || this.loading) return; + + // A value set from outside (e.g. a save response normalizing a typed term) may + // reference an id we have no data for yet, which would render as a raw id. + if (value?.some((selection) => !this.data?.find((item) => item.id == selection))) { + this.getDataForSelections(value); + } + }, + items(items, oldItems) { if (items.length > 0 && oldItems.length === 0) { if (this.canReorder) { diff --git a/resources/js/components/inputs/relationship/SelectField.vue b/resources/js/components/inputs/relationship/SelectField.vue index ffe66b49924..6bedba8c661 100644 --- a/resources/js/components/inputs/relationship/SelectField.vue +++ b/resources/js/components/inputs/relationship/SelectField.vue @@ -7,7 +7,7 @@ :max-selections="maxSelections" :model-value="items.map((item) => item.id)" :multiple - :options + :options="comboboxOptions" :placeholder="__(config.placeholder) || __('Choose...')" :read-only="readOnly" :taggable="isTaggable" @@ -95,6 +95,14 @@ export default { return JSON.stringify({ ...this.parameters, url: this.url }); }, + comboboxOptions() { + // Combobox resolves the selected label from this list, so a selected item missing + // from it (e.g. a just-created term) would otherwise display as its raw id. + const missing = this.items.filter((item) => !this.options.some((option) => option.id === item.id)); + + return [...this.options, ...missing]; + }, + noOptionsText() { return this.typeahead && !this.requested ? __('Start typing to search.') : __('No options to choose from.'); }, diff --git a/resources/js/tests/components/inputs/relationship/RelationshipInput.test.js b/resources/js/tests/components/inputs/relationship/RelationshipInput.test.js index bc2eda8a64e..069793e9583 100644 --- a/resources/js/tests/components/inputs/relationship/RelationshipInput.test.js +++ b/resources/js/tests/components/inputs/relationship/RelationshipInput.test.js @@ -239,3 +239,41 @@ describe('RelationshipInput in-flight request deduplication', () => { b.unmount(); }); }); + +describe('RelationshipInput refetches item data for unresolved values', () => { + test('fetches item data when the value prop changes to include an id with no matching data', async () => { + const d = deferred(); + const post = vi.fn(() => d.promise); + + const wrapper = mountInput({ axiosPost: post, itemDataUrl: '/test/stale-value' }); + await flushPromises(); + + await wrapper.setProps({ value: ['taxonomy::bob'] }); + await flushPromises(); + + expect(post).toHaveBeenCalledWith( + '/test/stale-value', + { site: 'default', selections: ['taxonomy::bob'] }, + expect.anything(), + ); + + d.resolve({ data: { data: [] } }); + await flushPromises(); + + wrapper.unmount(); + }); + + test('does not refetch when the new value is already covered by existing data', async () => { + const post = vi.fn(() => Promise.resolve({ data: { data: [] } })); + + const wrapper = mountInput({ axiosPost: post, itemDataUrl: '/test/stale-value-known' }); + await flushPromises(); + + await wrapper.setProps({ data: [{ id: '1', title: 'One' }], value: ['1'] }); + await flushPromises(); + + expect(post).not.toHaveBeenCalled(); + + wrapper.unmount(); + }); +}); diff --git a/resources/js/tests/components/inputs/relationship/SelectField.test.js b/resources/js/tests/components/inputs/relationship/SelectField.test.js new file mode 100644 index 00000000000..322f3f5216b --- /dev/null +++ b/resources/js/tests/components/inputs/relationship/SelectField.test.js @@ -0,0 +1,53 @@ +import { mount } from '@vue/test-utils'; +import { describe, expect, test, vi } from 'vitest'; + +vi.mock('@inertiajs/vue3', () => ({ + router: { on: () => () => {} }, +})); + +globalThis.__ = (key) => key; + +import { data_get } from '@/bootstrap/globals.js'; +globalThis.data_get = data_get; + +import SelectField from '@/components/inputs/relationship/SelectField.vue'; + +const stubs = { + Combobox: true, + StatusIndicator: true, +}; + +function mountSelectField({ items = [] } = {}) { + return mount(SelectField, { + props: { + items, + url: '/test/select-field', + config: {}, + }, + global: { + mocks: { + $axios: { get: () => Promise.resolve({ data: { data: [] } }) }, + }, + stubs, + }, + }); +} + +describe('SelectField comboboxOptions', () => { + test('includes a selected item missing from the fetched options list', () => { + const wrapper = mountSelectField({ items: [{ id: 'tags::bob', title: 'Bob' }] }); + + expect(wrapper.vm.comboboxOptions).toContainEqual({ id: 'tags::bob', title: 'Bob' }); + + wrapper.unmount(); + }); + + test('does not duplicate an item already present in the fetched options list', async () => { + const wrapper = mountSelectField({ items: [{ id: '1', title: 'One' }] }); + await wrapper.setData({ options: [{ id: '1', title: 'One' }] }); + + expect(wrapper.vm.comboboxOptions).toEqual([{ id: '1', title: 'One' }]); + + wrapper.unmount(); + }); +});