diff --git a/.gitignore b/.gitignore index 3743004f5f7..3b7048d5dd2 100644 --- a/.gitignore +++ b/.gitignore @@ -14,6 +14,7 @@ Source/.idea/* Build/* *.sass-cache node_modules/ +.pnpm-store/ vendor/ /bower_components cpresources/* diff --git a/CHANGELOG-WIP.md b/CHANGELOG-WIP.md index 98777683b2b..479c361ad5f 100644 --- a/CHANGELOG-WIP.md +++ b/CHANGELOG-WIP.md @@ -643,6 +643,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 - Deprecated `craft\services\Drafts`. `CraftCms\Cms\Element\Drafts` should be used instead. 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 bf5aedd7454..dcf1b03e436 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, @@ -479,6 +482,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 c31a5c787cb..61b4fec0bef 100644 --- a/resources/js/common/layouts/screens/PageScreen.vue +++ b/resources/js/common/layouts/screens/PageScreen.vue @@ -118,7 +118,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 8c755e07d98..c0b09c8531e 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; /** Runs before the Inertia app mounts; register plugin components here. */ booting(callback: (instance: CpStatic) => void): void; booted(callback: (instance: CpStatic) => void): void; diff --git a/resources/js/common/types/index.ts b/resources/js/common/types/index.ts index efb2e7d4f4a..9707b3a9939 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' | 'money' | 'multiline' 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.test.ts b/resources/js/modules/admin-table/components/AdminTable.test.ts index d5f82280174..99c4f6bc468 100644 --- a/resources/js/modules/admin-table/components/AdminTable.test.ts +++ b/resources/js/modules/admin-table/components/AdminTable.test.ts @@ -19,37 +19,56 @@ afterEach(() => { vi.unstubAllGlobals(); }); -it('renders settings rows and drives caller pagination without an element payload', () => { - const onPaginationChange = vi.fn(); - const table = useCraftTable({ - data: [{id: 1, name: 'Settings record'}], - columns: [ - {accessorKey: 'name', header: 'Name', cell: ({getValue}) => getValue()}, - ], - state: {pagination: {pageIndex: 0, pageSize: 50}}, - manualPagination: true, - rowCount: 151, - onPaginationChange, - }); - const host = document.createElement('div'); - document.body.append(host); - const app = createApp({ - render: () => h(AdminTable, {table, from: 1, to: 50, total: 151} as never), - }); - app.config.compilerOptions.isCustomElement = (tag) => tag.includes('-'); - app.mount(host); - teardown = () => app.unmount(); - expect(host.querySelector('tbody')?.textContent).toContain('Settings record'); - Array.from(host.querySelectorAll('craft-icon')) - .find( - (icon) => (icon as HTMLElement & {name: string}).name === 'chevron-right' - )! - .closest('craft-button')! - .dispatchEvent(new MouseEvent('click', {bubbles: true})); - expect(onPaginationChange).toHaveBeenCalledOnce(); - const update = onPaginationChange.mock.calls[0]![0]; - expect(update({pageIndex: 0, pageSize: 50})).toEqual({ - pageIndex: 1, - pageSize: 50, - }); -}); +it.each([undefined, true, false])( + 'renders settings rows with footer option %s', + (showFooter) => { + const onPaginationChange = vi.fn(); + const table = useCraftTable({ + data: [{id: 1, name: 'Settings record'}], + columns: [ + {accessorKey: 'name', header: 'Name', cell: ({getValue}) => getValue()}, + ], + state: {pagination: {pageIndex: 0, pageSize: 50}}, + manualPagination: true, + rowCount: 151, + onPaginationChange, + }); + const host = document.createElement('div'); + document.body.append(host); + const app = createApp({ + render: () => + h(AdminTable, { + table, + from: 1, + to: 50, + total: 151, + showFooter, + } as never), + }); + app.config.compilerOptions.isCustomElement = (tag) => tag.includes('-'); + app.mount(host); + teardown = () => app.unmount(); + expect(host.querySelector('tbody')?.textContent).toContain( + 'Settings record' + ); + if (showFooter === false) { + expect(host.querySelector('.admin-table__footer')).toBeNull(); + expect(onPaginationChange).not.toHaveBeenCalled(); + return; + } + expect(host.querySelector('.admin-table__footer')).not.toBeNull(); + Array.from(host.querySelectorAll('craft-icon')) + .find( + (icon) => + (icon as HTMLElement & {name: string}).name === 'chevron-right' + )! + .closest('craft-button')! + .dispatchEvent(new MouseEvent('click', {bubbles: true})); + expect(onPaginationChange).toHaveBeenCalledOnce(); + const update = onPaginationChange.mock.calls[0]![0]; + expect(update({pageIndex: 0, pageSize: 50})).toEqual({ + pageIndex: 1, + pageSize: 50, + }); + } +); diff --git a/resources/js/modules/admin-table/components/AdminTable.vue b/resources/js/modules/admin-table/components/AdminTable.vue index acc365c529b..e5ec857be90 100644 --- a/resources/js/modules/admin-table/components/AdminTable.vue +++ b/resources/js/modules/admin-table/components/AdminTable.vue @@ -1,6 +1,8 @@ @@ -60,6 +99,7 @@ :loading="loading" :layout="layout" :spacing="spacing" + :with-bottom-border="!footerVisible" @reorder="(start, end) => emit('reorder', start, end)" > -