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)"
>
-