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(); + }); +});