From 54e39eb2e46475bfe914b7342910cebaf0c5909b Mon Sep 17 00:00:00 2001 From: Amp Date: Tue, 10 Nov 2026 14:47:39 +0000 Subject: [PATCH] add generic relationship ui composition Amp-Thread-ID: https://ampcode.com/threads/T-2c2291ae-edc1-4e69-8693-c58a8e0a7963 Co-authored-by: Duncan McClean --- packages/cms/src/index.js | 5 ++ resources/js/bootstrap/cms/core.js | 5 ++ .../relationship/RelationshipFieldtype.vue | 1 + .../inputs/relationship/CreateButton.vue | 9 +- .../inputs/relationship/RelationshipInput.vue | 36 ++++---- .../inputs/relationship/Selector.vue | 7 +- resources/js/tests/Package.test.js | 5 ++ .../browser/RelationshipComposition.test.js | 79 ++++++++++++++++++ .../browser/RelationshipSelector.test.js | 83 +++++++++++++++++++ .../relationship/RelationshipInput.test.js | 56 ++++++++++++- .../inputs/relationship/Selector.test.js | 33 ++++++++ 11 files changed, 295 insertions(+), 24 deletions(-) create mode 100644 resources/js/tests/browser/RelationshipComposition.test.js create mode 100644 resources/js/tests/browser/RelationshipSelector.test.js create mode 100644 resources/js/tests/components/inputs/relationship/Selector.test.js diff --git a/packages/cms/src/index.js b/packages/cms/src/index.js index 8b30e9f288b..29b984f20ec 100644 --- a/packages/cms/src/index.js +++ b/packages/cms/src/index.js @@ -7,6 +7,11 @@ export const { HasPreferencesMixin, IndexFieldtypeMixin, InlineEditForm, + InlineCreateForm, + RelationshipInput, + RelationshipSelector, + RelationshipSelectField, + RelationshipCreateButton, DateFormatter, NumberFormatter, ItemActions, diff --git a/resources/js/bootstrap/cms/core.js b/resources/js/bootstrap/cms/core.js index 05070c7d6c8..4c8cc9a5d07 100644 --- a/resources/js/bootstrap/cms/core.js +++ b/resources/js/bootstrap/cms/core.js @@ -6,6 +6,11 @@ export { default as DateFormatter } from '../../components/DateFormatter.js'; export { default as NumberFormatter } from '../../components/NumberFormatter.js'; export { default as ItemActions } from '../../components/actions/ItemActions.vue'; export { default as InlineEditForm } from '../../components/inputs/relationship/InlineEditForm.vue'; +export { default as InlineCreateForm } from '../../components/inputs/relationship/InlineCreateForm.vue'; +export { default as RelationshipInput } from '../../components/inputs/relationship/RelationshipInput.vue'; +export { default as RelationshipSelector } from '../../components/inputs/relationship/Selector.vue'; +export { default as RelationshipSelectField } from '../../components/inputs/relationship/SelectField.vue'; +export { default as RelationshipCreateButton } from '../../components/inputs/relationship/CreateButton.vue'; export { default as RelatedItem } from '../../components/inputs/relationship/Item.vue'; export { default as RevisionHistory } from '../../components/revision-history/History.vue'; export { default as RestoreRevision } from '../../components/revision-history/Restore.vue'; diff --git a/resources/js/components/fieldtypes/relationship/RelationshipFieldtype.vue b/resources/js/components/fieldtypes/relationship/RelationshipFieldtype.vue index 86c3575e54c..73d523a8f31 100644 --- a/resources/js/components/fieldtypes/relationship/RelationshipFieldtype.vue +++ b/resources/js/components/fieldtypes/relationship/RelationshipFieldtype.vue @@ -16,6 +16,7 @@ :filters-url="filtersUrl" :selections-url="selectionsUrl" :creatables="creatables" + :create-menu-label="meta.createMenuLabel" :form-component="formComponent" :form-component-props="formComponentProps" :form-stack-size="formStackSize" diff --git a/resources/js/components/inputs/relationship/CreateButton.vue b/resources/js/components/inputs/relationship/CreateButton.vue index bb67e589d48..7873fcfdee6 100644 --- a/resources/js/components/inputs/relationship/CreateButton.vue +++ b/resources/js/components/inputs/relationship/CreateButton.vue @@ -7,7 +7,7 @@ size="sm" @click="create" /> - + - +
- {{ creatable.blueprint }} - {{ creatable.parent_title }} + {{ creatable.label ?? creatable.blueprint }} + {{ creatable.hint ?? creatable.parent_title }}
@@ -59,6 +59,7 @@ export default { stackSize: String, icon: String, text: String, + menuLabel: String, }, data() { diff --git a/resources/js/components/inputs/relationship/RelationshipInput.vue b/resources/js/components/inputs/relationship/RelationshipInput.vue index e9b5105ba01..86fe3c0ccac 100644 --- a/resources/js/components/inputs/relationship/RelationshipInput.vue +++ b/resources/js/components/inputs/relationship/RelationshipInput.vue @@ -67,6 +67,7 @@ :component-props="formComponentProps" :icon="icon" :text="createLabel" + :menu-label="createMenuLabel" :site="site" :stack-size="formStackSize" @created="itemCreated" @@ -82,22 +83,24 @@ - + + + @@ -136,6 +139,7 @@ export default { canReorder: { type: Boolean }, config: { type: Object }, creatables: { type: Array }, + createMenuLabel: { type: String }, data: { type: Array }, exclusions: { type: Array }, filtersUrl: { type: String }, diff --git a/resources/js/components/inputs/relationship/Selector.vue b/resources/js/components/inputs/relationship/Selector.vue index 29ebd23caa9..39c28734ca9 100644 --- a/resources/js/components/inputs/relationship/Selector.vue +++ b/resources/js/components/inputs/relationship/Selector.vue @@ -24,8 +24,11 @@
-
- +
+
+ +
+
diff --git a/resources/js/tests/Package.test.js b/resources/js/tests/Package.test.js index 4be1778b7c1..44d05bd7412 100644 --- a/resources/js/tests/Package.test.js +++ b/resources/js/tests/Package.test.js @@ -25,10 +25,15 @@ it('exports core', async () => { 'HasPreferencesMixin', 'IndexFieldtype', 'IndexFieldtypeMixin', + 'InlineCreateForm', 'InlineEditForm', 'ItemActions', 'NumberFormatter', 'RelatedItem', + 'RelationshipCreateButton', + 'RelationshipInput', + 'RelationshipSelectField', + 'RelationshipSelector', 'RestoreRevision', 'RevisionHistory', 'RevisionPreview', diff --git a/resources/js/tests/browser/RelationshipComposition.test.js b/resources/js/tests/browser/RelationshipComposition.test.js new file mode 100644 index 00000000000..3e6bf32d79a --- /dev/null +++ b/resources/js/tests/browser/RelationshipComposition.test.js @@ -0,0 +1,79 @@ +import { afterEach, expect, test } from 'vitest'; +import { page } from 'vitest/browser'; +import { flushPromises, mount } from '@vue/test-utils'; +import { h } from 'vue'; +import { RelationshipCreateButton } from '@/bootstrap/cms/core.js'; +import { Button, Badge, Dropdown, DropdownMenu, DropdownLabel, DropdownItem } from '@ui'; +import '../../../css/app.css'; + +window.__ = key => key; + +let wrapper; +let host; +afterEach(() => { + wrapper?.unmount(); + host?.remove(); +}); + +function render(creatables, menuLabel) { + host = document.createElement('div'); + document.body.appendChild(host); + wrapper = mount(RelationshipCreateButton, { + attachTo: host, + props: { creatables, menuLabel, text: 'Create Item' }, + global: { + mocks: { __: window.__ }, + components: { + 'ui-button': Button, + 'ui-badge': Badge, + 'ui-dropdown': Dropdown, + 'ui-dropdown-menu': DropdownMenu, + 'ui-dropdown-label': DropdownLabel, + 'ui-dropdown-item': DropdownItem, + }, + stubs: { + InlineCreateForm: { + props: ['itemUrl'], + setup: props => () => h('output', props.itemUrl), + }, + }, + }, + }); +} + +test('no creatables renders no creation control; one opens the form directly', async () => { + render([]); + expect(wrapper.find('button').exists()).toBe(false); + + await wrapper.setProps({ creatables: [{ url: '/posts/create', label: 'Posts' }] }); + await page.getByRole('button', { name: 'Create Item' }).click(); + expect(wrapper.find('output').text()).toBe('/posts/create'); + expect(document.querySelector('[role="menu"]')).toBeNull(); +}); + +test.each([ + [ + 'Choose Blueprint', + [{ url: '/article', blueprint: 'Article', parent_title: 'Blog' }, { url: '/link', blueprint: 'Link' }], + 'Article Blog', + '/article', + ], + [ + 'Choose Resource', + [ + { url: '/post', label: 'Posts', hint: '', parent_title: 'Hidden legacy hint' }, + { url: '/video', label: 'Videos', hint: 'Media', blueprint: 'Legacy blueprint', parent_title: 'Legacy parent' }, + ], + 'Videos Media', + '/video', + ], +])('creation menu supports %s and opens the selected target', async (heading, creatables, label, url) => { + render(creatables, heading === 'Choose Blueprint' ? undefined : heading); + await page.getByRole('button', { name: 'Create Item' }).click(); + await expect.element(page.getByText(heading, { exact: true })).toBeVisible(); + expect(document.querySelectorAll('[data-ui-badge]')).toHaveLength(1); + expect(document.querySelector('[role="menu"]').textContent).not.toMatch(/Legacy|Hidden/); + await page.getByRole('menuitem', { name: label }).click(); + await flushPromises(); + expect(wrapper.find('output').text()).toBe(url); +}); diff --git a/resources/js/tests/browser/RelationshipSelector.test.js b/resources/js/tests/browser/RelationshipSelector.test.js new file mode 100644 index 00000000000..1e69a0b5610 --- /dev/null +++ b/resources/js/tests/browser/RelationshipSelector.test.js @@ -0,0 +1,83 @@ +import { afterEach, expect, test, vi } from 'vitest'; +import { flushPromises, mount } from '@vue/test-utils'; +import { h } from 'vue'; +import axios from 'axios'; +import * as Globals from '@/bootstrap/globals.js'; +import { NumberFormatter, RelationshipSelector } from '@/bootstrap/cms/core.js'; +import { Select, Badge, Icon, Skeleton, ToggleGroup, ToggleItem, InputGroup, InputGroupPrepend, InputGroupAppend } from '@ui'; +import { translate, translateChoice } from '@/translations/translator.js'; +import '../../../../.storybook/storybook.css'; +import '../../../../.storybook/theme.css'; + +Object.assign(window, Globals, { __: translate, __n: translateChoice }); +window.Statamic = { + $config: { get: key => ({ paginationSize: 50, paginationSizeOptions: [50] })[key] }, + $preferences: { get: () => undefined }, + $progress: { loading() {}, complete() {} }, + $events: { $on() {}, $off() {} }, +}; + +let host; +let wrapper; +afterEach(() => { + wrapper?.unmount(); + host?.remove(); + vi.restoreAllMocks(); +}); + +async function render(width, toolbar = true) { + vi.spyOn(axios, 'get').mockResolvedValue({ + data: { data: [], meta: { from: 0, to: 0, total: 0, current_page: 1, last_page: 1, per_page: 50 } }, + }); + host = document.createElement('div'); + host.style.cssText = `width: ${width}px; height: 360px;`; + document.body.appendChild(host); + wrapper = mount(RelationshipSelector, { + attachTo: host, + props: { initialSelections: [], filtersUrl: '/filters', selectionsUrl: '/selections', maxSelections: 1 }, + slots: toolbar ? { + toolbar: () => h(Select, { modelValue: 'all', options: [{ value: 'all', label: 'All resources' }] }), + } : {}, + global: { + components: { + 'ui-badge': Badge, + 'ui-icon': Icon, + 'ui-skeleton': Skeleton, + 'ui-toggle-group': ToggleGroup, + 'ui-toggle-item': ToggleItem, + 'ui-input-group': InputGroup, + 'ui-input-group-prepend': InputGroupPrepend, + 'ui-input-group-append': InputGroupAppend, + }, + directives: { tooltip() {} }, + mocks: { + __: translate, + __n: translateChoice, + $number: new NumberFormatter(), + $axios: { get: () => Promise.resolve({ data: [] }) }, + }, + }, + }); + await flushPromises(); +} + +test.each([ + [960, 1 / 5], + [480, 2 / 5], +])('toolbar adapts to a %ipx selector and search fills the remainder', async (width, fraction) => { + await render(width); + + const resource = wrapper.find('[role="combobox"]').element.getBoundingClientRect(); + const search = wrapper.findComponent({ ref: 'search' }).element.getBoundingClientRect(); + + expect(resource.width).toBeCloseTo((width - 32) * fraction, 0); + expect(search.width).toBeGreaterThan((width - 32) * 0.55); + expect(search.right).toBeCloseTo(host.getBoundingClientRect().right - 16, 0); +}); + +test('ordinary selector keeps its existing search width without toolbar controls', async () => { + await render(960, false); + + expect(wrapper.find('[role="combobox"]').exists()).toBe(false); + expect(wrapper.findComponent({ ref: 'search' }).element.getBoundingClientRect().width).toBe(384); +}); diff --git a/resources/js/tests/components/inputs/relationship/RelationshipInput.test.js b/resources/js/tests/components/inputs/relationship/RelationshipInput.test.js index 069793e9583..084fdb3be8b 100644 --- a/resources/js/tests/components/inputs/relationship/RelationshipInput.test.js +++ b/resources/js/tests/components/inputs/relationship/RelationshipInput.test.js @@ -1,5 +1,6 @@ import { mount, flushPromises } from '@vue/test-utils'; import { describe, expect, test, vi } from 'vitest'; +import { h } from 'vue'; vi.mock('@inertiajs/vue3', () => ({ router: { on: () => () => {} }, @@ -45,8 +46,9 @@ function signalAwareDeferred() { }; } -function mountInput({ axiosPost, itemDataUrl, site = 'default' }) { +function mountInput({ axiosPost, itemDataUrl, site = 'default', slots = {}, stack = true }) { return mount(RelationshipInput, { + slots, props: { value: [], data: [], @@ -62,11 +64,61 @@ function mountInput({ axiosPost, itemDataUrl, site = 'default' }) { $axios: { post: axiosPost }, $progress: { loading: () => {} }, }, - stubs, + stubs: { ...stubs, Stack: stack }, }, }); } +test('uses the ordinary selector when no selector slot is provided', async () => { + const wrapper = mountInput({ + itemDataUrl: '/test/default-selector', + axiosPost: () => Promise.resolve({ data: { data: [{ id: 'entry-1', title: 'Entry' }] } }), + stack: { props: ['open'], template: '
' }, + }); + await flushPromises(); + await wrapper.findComponent({ ref: 'existing' }).trigger('click'); + + const selector = wrapper.findComponent({ name: 'ItemSelector' }); + expect(selector.props('selectionsUrl')).toBe('/api/selections'); + expect(selector.props('initialSelections')).toEqual([]); + selector.vm.$emit('selected', ['entry-1']); + selector.vm.$emit('closed'); + await flushPromises(); + + expect(wrapper.emitted('input')).toEqual([[['entry-1']]]); + expect(wrapper.find('item-selector-stub').exists()).toBe(false); + wrapper.unmount(); +}); + +test('a composed selector can select or cancel through the public slot', async () => { + const wrapper = mountInput({ + itemDataUrl: '/test/composed-selector', + axiosPost: () => Promise.resolve({ data: { data: [{ id: 'video::1', title: 'Video' }] } }), + stack: { props: ['open'], template: '
' }, + slots: { + selector: ({ select, close }) => [ + h('button', { onClick: () => { select(['video::1']); close(); } }, 'Select video'), + h('button', { onClick: close }, 'Cancel'), + ], + }, + }); + await flushPromises(); + await wrapper.setProps({ value: ['post::1'], data: [{ id: 'post::1', title: 'Post' }] }); + await wrapper.findComponent({ ref: 'existing' }).trigger('click'); + expect(wrapper.find('item-selector-stub').exists()).toBe(false); + await wrapper.find('button:last-child').trigger('click'); + expect(wrapper.emitted('input')).toBeUndefined(); + expect(wrapper.props('value')).toEqual(['post::1']); + expect(wrapper.find('button').exists()).toBe(false); + + await wrapper.findComponent({ ref: 'existing' }).trigger('click'); + await wrapper.find('button').trigger('click'); + await flushPromises(); + expect(wrapper.emitted('input')).toEqual([[['video::1']]]); + expect(wrapper.find('button').exists()).toBe(false); + wrapper.unmount(); +}); + describe('RelationshipInput in-flight request deduplication', () => { test('shares a single request across instances with the same selections', async () => { const d = deferred(); diff --git a/resources/js/tests/components/inputs/relationship/Selector.test.js b/resources/js/tests/components/inputs/relationship/Selector.test.js new file mode 100644 index 00000000000..ab7a77c7e38 --- /dev/null +++ b/resources/js/tests/components/inputs/relationship/Selector.test.js @@ -0,0 +1,33 @@ +import { flushPromises, shallowMount } from '@vue/test-utils'; +import { expect, test } from 'vitest'; +import { h } from 'vue'; +import Selector from '@/components/inputs/relationship/Selector.vue'; + +test('composes toolbar controls alongside the ordinary search and filters', async () => { + const wrapper = shallowMount(Selector, { + props: { + name: 'composed', + initialSelections: [], + filtersUrl: '/filters', + }, + slots: { + toolbar: () => h('button', 'Choose resource'), + }, + global: { + mocks: { + __n: key => key, + $axios: { get: () => Promise.resolve({ data: [{ handle: 'status' }] }) }, + }, + stubs: { + Listing: { template: '
' }, + 'ui-badge': true, + }, + }, + }); + await flushPromises(); + + expect(wrapper.find('button').text()).toBe('Choose resource'); + expect(wrapper.find('search-stub').exists()).toBe(true); + expect(wrapper.find('filters-stub').exists()).toBe(true); + wrapper.unmount(); +});