diff --git a/CHANGELOG-WIP.md b/CHANGELOG-WIP.md index 38716dd7a11..5491c56d348 100644 --- a/CHANGELOG-WIP.md +++ b/CHANGELOG-WIP.md @@ -444,6 +444,10 @@ Craft 6 now uses [Laravel's authorization system](https://laravel.com/docs/12.x/ - Removed `Cp.$axios`. - Removed the `Craft.Accordion` and `Craft.EnvVarGenerator` control panel JavaScript classes. ([#19323](https://github.com/craftcms/cms/pull/19323)) - Deprecated the `Craft.LightSwitch`, `Craft.InfoIcon`, `Craft.ColorInput`, `Craft.PasswordInput`, `Craft.IconPicker`, `Craft.SlidePicker`, `Craft.SlideRuleInput`, and `Craft.Tooltip` control panel JavaScript classes, along with the `.infoicon` jQuery plugin. The corresponding `@craftcms/ui` web components should be used instead. +- Plugin bundles can now import the control panel’s element index and element editor components (`ElementIndexPage`, `ElementEditor`, `CpButtonLink`, `ActionMenu`) from the `@craftcms/cms/elements` import-map module, so a plugin’s own element types can have Inertia index and edit pages. +- Reactive controls in an element’s field layout now refresh the layout from the server when they change, the same as in settings forms. +- Element action menu items can now use a `formModal` behavior (`modalUrl`, `actionUrl`, `params`), which opens a server-built form in a modal, posts its values to `actionUrl`, and reloads the page once it’s submitted. +- Element actions run from the element index’s bulk actions bar can now redirect the browser by setting a redirect response with `setResponse()`. ### Drafts diff --git a/resources/js/bootstrap/cp.ts b/resources/js/bootstrap/cp.ts index 7747880354a..d8e8041cd27 100644 --- a/resources/js/bootstrap/cp.ts +++ b/resources/js/bootstrap/cp.ts @@ -68,6 +68,11 @@ const Cp = { return cpComponentRegistry; }, + // Plugin bundles must use the mounted app's router, not their own module copy. + get $router() { + return router; + }, + get $elementDetailsTabs() { return elementDetailsTabRegistry; }, diff --git a/resources/js/common/components/ActionList.vue b/resources/js/common/components/ActionList.vue index 153b904e49b..3dfb1bb333c 100644 --- a/resources/js/common/components/ActionList.vue +++ b/resources/js/common/components/ActionList.vue @@ -40,6 +40,8 @@ href?: string; external?: boolean; label?: string; + /** A colored status dot before the label — `craft-indicator`'s own `fill` values. */ + fill?: string; onClick?: (event: Event) => void; /** * Everything optional, with the unset keys left out entirely. Binding an @@ -172,6 +174,7 @@ { kind: 'button', label: action.label, + fill: action.fill, onClick: action.onClick, attrs: defined({ ...attrs, @@ -476,6 +479,7 @@ :is="as" @click="action.onClick" > + {{ action.label }} diff --git a/resources/js/common/components/MetadataDetails.test.ts b/resources/js/common/components/MetadataDetails.test.ts index 1c8aa105afe..bc0df8e9ad0 100644 --- a/resources/js/common/components/MetadataDetails.test.ts +++ b/resources/js/common/components/MetadataDetails.test.ts @@ -51,3 +51,47 @@ it('renders nothing without metadata', async () => { expect(container!.querySelector('craft-tabs')).toBeNull(); }); + +it('shows sidebar controls above the metadata', async () => { + container = document.createElement('div'); + document.body.append(container); + app = createApp({ + render: () => + h( + MetadataDetails, + {html: '
ID 1
'}, + {default: () => h('div', {class: 'controls'})} + ), + }); + app.mount(container); + await nextTick(); + + const controls = container.querySelector('.controls'); + const details = container.querySelector('.details-html'); + + expect(controls).not.toBeNull(); + expect(container.querySelector('hr')).not.toBeNull(); + expect( + controls!.compareDocumentPosition(details!) & + Node.DOCUMENT_POSITION_FOLLOWING + ).toBeTruthy(); +}); + +it('shows sidebar controls without metadata', async () => { + container = document.createElement('div'); + document.body.append(container); + app = createApp({ + render: () => + h( + MetadataDetails, + {html: null}, + {default: () => h('div', {class: 'controls'})} + ), + }); + app.mount(container); + await nextTick(); + + expect(container.querySelector('.controls')).not.toBeNull(); + expect(container.querySelector('hr')).toBeNull(); + expect(container.querySelector('.details-html')).toBeNull(); +}); diff --git a/resources/js/common/composables/subnavCrumbs.test.ts b/resources/js/common/composables/subnavCrumbs.test.ts index cc769bc8632..ae5479fe7cd 100644 --- a/resources/js/common/composables/subnavCrumbs.test.ts +++ b/resources/js/common/composables/subnavCrumbs.test.ts @@ -5,6 +5,8 @@ import { withSubnavCrumbs, } from './subnavCrumbs'; +import type {BreadcrumbItem} from '@/common/types'; + type NavItem = CraftCms.Cms.Cp.Data.NavItem; function navCrumbItem(config: Partial & {label: string}): NavItem { @@ -315,4 +317,116 @@ describe('withNavCrumbMenus', () => { expect(crumb!.items).toBe(items); }); + + it('leaves a section that is nothing but its sources to the source crumb', () => { + const [entries, all] = withNavCrumbMenus( + [ + {label: 'Entries', href: '/admin/content/entries'}, + { + label: 'All entries', + href: '/admin/content/entries', + items: [{type: 'link', label: 'All entries', href: '/x'}], + }, + ], + NAV, + '/admin/content/entries' + ); + + expect(entries!.items).toBeUndefined(); + expect(all!.items).toHaveLength(3); + }); + + describe('in a plugin’s section', () => { + const COMMERCE = navCrumbItem({ + label: 'Commerce', + href: '/admin/commerce', + subnav: [ + navCrumbItem({label: 'Orders', href: '/admin/commerce/orders'}), + navCrumbItem({label: 'All products', href: '/admin/commerce/products'}), + navCrumbItem({ + label: 'Product Types', + href: null, + group: true, + subnav: [ + navCrumbItem({ + label: 'Widgets', + href: '/admin/commerce/products/widgets', + }), + navCrumbItem({ + label: 'Gizmos', + href: '/admin/commerce/products/gizmos', + }), + ], + }), + navCrumbItem({label: 'Inventory', href: '/admin/commerce/inventory'}), + ], + }); + const PLUGIN_NAV = [navCrumbItem({label: 'Dashboard'}), COMMERCE]; + const SOURCES = [ + {type: 'link' as const, label: 'All products', href: '/x'}, + ]; + + const productCrumbs = (current: string): Array => [ + {label: 'Commerce', href: '/admin/commerce'}, + {label: 'Products', href: '/admin/commerce/products'}, + {label: 'Current', href: current, items: SOURCES}, + ]; + + it('gives the section’s crumb the whole section', () => { + const [commerce] = withNavCrumbMenus( + productCrumbs('/admin/commerce/products/gizmos'), + PLUGIN_NAV, + '/admin/commerce/products/gizmos' + ); + + expect(commerce!.items).toMatchObject([ + {type: 'link', label: 'Orders', selected: false}, + {type: 'link', label: 'All products', selected: false}, + { + type: 'group', + heading: 'Product Types', + items: [ + {type: 'link', label: 'Widgets', selected: false}, + {type: 'link', label: 'Gizmos', selected: true}, + ], + }, + {type: 'link', label: 'Inventory', selected: false}, + ]); + }); + + it('limits a source crumb to what sits under its index', () => { + const [, products, source] = withNavCrumbMenus( + productCrumbs('/admin/commerce/products'), + PLUGIN_NAV, + '/admin/commerce/products' + ); + + expect(products!.items).toBeUndefined(); + expect(source!.items).toMatchObject([ + {type: 'link', label: 'All products', selected: true}, + { + type: 'group', + heading: 'Product Types', + items: [ + {type: 'link', label: 'Widgets', selected: false}, + {type: 'link', label: 'Gizmos', selected: false}, + ], + }, + ]); + }); + + it('gives the section’s crumb its menu on a screen with no switchers', () => { + const [commerce, inventory] = withNavCrumbMenus( + [ + {label: 'Commerce', href: '/admin/commerce'}, + {label: 'Inventory', href: '/admin/commerce/inventory'}, + ], + PLUGIN_NAV, + '/admin/commerce/inventory' + ); + + expect(commerce!.items).toHaveLength(4); + expect(inventory!.items).toBeUndefined(); + }); + }); }); diff --git a/resources/js/common/composables/subnavCrumbs.ts b/resources/js/common/composables/subnavCrumbs.ts index 80cd67b2241..7d6ce777e6c 100644 --- a/resources/js/common/composables/subnavCrumbs.ts +++ b/resources/js/common/composables/subnavCrumbs.ts @@ -134,27 +134,130 @@ function navLevelOf( } /** - * Gives each crumb that has a switcher the menu the nav draws for its level. + * The part of a level that lives under `href`, keeping the headings over + * whatever survives. + * + * A plugin lists an index's sources in its own nav alongside everything else + * it has, so the level a source sits in is the whole plugin. The index crumb + * above the source is what says which of those belong to it. + */ +function levelWithin(level: Array, href: string): Array { + return level.flatMap((item): Array => { + if (item.group) { + const children = levelWithin( + Array.isArray(item.subnav) ? item.subnav : [], + href + ); + + return children.length > 0 ? [{...item, subnav: children}] : []; + } + + return item.href && navItemContains(href, item.href) ? [item] : []; + }); +} + +/** What a level offers, for telling whether two crumbs would offer the same. */ +function levelSignature(level: Array): string { + return level + .map((item) => + item.group && Array.isArray(item.subnav) + ? `[${levelSignature(item.subnav)}]` + : (item.href ?? '') + ) + .join(','); +} + +function offersChoice(level: Array): boolean { + return ( + level.filter((item) => !item.group).length > 1 || + level.some((item) => item.group) + ); +} + +/** + * Gives each crumb that has a switcher the menu the nav draws for its level, + * and a section's crumb the menu of the section. * * An index screen's crumbs come from its secondary nav, while a screen deeper * in (an entry's edit page, say) gets its crumbs from the server. Taking the * menu from the nav for both keeps a source's switcher the same wherever it * appears. A crumb without a menu keeps not having one, and one the nav * doesn't know keeps the server's. + * + * The exception is a section — a main nav item with a subnav, which is what a + * plugin's nav item is. Its crumb switches between everything in the section, + * unless a crumb further along already offers exactly that, as an element + * index's source crumb does when the section is nothing but its sources. + * + * @param url The page being shown, which the section's menu marks. */ export function withNavCrumbMenus( crumbs: Array, - nav: Array + nav: Array, + url?: string ): Array { - return crumbs.map((crumb) => { - const href = crumb.href ?? crumb.url; + const hrefs = crumbs.map((crumb) => crumb.href ?? crumb.url ?? null); + + const levels = crumbs.map((crumb, index): Array | null => { + const href = hrefs[index]; if (!crumb.items?.length || !href) { - return crumb; + return null; } const level = navLevelOf(withNavSelection(nav, href), href); + if (!level) { + return null; + } + + const parent = hrefs + .slice(0, index) + .reverse() + .find((other): other is string => other !== null); + + if (!parent || !navItemContains(parent, href)) { + return level; + } + + const within = levelWithin(level, parent); + + return within.length > 0 ? within : level; + }); + + const current = + url ?? [...hrefs].reverse().find((href) => href !== null) ?? ''; + const selectedNav = withNavSelection(nav, current); + + const sections = crumbs.map((crumb, index): Array | null => { + const href = hrefs[index]; + + if (crumb.items?.length || !href) { + return null; + } + + const section = selectedNav.find( + (item) => + samePath(item.href, href) && + Array.isArray(item.subnav) && + offersChoice(item.subnav) + ); + + if (!section || !Array.isArray(section.subnav)) { + return null; + } + + const signature = levelSignature(section.subnav); + const offeredLater = levels + .slice(index + 1) + .some((level) => level !== null && levelSignature(level) === signature); + + return offeredLater ? null : section.subnav; + }); + + return crumbs.map((crumb, index) => { + const level = levels[index] ?? sections[index]; + return level ? {...crumb, items: navItemActions(level)} : crumb; }); } diff --git a/resources/js/common/layouts/screens/PageScreen.vue b/resources/js/common/layouts/screens/PageScreen.vue index 367a9ca7bc6..e57b548f4a0 100644 --- a/resources/js/common/layouts/screens/PageScreen.vue +++ b/resources/js/common/layouts/screens/PageScreen.vue @@ -115,7 +115,7 @@ const {nav, siteCrumb} = useCraftData(); const crumbs = computed | null>(() => { const merged = withSubnavCrumbs( - withNavCrumbMenus(page.props.crumbs ?? [], nav.value ?? []), + withNavCrumbMenus(page.props.crumbs ?? [], nav.value ?? [], page.url), subnav.value ); diff --git a/resources/js/common/types/globals.d.ts b/resources/js/common/types/globals.d.ts index 43eed684ca2..a5f30f0a5e5 100644 --- a/resources/js/common/types/globals.d.ts +++ b/resources/js/common/types/globals.d.ts @@ -3,6 +3,7 @@ import type {QueueService} from '@/modules/queue/queue'; import type {CpComponentRegistry} from '@/bootstrap/components'; import type {ElementDetailsTabRegistry} from '@/bootstrap/element-details-tabs'; import type {InertiaPageRegistry} from '@/bootstrap/inertia-pages'; +import type {Router} from '@inertiajs/core'; import type {HttpResponse} from '@craftcms/ui/utilities/api/http'; import type {LegacyRequestOptions} from '@craftcms/ui/utilities/api/legacyRequest'; @@ -75,6 +76,7 @@ interface CpStatic extends CpServices { $components: CpComponentRegistry; $elementDetailsTabs: ElementDetailsTabRegistry; $inertia: InertiaPageRegistry; + $router: Router; } interface CpNotificationSettings { diff --git a/resources/js/common/types/index.ts b/resources/js/common/types/index.ts index 98a05920994..ea748d43fc3 100644 --- a/resources/js/common/types/index.ts +++ b/resources/js/common/types/index.ts @@ -5,8 +5,8 @@ import type { VariantKey, } from '@craftcms/ui'; import type {ComboboxOptionData} from '@craftcms/ui/components/combobox/combobox'; -import type {Component} from 'vue'; import type {UrlMethodPair} from '@inertiajs/core'; +import type {Component} from 'vue'; import type {FormValues} from '@/modules/forms/types'; export type OptionData = ComboboxOptionData; @@ -143,6 +143,8 @@ export interface ActionItemButton { feedback?: ActionFeedback; keywords?: string; iconColor?: string; + /** A colored status dot before the label — `craft-indicator`'s own `fill` values. */ + fill?: string; /** * Items that hang off this one — the nav's own children. * @@ -320,6 +322,7 @@ export type EditableTableCellType = | 'autosuggest' | 'template' | 'number' + | 'money' | 'singleline' | 'multiline' | 'heading' diff --git a/resources/js/elements.ts b/resources/js/elements.ts new file mode 100644 index 00000000000..e5a26361852 --- /dev/null +++ b/resources/js/elements.ts @@ -0,0 +1,36 @@ +import {defineAsyncComponent} from 'vue'; + +/** + * The CP's element index and editor, and the pieces their pages build + * toolbars from, published to plugin bundles through the import map as + * `@craftcms/cms/elements` (see `Cp::sharedModules()`), so a plugin's own + * element pages can wrap the same `ElementIndexPage` and `ElementEditor` the + * CP's pages do. + * + * The components load through dynamic imports so Vite's preload helper brings + * their CSS along with them, the way the CP's own pages get theirs — an entry + * reached through the import map has no stylesheet links of its own. + */ +export const ElementIndexPage = defineAsyncComponent( + () => import('./modules/elements/index/components/ElementIndexPage.vue') +); + +export const ElementEditor = defineAsyncComponent( + () => import('./modules/elements/components/ElementEditor.vue') +); + +export const CpButtonLink = defineAsyncComponent( + () => import('./common/components/CpButtonLink.vue') +); + +export const ActionMenu = defineAsyncComponent( + () => import('./common/components/ActionMenu.vue') +); + +export type {ActionItem, ActionItemLink} from './common/types'; + +export { + appendIndexQuery, + type ElementIndexRoute, + type IndexQueryParams, +} from './modules/elements/index/composables/useElementIndexVisits'; diff --git a/resources/js/modules/admin-table/components/AdminTable.vue b/resources/js/modules/admin-table/components/AdminTable.vue index acc365c529b..bf2d8026fd4 100644 --- a/resources/js/modules/admin-table/components/AdminTable.vue +++ b/resources/js/modules/admin-table/components/AdminTable.vue @@ -1,6 +1,8 @@ + + + + diff --git a/resources/js/modules/admin-table/components/AdminTableToolbar.vue b/resources/js/modules/admin-table/components/AdminTableToolbar.vue new file mode 100644 index 00000000000..cb815a70fa0 --- /dev/null +++ b/resources/js/modules/admin-table/components/AdminTableToolbar.vue @@ -0,0 +1,39 @@ + + + diff --git a/resources/js/modules/admin-table/components/CreateActionButton.vue b/resources/js/modules/admin-table/components/CreateActionButton.vue new file mode 100644 index 00000000000..f06551fc1ce --- /dev/null +++ b/resources/js/modules/admin-table/components/CreateActionButton.vue @@ -0,0 +1,45 @@ + + + diff --git a/resources/js/modules/admin-table/components/MoveToPageButton.vue b/resources/js/modules/admin-table/components/MoveToPageButton.vue new file mode 100644 index 00000000000..67c88e0ab78 --- /dev/null +++ b/resources/js/modules/admin-table/components/MoveToPageButton.vue @@ -0,0 +1,107 @@ + + + + + diff --git a/resources/js/modules/editable-table/editable-table.ts b/resources/js/modules/editable-table/editable-table.ts index 0c92b53beba..d8994999102 100644 --- a/resources/js/modules/editable-table/editable-table.ts +++ b/resources/js/modules/editable-table/editable-table.ts @@ -1,7 +1,9 @@ import {Base} from '@craftcms/garnish'; import type CraftCombobox from '@craftcms/ui/components/combobox/combobox'; +import type {ComboboxItem} from '@craftcms/ui/components/combobox/combobox'; import type CraftTextExpander from '@craftcms/ui/components/text-expander/text-expander'; import '@craftcms/ui/components/text-expander/text-expander'; +import CraftInputMoney from '@craftcms/ui/components/input-money/input-money'; import '@craftcms/ui/components/field/field'; import '@craftcms/ui/components/field-group/field-group'; import '@craftcms/ui/components/select/select'; @@ -10,6 +12,7 @@ import type { EditableTableColumn, EditableTableColumns, EditableTableOption, + EditableTableOptionGroup, EditableTableOptions, EditableTableRow, EditableTableValue, @@ -27,6 +30,12 @@ declare const $: any; const noop = (): void => {}; +function isOptionGroup( + option: EditableTableOption | EditableTableOptionGroup +): option is EditableTableOptionGroup { + return Array.isArray((option as EditableTableOptionGroup).options); +} + function defaultOptionValue( options: EditableTableOptions | EditableTableOption[] | undefined ): EditableTableValue | null { @@ -582,8 +591,11 @@ export class EditableTable extends Base { ): any { void staticRows; + // Keep hidden rows' inputs mounted so they retain and submit their values. + // Some hosts override the UA [hidden] rule, so the class is also needed. const $tr = $('', { 'data-id': rowId, + ...(values._hidden ? {hidden: true, class: 'hidden'} : {}), }); for (const colId in columns) { @@ -684,6 +696,45 @@ export class EditableTable extends Base { .appendTo($cell); break; + case 'money': { + // New rows may start with an empty string rather than {value, locale}. + const moneyValue = + value instanceof Object && !Array.isArray(value) + ? ((value as Record).value ?? null) + : (value ?? null); + const moneyLocale = + (value instanceof Object && !Array.isArray(value) + ? (value as Record).locale + : undefined) ?? + col.locale ?? + 'en-US'; + const money = document.createElement( + 'craft-input-money' + ) as CraftInputMoney; + money.name = `${name}[value]`; + money.modelValue = moneyValue === null ? '' : String(moneyValue); + money.currency = col.currency ?? 'USD'; + money.locale = String(moneyLocale); + if (col.decimals !== undefined) money.decimals = col.decimals; + if (col.decimalSeparator !== undefined) { + money.decimalSeparator = col.decimalSeparator; + } + if (col.groupSeparator !== undefined) { + money.groupSeparator = col.groupSeparator; + } + if (col.showCurrency !== undefined) { + money.showCurrency = col.showCurrency; + } + if (col.clearable !== undefined) money.clearable = col.clearable; + $cell.append(money); + $('', { + type: 'hidden', + name: `${name}[locale]`, + value: String(moneyLocale), + }).appendTo($cell); + break; + } + case 'time': Craft.ui .createTimeInput({ @@ -727,10 +778,24 @@ export class EditableTable extends Base { combobox.name = name; combobox.label = col.heading ?? colId; combobox.options = Array.isArray(col.options) - ? col.options.map((option) => ({ - label: option.label ?? String(option.value ?? ''), - value: String(option.value ?? ''), - })) + ? col.options.map( + (option): ComboboxItem => + isOptionGroup(option) + ? { + type: 'optgroup', + label: option.label ?? '', + options: option.options.map((groupedOption) => ({ + label: + groupedOption.label ?? + String(groupedOption.value ?? ''), + value: String(groupedOption.value ?? ''), + })), + } + : { + label: option.label ?? String(option.value ?? ''), + value: String(option.value ?? ''), + } + ) : []; combobox.modelValue = String(value ?? ''); combobox.showAllOnEmpty = true; diff --git a/resources/js/modules/editable-table/types.ts b/resources/js/modules/editable-table/types.ts index 778b8818f56..af84bc4b824 100644 --- a/resources/js/modules/editable-table/types.ts +++ b/resources/js/modules/editable-table/types.ts @@ -11,12 +11,17 @@ export interface EditableTableColumn { type: EditableTableCellType | string; class?: string; heading?: string; + /** Tooltip shown beside the column heading. */ + info?: string; width?: string | number; placeholder?: string; rows?: number; code?: boolean; value?: string | number; - options?: EditableTableOptions | EditableTableOption[]; + options?: + | EditableTableOptions + | EditableTableOption[] + | EditableTableOptionGroup[]; prefixSelect?: EditableTablePrefixSelect; textExpanderTriggers?: TextExpanderTriggers; /** Checkbox: only one in the column may be checked at a time. */ @@ -25,8 +30,20 @@ export interface EditableTableColumn { toggle?: string[]; /** Auto-populate this column's value (a handle) from another column. */ autopopulate?: string; - /** Number column: locale used for formatting/parsing. */ + /** Number/money column: locale used for formatting/parsing. */ locale?: string; + /** Money column: ISO currency code (e.g. `USD`). Defaults to `USD`. */ + currency?: string; + /** Money column: fraction digits to allow. Defaults to the currency's own. */ + decimals?: number; + /** Money column: overrides the locale's own decimal separator. */ + decimalSeparator?: string; + /** Money column: overrides the locale's own thousands separator. */ + groupSeparator?: string; + /** Money column: shows the currency code/symbol prefix. Defaults to `true`. */ + showCurrency?: boolean; + /** Money column: shows a clear button once there's a value. Defaults to `true`. */ + clearable?: boolean; [key: string]: EditableTableColumnValue; } @@ -38,6 +55,7 @@ export type EditableTableValue = | EditableTableValue[] | EditableTableRow; +/** `_hidden` hides a row without removing its inputs or submitted values. */ export interface EditableTableRow { [key: string]: EditableTableValue; } @@ -58,6 +76,12 @@ export interface EditableTableOptions { [key: string]: EditableTableOption; } +export interface EditableTableOptionGroup { + label?: string; + type?: 'optgroup'; + options: EditableTableOption[]; +} + type EditableTableColumnValue = | string | number @@ -66,6 +90,7 @@ type EditableTableColumnValue = | string[] | EditableTableOptions | EditableTableOption[] + | EditableTableOptionGroup[] | EditableTablePrefixSelect | TextExpanderTriggers; diff --git a/resources/js/modules/elements/composables/useElementActionMenu.test.ts b/resources/js/modules/elements/composables/useElementActionMenu.test.ts index e715c118822..9465e2e4e98 100644 --- a/resources/js/modules/elements/composables/useElementActionMenu.test.ts +++ b/resources/js/modules/elements/composables/useElementActionMenu.test.ts @@ -7,6 +7,7 @@ import { openImageEditorDialog, type ImageEditorSettings, } from '@/modules/image-editor/open-image-editor-dialog'; +import {openFormModal} from '@/modules/forms/open-form-modal'; import { createElementActionMenu, useElementActionMenu, @@ -17,6 +18,7 @@ vi.mock('@/common/slideouts', () => ({openSlideout: vi.fn()})); vi.mock('@/modules/image-editor/open-image-editor-dialog', () => ({ openImageEditorDialog: vi.fn(), })); +vi.mock('@/modules/forms/open-form-modal', () => ({openFormModal: vi.fn()})); const {actionPost, deletionManagers} = vi.hoisted(() => ({ actionPost: vi.fn(), @@ -242,6 +244,38 @@ describe('useElementActionMenu', () => { expect(document.querySelector('form')).toBeNull(); }); + it('opens a formModal behavior and reloads the page once it’s submitted', () => { + const reload = vi + .spyOn(router, 'reload') + .mockImplementation(() => undefined); + + activate( + mount([ + { + label: 'Receive', + behavior: { + type: 'formModal', + modalUrl: 'things/receive-modal', + actionUrl: 'things/receive', + params: {thingId: 4}, + }, + }, + ]) + ); + + expect(openFormModal).toHaveBeenCalledWith({ + modalUrl: 'things/receive-modal', + actionUrl: 'things/receive', + params: {thingId: 4}, + onSubmitted: expect.any(Function), + }); + expect(reload).not.toHaveBeenCalled(); + + vi.mocked(openFormModal).mock.lastCall![0].onSubmitted!({}); + + expect(reload).toHaveBeenCalled(); + }); + describe('editImage', () => { const settings: ImageEditorSettings = { assetId: 7, diff --git a/resources/js/modules/elements/composables/useElementEditor.test.ts b/resources/js/modules/elements/composables/useElementEditor.test.ts index 529971f8c1e..89478045514 100644 --- a/resources/js/modules/elements/composables/useElementEditor.test.ts +++ b/resources/js/modules/elements/composables/useElementEditor.test.ts @@ -254,7 +254,9 @@ describe('useElementEditor', () => { const {editor} = mount( payload({ workflow: { - current: {status} as CraftCms.Cms.Workflow.Data.WorkflowReviewData, + current: { + status, + } as CraftCms.Cms.Workflow.Data.WorkflowReviewData, draftReviews: [], }, }) @@ -275,7 +277,9 @@ describe('useElementEditor', () => { const {editor} = mount( payload({ workflow: { - current: {status} as CraftCms.Cms.Workflow.Data.WorkflowReviewData, + current: { + status, + } as CraftCms.Cms.Workflow.Data.WorkflowReviewData, draftReviews: [], }, }) @@ -561,6 +565,56 @@ describe('useElementEditor', () => { expect(editor.formPayload.value?.values).toEqual({title: 'Latest title'}); }); + it('refreshes the layout for a reactive control and returns its payload', async () => { + const {editor} = mount( + payload({canAutosave: false, form: fieldLayout('Original title')}) + ); + postSpy.mockResolvedValue({data: {form: fieldLayout('Server title')}}); + + const refreshed = await editor.refreshLayout({}, []); + + expect(postSpy.mock.calls[0]?.[0]).toContain( + '/elements/update-field-layout' + ); + expect(refreshed.values).toEqual({title: 'Server title'}); + expect(editor.formPayload.value?.values).toEqual({title: 'Server title'}); + }); + + it('leaves the layout to the renderer when refreshing a nested scope', async () => { + const {editor} = mount( + payload({canAutosave: false, form: fieldLayout('Original title')}) + ); + const nested = {...fieldLayout('Nested title'), scope: ['fields']}; + postSpy.mockResolvedValue({data: {form: nested}}); + + const refreshed = await editor.refreshLayout({}, ['fields']); + + expect(postSpy.mock.calls[0]?.[2]?.headers).toMatchObject({ + 'X-Craft-Form-Scope': JSON.stringify(['fields']), + }); + expect(refreshed).toEqual(nested); + expect(editor.formPayload.value?.values).toEqual({ + title: 'Original title', + }); + }); + + it('rejects a layout refresh that a newer one superseded', async () => { + const first = deferred<{data: {form: FormPayload}}>(); + postSpy + .mockImplementationOnce(() => first.promise) + .mockResolvedValueOnce({data: {form: fieldLayout('Latest title')}}); + const {editor} = mount( + payload({canAutosave: false, form: fieldLayout('Original title')}) + ); + + const stale = editor.refreshLayout({}, []); + await editor.refreshLayout({}, []); + first.resolve({data: {form: fieldLayout('Stale title')}}); + + await expect(stale).rejects.toThrow(); + expect(editor.formPayload.value?.values).toEqual({title: 'Latest title'}); + }); + it('ignores a refresh that predates an authoritative page payload', async () => { const refresh = deferred<{data: {form: FormPayload}}>(); postSpy.mockImplementationOnce(() => refresh.promise); @@ -1566,7 +1620,9 @@ describe('useElementEditor', () => { Preview: {refresh}, }); stubSaveRequest(() => - Promise.resolve({data: {message: 'Entry saved.', element: {id: 12}}}) + Promise.resolve({ + data: {message: 'Entry saved.', element: {id: 12}}, + }) ); const {editor} = mount(payload(), handledSlideout()); @@ -1574,7 +1630,10 @@ describe('useElementEditor', () => { await vi.waitFor(() => expect(displaySuccess).toHaveBeenCalled()); expect(displaySuccess.mock.calls[0]![0]).toBe('Entry saved.'); - expect(postMessage).toHaveBeenCalledWith({event: 'saveElement', id: 12}); + expect(postMessage).toHaveBeenCalledWith({ + event: 'saveElement', + id: 12, + }); expect(refresh).toHaveBeenCalled(); }); }); diff --git a/resources/js/modules/elements/index/components/IndexViewSettings.vue b/resources/js/modules/elements/index/components/IndexViewSettings.vue index a89fd021148..866d3eea543 100644 --- a/resources/js/modules/elements/index/components/IndexViewSettings.vue +++ b/resources/js/modules/elements/index/components/IndexViewSettings.vue @@ -9,7 +9,7 @@ const props = defineProps<{ /** The toggleable/reorderable table columns. */ options: Array; - /** The sortable attributes for the "Sort by" select. */ + /** The sortable attributes for the "Sort by" select; an empty `value` means no sort. */ sortOptions: Array; /** Whether the active sort has an intrinsic direction. */ sortDirectionLocked?: boolean; @@ -99,7 +99,7 @@
-
+