Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions packages/cms/src/index.js
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,11 @@ export const {
HasPreferencesMixin,
IndexFieldtypeMixin,
InlineEditForm,
InlineCreateForm,
RelationshipInput,
RelationshipSelector,
RelationshipSelectField,
RelationshipCreateButton,
DateFormatter,
NumberFormatter,
ItemActions,
Expand Down
5 changes: 5 additions & 0 deletions resources/js/bootstrap/cms/core.js
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand Down
9 changes: 5 additions & 4 deletions resources/js/components/inputs/relationship/CreateButton.vue
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,7 @@
size="sm"
@click="create"
/>
<ui-dropdown v-else>
<ui-dropdown v-else-if="creatables.length > 1">
<template #trigger>
<ui-button
:icon="icon"
Expand All @@ -16,15 +16,15 @@
/>
</template>
<ui-dropdown-menu>
<ui-dropdown-label v-text="__('Choose Blueprint')" />
<ui-dropdown-label :text="menuLabel || __('Choose Blueprint')" />
<ui-dropdown-item
v-for="creatable in creatables"
:key="creatable.url"
@click="select(creatable)"
>
<div class="flex items-center justify-between">
<span>{{ creatable.blueprint }}</span>
<ui-badge size="sm">{{ creatable.parent_title }}</ui-badge>
<span>{{ creatable.label ?? creatable.blueprint }}</span>
<ui-badge v-if="creatable.hint ?? creatable.parent_title" size="sm">{{ creatable.hint ?? creatable.parent_title }}</ui-badge>
</div>
</ui-dropdown-item>
</ui-dropdown-menu>
Expand Down Expand Up @@ -59,6 +59,7 @@ export default {
stackSize: String,
icon: String,
text: String,
menuLabel: String,
},

data() {
Expand Down
36 changes: 20 additions & 16 deletions resources/js/components/inputs/relationship/RelationshipInput.vue
Original file line number Diff line number Diff line change
Expand Up @@ -67,6 +67,7 @@
:component-props="formComponentProps"
:icon="icon"
:text="createLabel"
:menu-label="createMenuLabel"
:site="site"
:stack-size="formStackSize"
@created="itemCreated"
Expand All @@ -82,22 +83,24 @@
</div>

<Stack v-model:open="isSelecting" inset :show-close-button="false">
<ItemSelector
:name="name"
:filters-url="filtersUrl"
:selections-url="selectionsUrl"
:site="site"
:initial-sort-column="initialSortColumn"
:initial-sort-direction="initialSortDirection"
:initial-selections="value"
:max-selections="maxItems"
:search="search"
:exclusions="exclusions"
:type="config.type"
:tree="config.query_scopes?.length > 0 ? null : tree"
@selected="selectionsUpdated"
@closed="isSelecting = false"
/>
<slot name="selector" :select="selectionsUpdated" :close="() => isSelecting = false">
<ItemSelector
:name="name"
:filters-url="filtersUrl"
:selections-url="selectionsUrl"
:site="site"
:initial-sort-column="initialSortColumn"
:initial-sort-direction="initialSortDirection"
:initial-selections="value"
:max-selections="maxItems"
:search="search"
:exclusions="exclusions"
:type="config.type"
:tree="config.query_scopes?.length > 0 ? null : tree"
@selected="selectionsUpdated"
@closed="isSelecting = false"
/>
</slot>
</Stack>

<input v-if="name" type="hidden" :name="name" :value="JSON.stringify(value)" />
Expand Down Expand Up @@ -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 },
Expand Down
7 changes: 5 additions & 2 deletions resources/js/components/inputs/relationship/Selector.vue
Original file line number Diff line number Diff line change
Expand Up @@ -24,8 +24,11 @@

<div class="flex flex-1 flex-col gap-4 overflow-auto p-4">
<div class="flex items-center gap-2 sm:gap-3 min-h-16">
<div class="flex flex-1 items-center gap-2 sm:gap-3">
<Search ref="search" />
<div class="@container flex flex-1 items-center gap-2 sm:gap-3">
<div v-if="$slots.toolbar" class="w-2/5 @2xl:w-1/5 min-w-0 shrink-0">
<slot name="toolbar" />
</div>
<Search ref="search" :class="{ 'min-w-0 max-w-none!': $slots.toolbar }" />
<Filters v-if="filters && filters.length" />
</div>

Expand Down
5 changes: 5 additions & 0 deletions resources/js/tests/Package.test.js
Original file line number Diff line number Diff line change
Expand Up @@ -25,10 +25,15 @@ it('exports core', async () => {
'HasPreferencesMixin',
'IndexFieldtype',
'IndexFieldtypeMixin',
'InlineCreateForm',
'InlineEditForm',
'ItemActions',
'NumberFormatter',
'RelatedItem',
'RelationshipCreateButton',
'RelationshipInput',
'RelationshipSelectField',
'RelationshipSelector',
'RestoreRevision',
'RevisionHistory',
'RevisionPreview',
Expand Down
79 changes: 79 additions & 0 deletions resources/js/tests/browser/RelationshipComposition.test.js
Original file line number Diff line number Diff line change
@@ -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);
});
83 changes: 83 additions & 0 deletions resources/js/tests/browser/RelationshipSelector.test.js
Original file line number Diff line number Diff line change
@@ -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);
});
Original file line number Diff line number Diff line change
@@ -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: () => () => {} },
Expand Down Expand Up @@ -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: [],
Expand All @@ -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: '<div v-if="open"><slot /></div>' },
});
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: '<div v-if="open"><slot /></div>' },
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();
Expand Down
Loading
Loading