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 @@
+
+
+
+
+
+
+ {{ t('Clear selection') }}
+
+
+
+
+
+
+
+ {{ t('Set status') }}
+
+
+
+
+
+
+
+
+ {{ t('Actions') }}
+
+
+
+
+
+
+
+
+
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 @@
+
+
+
+ {{ label }}
+
+
+
+ {{ label }}
+
+
+
+
+
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 @@
+
+
+
+
+
+
+ {{ t('Move to page…') }}
+
+
+
+
+ {{ t('Move') }}
+
+
+
+
+
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 @@
-
+
diff --git a/resources/js/modules/elements/types/actions.ts b/resources/js/modules/elements/types/actions.ts
index 7ddd131014f..ad0fd067128 100644
--- a/resources/js/modules/elements/types/actions.ts
+++ b/resources/js/modules/elements/types/actions.ts
@@ -1,16 +1,14 @@
/**
- * A bulk element action, serialized by `ElementActions::serializeActionItems()`
- * on the server as a primitive "action item" descriptor. These are rendered
- * through the shared `craft-action-item` component (via `ActionMenu`), so the
- * request, confirm dialog, spinner, and feedback are all handled by the
- * built-in `runAction()` primitives rather than bespoke code.
- *
- * The `action.body` carries the action class + its server-baked settings; the
- * client merges in the live selection (`elementIds`) and index context
- * (`elementType`, `source`, `context`) before performing.
+ * A `BulkActionsBar` menu/status item, as a primitive descriptor rather than a
+ * fully-built `ActionItem` — the bar itself fills in the selection (under
+ * `idsField`) and any shared `actionContext` before performing, so the
+ * request, confirm dialog, spinner, and feedback all come from the built-in
+ * `runAction()` primitives rather than bespoke code. Not element-specific:
+ * `action.body` is whatever the target endpoint expects, element index or not.
*/
export interface BulkActionItem {
- /** The action's class string — a stable id for singling out specific actions. */
+ type?: 'button';
+ /** A stable id for singling out specific actions (e.g. matching one across renders). */
key: string;
/** The action's trigger/button label. */
label: string;
@@ -20,13 +18,21 @@ export interface BulkActionItem {
variant?: string;
/** Disabled actions (e.g. interactive ones not yet ported to Vue). */
disabled?: boolean;
- /** `false` if only performable on a single selected element (mirrors the legacy `bulk: false` trigger setting). Omitted when bulk-capable. */
+ /** `false` if only performable on a single selected row (mirrors the legacy `bulk: false` trigger setting). Omitted when bulk-capable. */
bulk?: boolean;
- /** Limits the action to real elements or synthetic asset-folder rows. */
+ /** Limits the action to real elements or synthetic asset-folder rows. Meaningless outside an element index. */
appliesTo?: 'elements' | 'folders';
+ /** A colored status dot before the label — `craft-indicator`'s own `fill` values. Only meaningful in a `statuses` list. */
+ fill?: string;
+ /**
+ * An imperative alternative to `action`, for a caller that needs to do more
+ * than post-and-refresh (optimistic local removal, say). Takes priority
+ * over `action` when both are given.
+ */
+ onClick?: (event: Event) => void;
/** Row capability that every selected element must expose as truthy. */
selectionAttribute?: keyof ElementCapabilities;
- /** The primitive action descriptor. Absent for disabled/placeholder items. */
+ /** The primitive action descriptor. Ignored when `onClick` is set; absent for disabled/placeholder items. */
action?:
| {
type: 'event';
@@ -46,6 +52,14 @@ export interface BulkActionItem {
};
}
+/** A heading over a run of `BulkActionItem`s, for `actions`/`statuses`. */
+export interface BulkActionGroup {
+ type: 'group';
+ heading?: string;
+ items: Array;
+}
+
+export type BulkAction = BulkActionItem | BulkActionGroup | ActionItemDisplay;
export type BulkActionParams = Record;
export interface ElementCapabilities {
@@ -94,4 +108,5 @@ export interface BulkActionEventDetail {
elementType: string;
trigger: HTMLElement;
}
+import type {ActionItemDisplay} from '@/common/types';
import type {FormValues} from '@/modules/forms/types';
diff --git a/resources/js/modules/forms/AdminTableDeleteModal.test.ts b/resources/js/modules/forms/AdminTableDeleteModal.test.ts
new file mode 100644
index 00000000000..e53dd33632e
--- /dev/null
+++ b/resources/js/modules/forms/AdminTableDeleteModal.test.ts
@@ -0,0 +1,135 @@
+import {actionClient} from '@craftcms/ui';
+import {createApp, defineComponent, h, nextTick} from 'vue';
+import {afterEach, beforeEach, describe, expect, it, vi} from 'vite-plus/test';
+import AdminTableDeleteModal from './AdminTableDeleteModal.vue';
+
+const modalProps = vi.hoisted(() => ({current: {} as Record}));
+const rendererProps = vi.hoisted(() => ({
+ current: {} as Record,
+}));
+
+vi.mock('@/common/components/ModalForm.vue', () => ({
+ default: defineComponent({
+ props: ['isActive', 'title', 'submitLabel', 'loading'],
+ emits: ['submit', 'close'],
+ setup(props, {slots, emit}) {
+ return () => {
+ modalProps.current = {...props};
+ return h(
+ 'form',
+ {
+ 'data-modal': '',
+ onSubmit: (e: Event) => (e.preventDefault(), emit('submit')),
+ },
+ slots.default?.()
+ );
+ };
+ },
+ }),
+}));
+
+vi.mock('./FormRenderer.vue', () => ({
+ default: defineComponent({
+ props: ['payload', 'errors'],
+ setup(props, {expose}) {
+ expose({currentValues: () => ({destination: '3'})});
+ return () => {
+ rendererProps.current = {...props};
+ return h('div', {'data-renderer': ''});
+ };
+ },
+ }),
+}));
+
+const flush = async () => {
+ await Promise.resolve();
+ await nextTick();
+ await nextTick();
+};
+
+describe('AdminTableDeleteModal', () => {
+ let app: ReturnType;
+ let container: HTMLElement;
+ const get = vi.spyOn(actionClient, 'get');
+ const post = vi.spyOn(actionClient, 'post');
+ const events: string[] = [];
+
+ beforeEach(() => {
+ (window as any).Craft = {
+ cp: {displayError: vi.fn(), displayNotice: vi.fn()},
+ };
+ get.mockResolvedValue({
+ data: {
+ form: {nodes: [], values: {destination: '2'}},
+ title: 'Deleting it',
+ submitLabel: 'Remove',
+ },
+ });
+ events.length = 0;
+ container = document.createElement('div');
+ document.body.append(container);
+ app = createApp(() =>
+ h(AdminTableDeleteModal, {
+ modalUrl: 'things/delete-modal',
+ deleteUrl: 'things/delete',
+ rowId: 7,
+ onDeleted: () => events.push('deleted'),
+ onClose: () => events.push('close'),
+ })
+ );
+ app.mount(container);
+ });
+
+ afterEach(() => {
+ app.unmount();
+ container.remove();
+ vi.clearAllMocks();
+ });
+
+ it('loads the Form for the row and uses the returned labels', async () => {
+ await flush();
+
+ expect(get).toHaveBeenCalledWith('things/delete-modal', {params: {id: 7}});
+ expect(modalProps.current).toMatchObject({
+ isActive: true,
+ title: 'Deleting it',
+ submitLabel: 'Remove',
+ });
+ expect(rendererProps.current.payload).toEqual({
+ nodes: [],
+ values: {destination: '2'},
+ });
+ });
+
+ it('posts the Form values with the row id to the delete URL', async () => {
+ post.mockResolvedValue({data: {}});
+ await flush();
+
+ container.querySelector('form')!.dispatchEvent(new Event('submit'));
+ await flush();
+
+ expect(post).toHaveBeenCalledWith('things/delete', {
+ destination: '3',
+ id: 7,
+ });
+ expect(events).toEqual(['deleted']);
+ });
+
+ it('shows validation errors against their control paths', async () => {
+ post.mockRejectedValue({
+ response: {
+ data: {message: 'Nope', errors: {'address.line1': ['Required']}},
+ },
+ });
+ await flush();
+
+ container.querySelector('form')!.dispatchEvent(new Event('submit'));
+ await flush();
+
+ expect(rendererProps.current.errors).toEqual([
+ {path: ['address', 'line1'], messages: ['Required']},
+ ]);
+ expect((window as any).Craft.cp.displayError).toHaveBeenCalledWith('Nope');
+ expect(events).toEqual([]);
+ });
+});
diff --git a/resources/js/modules/forms/AdminTableDeleteModal.vue b/resources/js/modules/forms/AdminTableDeleteModal.vue
new file mode 100644
index 00000000000..06b221f27ec
--- /dev/null
+++ b/resources/js/modules/forms/AdminTableDeleteModal.vue
@@ -0,0 +1,34 @@
+
+
+
+
+
diff --git a/resources/js/modules/forms/AdminTableNode.vue b/resources/js/modules/forms/AdminTableNode.vue
new file mode 100644
index 00000000000..1008b2412d0
--- /dev/null
+++ b/resources/js/modules/forms/AdminTableNode.vue
@@ -0,0 +1,1134 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ {{ option.label }}
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/resources/js/modules/forms/ColorSelectControl.vue b/resources/js/modules/forms/ColorSelectControl.vue
index 93311dd2562..9c1eaa25be8 100644
--- a/resources/js/modules/forms/ColorSelectControl.vue
+++ b/resources/js/modules/forms/ColorSelectControl.vue
@@ -50,7 +50,7 @@
? BLANK_VALUE
: String(value ?? '')
"
- :allow-transparent="control.props.allowTransparent ?? false"
+ :allow-transparent="control.props.allowTransparent || undefined"
:blank-label="control.props.blankLabel"
:colors="control.props.colors ?? undefined"
:required="editable && required"
diff --git a/resources/js/modules/forms/ComboboxCreateOption.test.ts b/resources/js/modules/forms/ComboboxCreateOption.test.ts
new file mode 100644
index 00000000000..14c3fa8ef7b
--- /dev/null
+++ b/resources/js/modules/forms/ComboboxCreateOption.test.ts
@@ -0,0 +1,230 @@
+import {createApp, h, nextTick, ref} from 'vue';
+import {afterEach, beforeEach, expect, it, vi} from 'vite-plus/test';
+import type CraftCombobox from '@craftcms/ui/components/combobox/combobox';
+import type {ComboboxItem} from '@craftcms/ui/components/combobox/combobox';
+import '@craftcms/ui/components/combobox/combobox';
+import CraftComboboxVue from '@craftcms/ui/vue/CraftCombobox.vue';
+import ComboboxControl from './ComboboxControl.vue';
+
+const slideout = vi.hoisted(() => ({openSlideout: vi.fn()}));
+vi.mock('@/common/slideouts', () => slideout);
+
+const options = [
+ {label: 'Existing', value: '42'},
+ {
+ label: 'Create category',
+ value: '__add__',
+ data: {
+ create: {
+ url: '/categories/new',
+ resultKey: 'category',
+ labelField: 'name',
+ valueField: 'id',
+ },
+ },
+ },
+];
+
+let app: ReturnType | undefined;
+let container: HTMLElement;
+const attachInternals = Object.getOwnPropertyDescriptor(
+ HTMLElement.prototype,
+ 'attachInternals'
+);
+
+beforeEach(() => {
+ Object.defineProperty(HTMLElement.prototype, 'attachInternals', {
+ configurable: true,
+ value: () => ({setFormValue: vi.fn(), setValidity: vi.fn()}),
+ });
+});
+
+afterEach(() => {
+ app?.unmount();
+ container?.remove();
+ slideout.openSlideout.mockReset();
+ if (attachInternals) {
+ Object.defineProperty(
+ HTMLElement.prototype,
+ 'attachInternals',
+ attachInternals
+ );
+ } else {
+ Reflect.deleteProperty(HTMLElement.prototype, 'attachInternals');
+ }
+});
+
+async function fixture(multiple = false, items: ComboboxItem[] = options) {
+ container = document.createElement('div');
+ document.body.append(container);
+ const selection = ref(multiple ? ['42'] : '42');
+ const changes = vi.fn(
+ (value: string | string[]) => (selection.value = value)
+ );
+ app = createApp({
+ render: () =>
+ h(ComboboxControl, {
+ control: {
+ component: 'craft:combobox',
+ type: 'Combobox',
+ path: ['category'],
+ deltaGroup: ['category'],
+ mode: 'editable',
+ props: {options: items, multiple},
+ },
+ value: selection.value,
+ editable: true,
+ invalid: false,
+ required: false,
+ 'onUpdate:value': changes,
+ }),
+ });
+ app.mount(container);
+ await nextTick();
+ const combobox = container.querySelector('craft-combobox') as CraftCombobox;
+ await combobox.updateComplete;
+ await vi.waitFor(() =>
+ expect(combobox.querySelectorAll('craft-option')).toHaveLength(2)
+ );
+ return {combobox, selection, changes};
+}
+
+it('selects ordinary options without opening a slideout', async () => {
+ const {combobox, selection} = await fixture();
+ selection.value = '';
+ await nextTick();
+ combobox.querySelectorAll('craft-option')[0]!.click();
+
+ await vi.waitFor(() => expect(selection.value).toBe('42'));
+ expect(slideout.openSlideout).not.toHaveBeenCalled();
+});
+
+it('exposes a native selection event before syncing the Vue model', async () => {
+ container = document.createElement('div');
+ document.body.append(container);
+ const selection = ref('');
+ const updated = vi.fn(
+ (value: string | string[] | number | boolean | undefined) =>
+ (selection.value = String(value ?? ''))
+ );
+ app = createApp({
+ render: () =>
+ h(CraftComboboxVue, {
+ options,
+ modelValue: selection.value,
+ 'onUpdate:modelValue': updated,
+ 'onModel-value-changed': (
+ event: CustomEvent,
+ cancelModelUpdate: () => void
+ ) => {
+ if ((event.target as CraftCombobox).modelValue === '__add__') {
+ cancelModelUpdate();
+ }
+ },
+ }),
+ });
+ app.mount(container);
+ const combobox = container.querySelector('craft-combobox') as CraftCombobox;
+ await vi.waitFor(() =>
+ expect(combobox.querySelectorAll('craft-option')).toHaveLength(2)
+ );
+
+ combobox.querySelectorAll('craft-option')[0]!.click();
+ await vi.waitFor(() => expect(selection.value).toBe('42'));
+ updated.mockClear();
+
+ combobox.querySelectorAll('craft-option')[1]!.click();
+ await nextTick();
+ expect(updated).not.toHaveBeenCalled();
+});
+
+it('opens the create screen when its option is selected by keyboard', async () => {
+ slideout.openSlideout.mockResolvedValue(null);
+ const {combobox, selection} = await fixture();
+ const input = combobox.querySelector('input')!;
+ input.focus();
+ input.value = 'Create';
+ input.dispatchEvent(new Event('input', {bubbles: true}));
+ await vi.waitFor(() => expect(combobox.activeIndex).toBe(0));
+ input.dispatchEvent(
+ new KeyboardEvent('keydown', {key: 'Enter', bubbles: true})
+ );
+
+ await vi.waitFor(() =>
+ expect(slideout.openSlideout).toHaveBeenCalledTimes(1)
+ );
+ await vi.waitFor(() => expect(selection.value).toBe('42'));
+ expect(combobox.modelValue).toBe('42');
+});
+
+it.each([false, true])(
+ 'keeps the prior selection on cancel and selects the saved record (multiple: %s)',
+ async (multiple) => {
+ slideout.openSlideout.mockResolvedValue(null);
+ const {combobox, selection, changes} = await fixture(multiple);
+ combobox.querySelectorAll('craft-option')[1]!.click();
+
+ await vi.waitFor(() =>
+ expect(slideout.openSlideout).toHaveBeenCalledTimes(1)
+ );
+ await vi.waitFor(() =>
+ expect(combobox.modelValue).toEqual(multiple ? ['42'] : '42')
+ );
+ expect(selection.value).toEqual(multiple ? ['42'] : '42');
+ expect(changes).not.toHaveBeenCalled();
+
+ const [url, config] = slideout.openSlideout.mock.calls[0]!;
+ expect(url).toBe('/categories/new');
+ config.onSaved({data: {category: {name: 'New category', id: 73}}});
+ await vi.waitFor(() =>
+ expect(selection.value).toEqual(multiple ? ['42', '73'] : '73')
+ );
+ expect(
+ combobox.options.flatMap((item) =>
+ item.type === 'optgroup'
+ ? item.options.map((option) => option.value)
+ : [item.value]
+ )
+ ).toEqual(['42', '73', '__add__']);
+ expect(changes).toHaveBeenCalledTimes(1);
+ if (!multiple) {
+ expect(combobox.querySelector('input')?.value).toBe('New category');
+ }
+ }
+);
+
+it('selects a created volume by UID rather than its numeric ID', async () => {
+ slideout.openSlideout.mockResolvedValue(null);
+ const {combobox, selection} = await fixture(false, [
+ {label: 'Existing', value: '42'},
+ {
+ label: 'Create a new volume…',
+ value: '__createVolume__',
+ data: {
+ create: {
+ url: '/settings/assets/new',
+ resultKey: 'volume',
+ labelField: 'name',
+ valueField: 'uid',
+ },
+ },
+ },
+ ]);
+ combobox.querySelectorAll('craft-option')[1]!.click();
+
+ await vi.waitFor(() =>
+ expect(slideout.openSlideout).toHaveBeenCalledTimes(1)
+ );
+ slideout.openSlideout.mock.calls[0]![1].onSaved({
+ data: {volume: {id: 73, name: 'User Photos', uid: 'volume-uid'}},
+ });
+
+ await vi.waitFor(() => expect(selection.value).toBe('volume-uid'));
+ expect(
+ combobox.options.flatMap((item) =>
+ item.type === 'optgroup'
+ ? item.options.map((option) => option.value)
+ : [item.value]
+ )
+ ).toEqual(['42', 'volume-uid', '__createVolume__']);
+});
diff --git a/resources/js/modules/forms/FormModal.test.ts b/resources/js/modules/forms/FormModal.test.ts
new file mode 100644
index 00000000000..71f575323f6
--- /dev/null
+++ b/resources/js/modules/forms/FormModal.test.ts
@@ -0,0 +1,121 @@
+import {actionClient} from '@craftcms/ui';
+import {createApp, defineComponent, h, nextTick} from 'vue';
+import {afterEach, beforeEach, describe, expect, it, vi} from 'vite-plus/test';
+import FormModal from './FormModal.vue';
+
+const modalProps = vi.hoisted(() => ({current: {} as Record}));
+
+vi.mock('@/common/components/ModalForm.vue', () => ({
+ default: defineComponent({
+ props: ['isActive', 'title', 'submitLabel', 'loading', 'width'],
+ emits: ['submit', 'close'],
+ setup(props, {slots, emit}) {
+ return () => {
+ modalProps.current = {...props};
+ return h(
+ 'form',
+ {onSubmit: (e: Event) => (e.preventDefault(), emit('submit'))},
+ slots.default?.()
+ );
+ };
+ },
+ }),
+}));
+
+vi.mock('./FormRenderer.vue', () => ({
+ default: defineComponent({
+ props: ['payload', 'errors'],
+ setup(_props, {expose}) {
+ expose({currentValues: () => ({details: {a: {accept: '2'}}})});
+ return () => h('div');
+ },
+ }),
+}));
+
+const flush = async () => {
+ await Promise.resolve();
+ await nextTick();
+ await nextTick();
+};
+
+describe('FormModal', () => {
+ let app: ReturnType;
+ let container: HTMLElement;
+ const get = vi.spyOn(actionClient, 'get');
+ const post = vi.spyOn(actionClient, 'post');
+ const submitted: Array> = [];
+
+ function mount(props: Record = {}): void {
+ container = document.createElement('div');
+ document.body.append(container);
+ app = createApp(() =>
+ h(FormModal, {
+ modalUrl: 'things/receive-modal',
+ actionUrl: 'things/receive',
+ params: {thingId: 4},
+ onSubmitted: (data: Record) => submitted.push(data),
+ ...props,
+ })
+ );
+ app.mount(container);
+ }
+
+ beforeEach(() => {
+ (window as any).Craft = {
+ cp: {displayError: vi.fn(), displayNotice: vi.fn()},
+ };
+ get.mockResolvedValue({data: {form: {nodes: [], values: {}}}});
+ submitted.length = 0;
+ });
+
+ afterEach(() => {
+ app.unmount();
+ container.remove();
+ vi.clearAllMocks();
+ });
+
+ it('loads the Form with its params and falls back to its own labels', async () => {
+ mount({title: 'Receive', submitLabel: 'Receive now'});
+ await flush();
+
+ expect(get).toHaveBeenCalledWith('things/receive-modal', {
+ params: {thingId: 4},
+ });
+ expect(modalProps.current).toMatchObject({
+ isActive: true,
+ title: 'Receive',
+ submitLabel: 'Receive now',
+ width: 'md',
+ });
+ });
+
+ it('posts the Form values with its params, then reports the response', async () => {
+ post.mockResolvedValue({data: {message: 'Received', ok: true}});
+ mount();
+ await flush();
+
+ container.querySelector('form')!.dispatchEvent(new Event('submit'));
+ await flush();
+
+ expect(post).toHaveBeenCalledWith('things/receive', {
+ details: {a: {accept: '2'}},
+ thingId: 4,
+ });
+ expect((window as any).Craft.cp.displayNotice).toHaveBeenCalledWith(
+ 'Received'
+ );
+ expect(submitted).toEqual([{message: 'Received', ok: true}]);
+ });
+
+ it('closes when the Form can’t be loaded', async () => {
+ const closed = vi.fn();
+ get.mockRejectedValue({response: {data: {message: 'Not found'}}});
+ mount({onClose: closed});
+ await flush();
+
+ expect((window as any).Craft.cp.displayError).toHaveBeenCalledWith(
+ 'Not found'
+ );
+ expect(closed).toHaveBeenCalled();
+ });
+});
diff --git a/resources/js/modules/forms/GroupNode.vue b/resources/js/modules/forms/GroupNode.vue
index fac771b964e..6d80b53cf6f 100644
--- a/resources/js/modules/forms/GroupNode.vue
+++ b/resources/js/modules/forms/GroupNode.vue
@@ -18,10 +18,12 @@
asField?: boolean;
required?: boolean;
instructions?: string | null;
+ instructionsPosition?: 'before' | 'after';
tip?: string;
tipHtml?: string;
warning?: string;
warningHtml?: string;
+ layoutUid?: string;
width?: number;
/** Absolute path of the reactive control whose refresh loads this group. */
dependsOn?: string[];
@@ -60,6 +62,7 @@
:label="node.props.label ?? undefined"
:required="node.props.required || undefined"
:help-text="node.props.instructions ?? undefined"
+ :instructions-position="node.props.instructionsPosition"
:class="{
[`width-${node.props.width}`]: Boolean(node.props.width),
hidden: Boolean(node.props.hidden),
@@ -67,6 +70,7 @@
}"
:hidden="node.props.hidden || undefined"
:data-form-node="node.uid"
+ :data-layout-element="node.props.layoutUid"
:aria-busy="showLoading || undefined"
>
diff --git a/resources/js/modules/forms/TableControl.vue b/resources/js/modules/forms/TableControl.vue
index 75fffe26a9f..0060e0a1a82 100644
--- a/resources/js/modules/forms/TableControl.vue
+++ b/resources/js/modules/forms/TableControl.vue
@@ -1,7 +1,11 @@
-
+
diff --git a/resources/js/pages/settings/users/Settings.test.ts b/resources/js/pages/settings/users/Settings.test.ts
index 5a9a78a7efb..461ea74e86f 100644
--- a/resources/js/pages/settings/users/Settings.test.ts
+++ b/resources/js/pages/settings/users/Settings.test.ts
@@ -12,51 +12,15 @@ interface CheckboxGroupElement extends HTMLElement {
}
const state = vi.hoisted(() => ({
- openSlideout: vi.fn(),
- setPhotoVolume: vi.fn(),
setRequire2fa: vi.fn(),
}));
-vi.mock('@actions/Settings/VolumesController', () => ({
- create: {url: () => '/settings/assets/new'},
-}));
-
-vi.mock('@/common/slideouts', () => ({
- openSlideout: state.openSlideout,
-}));
-
-vi.mock('@craftcms/ui/vue/CraftCombobox.vue', () => ({
- default: defineComponent({
- emits: ['update:modelValue'],
- setup:
- (_, {emit}) =>
- () =>
- h('button', {
- 'data-create-volume': '',
- onClick: () => emit('update:modelValue', '__createVolume__'),
- }),
- }),
-}));
-
vi.mock('@/pages/Form.vue', () => ({
default: defineComponent({
setup:
(_, {slots}) =>
() =>
h('div', [
- slots.photoVolumeUid?.({
- control: control('photoVolumeUid', {
- options: [
- {label: 'Existing', value: 'existing'},
- {label: 'Create a new volume…', value: '__createVolume__'},
- ],
- }),
- value: '',
- setValue: state.setPhotoVolume,
- editable: true,
- invalid: false,
- required: false,
- }),
slots.require2fa?.({
control: control('require2fa', {
options: [
@@ -87,8 +51,6 @@ let app: ReturnType;
let container: HTMLElement;
beforeEach(() => {
- state.openSlideout.mockReset();
- state.setPhotoVolume.mockReset();
state.setRequire2fa.mockReset();
container = document.createElement('div');
document.body.append(container);
@@ -99,24 +61,6 @@ afterEach(() => {
container.remove();
});
-it('selects a volume created from the combobox', async () => {
- mount();
- container.querySelector('[data-create-volume]')?.click();
-
- expect(state.openSlideout).toHaveBeenCalledWith(
- '/settings/assets/new',
- expect.objectContaining({onSaved: expect.any(Function)})
- );
-
- const openCall = state.openSlideout.mock.calls[0];
- if (!openCall) throw new Error('Expected the volume slideout to open.');
- openCall[1].onSaved({
- data: {volume: {name: 'User Photos', uid: 'new-volume'}},
- });
-
- expect(state.setPhotoVolume).toHaveBeenCalledWith('new-volume');
-});
-
it('keeps the exclusive two-step verification value shape', () => {
mount();
const group = container.querySelector(
diff --git a/resources/js/vue.ts b/resources/js/vue.ts
new file mode 100644
index 00000000000..b095759159c
--- /dev/null
+++ b/resources/js/vue.ts
@@ -0,0 +1,6 @@
+/**
+ * The CP's own Vue instance, published to plugin bundles through the import
+ * map (see `Cp::registerImportMap()`) so their components share one runtime
+ * with the CP rather than each bundling their own copy.
+ */
+export * from 'vue';
diff --git a/resources/templates/_includes/forms/editableTable.twig b/resources/templates/_includes/forms/editableTable.twig
index ad8b3df02bf..7c6726ab4cb 100644
--- a/resources/templates/_includes/forms/editableTable.twig
+++ b/resources/templates/_includes/forms/editableTable.twig
@@ -113,7 +113,7 @@
{% set rowNumber = loop.index %}
{% set rowName = 'Row {index}'|t('app', {index: rowNumber}) %}
{% set actionBtnLabel = "#{rowName} #{'Actions'|t('app')}" %}
-
+
{% for colId, col in cols %}
{% set cell = row[colId] is defined ? row[colId] : (defaultValues[colId] ?? null) %}
{% set value = cell.value is defined ? cell.value : cell %}
diff --git a/resources/translations/en/app.php b/resources/translations/en/app.php
index 2ca8168a174..a9c548a27fe 100644
--- a/resources/translations/en/app.php
+++ b/resources/translations/en/app.php
@@ -558,6 +558,7 @@
'Default Value' => 'Default Value',
'Default Values' => 'Default Values',
'Default View Mode' => 'Default View Mode',
+ 'Default order' => 'Default order',
'Default {type} Placement' => 'Default {type} Placement',
'Default' => 'Default',
'Default?' => 'Default?',
@@ -1058,6 +1059,7 @@
'Manage categories' => 'Manage categories',
'Manage element thumbnails' => 'Manage element thumbnails',
'Manipulated SVG image rasterizing is unreliable. See \\CraftCms\\Cms\\Image\\Images::loadImage()' => 'Manipulated SVG image rasterizing is unreliable. See \\CraftCms\\Cms\\Image\\Images::loadImage()',
+ 'Manual order' => 'Manual order',
'Markdown Extra' => 'Markdown Extra',
'Markdown Flavor' => 'Markdown Flavor',
'Markdown Guide' => 'Markdown Guide',
diff --git a/src/Cp/Cp.php b/src/Cp/Cp.php
index 1b4364e3a94..9e701a9361f 100644
--- a/src/Cp/Cp.php
+++ b/src/Cp/Cp.php
@@ -123,6 +123,21 @@ public static function publicAssetUrl(string $path): string
return asset("vendor/craft/build/$path");
}
+ /**
+ * Module specifiers the CP shares with plugin bundles through the import map,
+ * so a plugin that leaves them out of its build runs on the CP's own instance
+ * rather than a copy of its own.
+ *
+ * @return array
+ */
+ public static function sharedModules(): array
+ {
+ return [
+ 'vue' => static::vite()->asset('resources/js/vue.ts'),
+ '@craftcms/cms/elements' => static::vite()->asset('resources/js/elements.ts'),
+ ];
+ }
+
public static function viteScripts(): Vite
{
return static::vite()->withEntryPoints([
diff --git a/src/Cp/Html/ElementHtml.php b/src/Cp/Html/ElementHtml.php
index 5b609b7e4ed..e73345e7780 100644
--- a/src/Cp/Html/ElementHtml.php
+++ b/src/Cp/Html/ElementHtml.php
@@ -119,6 +119,7 @@ public function chipHtml(Chippable $component, array $config = []): string
'label' => $component->getUiLabel(),
'description' => $component instanceof Describable ? $component->getDescription() : null,
'handle' => $component instanceof Grippable ? $component->getHandle() : null,
+ 'color' => $color->value ?? 'white',
'settings' => $config['autoReload'] ? [
'selectable' => $config['selectable'],
'id' => InputNamespace::namespaceId($config['id']),
@@ -214,25 +215,27 @@ public function chipHtml(Chippable $component, array $config = []): string
]);
}
- $html .= Html::beginTag('div', ['slot' => 'suffix']);
- if ($config['showActionMenu']) {
- /** @var Chippable&Actionable $component */
- $html .= $this->componentActionMenu($component);
- }
- if ($config['sortable']) {
- $html .= Button::make()
- ->icon('move')
- ->attributes([
- 'class' => ['chromeless', 'small', 'move-btn'],
- 'title' => t('Reorder'),
- 'aria' => [
- 'label' => t('Reorder'),
- ],
- 'role' => 'none',
- 'tabindex' => '-1',
- ]);
+ if ($config['showActionMenu'] || $config['sortable']) {
+ $html .= Html::beginTag('div', ['slot' => 'suffix']);
+ if ($config['showActionMenu']) {
+ /** @var Chippable&Actionable $component */
+ $html .= $this->componentActionMenu($component);
+ }
+ if ($config['sortable']) {
+ $html .= Button::make()
+ ->icon('move')
+ ->attributes([
+ 'class' => ['chromeless', 'small', 'move-btn'],
+ 'title' => t('Reorder'),
+ 'aria' => [
+ 'label' => t('Reorder'),
+ ],
+ 'role' => 'none',
+ 'tabindex' => '-1',
+ ]);
+ }
+ $html .= Html::endTag('div'); // slot=suffix
}
- $html .= Html::endTag('div'); // slot=suffix
if ($config['inputName'] !== null) {
$inputValue = $config['inputValue'] ?? $component->getId();
diff --git a/src/Cp/Navigation.php b/src/Cp/Navigation.php
index c48ac74373f..99290bd8e23 100644
--- a/src/Cp/Navigation.php
+++ b/src/Cp/Navigation.php
@@ -329,7 +329,7 @@ private function buildTree(): array
* @param string $indexUri The index these sources hang off
* @return NavItem[]
*/
- private function sourceSubnav(string $elementType, string $indexUri, ?string $page = null): array
+ public function sourceSubnav(string $elementType, string $indexUri, ?string $page = null): array
{
$items = [];
$group = null;
diff --git a/src/Form/Controls/Table.php b/src/Form/Controls/Table.php
index b3bc51917cd..e857a9c519d 100644
--- a/src/Form/Controls/Table.php
+++ b/src/Form/Controls/Table.php
@@ -27,12 +27,17 @@ class Table extends Control
private bool $allowReorder = false;
+ private ?string $addRowLabel = null;
+
private ?int $minRows = null;
private ?int $maxRows = null;
private bool $keyed = false;
+ /** @var list */
+ private array $hiddenRows = [];
+
/** @var array */
private array $defaultValues = [];
@@ -53,10 +58,12 @@ public static function renderHtml(ControlPayload $control, mixed $value, array $
'allowAdd' => (bool) ($control->props['allowAdd'] ?? false),
'allowDelete' => (bool) ($control->props['allowDelete'] ?? false),
'allowReorder' => (bool) ($control->props['allowReorder'] ?? false),
+ 'addRowLabel' => $control->props['addRowLabel'] ?? null,
'minRows' => $control->props['minRows'] ?? null,
'maxRows' => $control->props['maxRows'] ?? null,
'defaultValues' => $control->props['defaultValues'] ?? [],
'static' => $attributes['name'] === null,
+ 'hiddenRows' => $control->props['hiddenRows'] ?? [],
'errors' => $control->props['errors'] ?? [],
]);
}
@@ -95,6 +102,13 @@ public function allowReorder(bool $allowReorder = true): static
return $this;
}
+ public function addRowLabel(?string $addRowLabel): static
+ {
+ $this->addRowLabel = $addRowLabel;
+
+ return $this;
+ }
+
public function minRows(?int $minRows): static
{
$this->minRows = $minRows;
@@ -116,6 +130,19 @@ public function keyed(bool $keyed = true): static
return $this;
}
+ /**
+ * Hide rows by key without removing their inputs or submitted values. Visibility
+ * is a prop so it can change on a reactive refresh without changing row values.
+ *
+ * @param list $rowIds
+ */
+ public function hiddenRows(array $rowIds): static
+ {
+ $this->hiddenRows = $rowIds;
+
+ return $this;
+ }
+
/** @param array $defaultValues */
public function defaultValues(array $defaultValues): static
{
@@ -147,9 +174,11 @@ public function props(mixed $value = null): array
'allowAdd' => $this->allowAdd,
'allowDelete' => $this->allowDelete,
'allowReorder' => $this->allowReorder,
+ 'addRowLabel' => $this->addRowLabel,
'minRows' => $this->minRows,
'maxRows' => $this->maxRows,
'keyed' => $this->keyed,
+ 'hiddenRows' => $this->hiddenRows ?: null,
'defaultValues' => $this->defaultValues,
'errors' => $this->errors ?: null,
]);
diff --git a/src/Form/FormNodeTypes.php b/src/Form/FormNodeTypes.php
index 2dee2cdfe6f..20197fcfdab 100644
--- a/src/Form/FormNodeTypes.php
+++ b/src/Form/FormNodeTypes.php
@@ -19,6 +19,7 @@
use CraftCms\Cms\Form\Nodes\Missing;
use CraftCms\Cms\Form\Nodes\Separator;
use CraftCms\Cms\Form\Nodes\Tab;
+use CraftCms\Cms\Form\Nodes\Table;
use CraftCms\Cms\Form\Nodes\TemplateContent;
use Illuminate\Container\Attributes\Singleton;
@@ -48,6 +49,7 @@ class FormNodeTypes extends TypeRegistry
Missing::class,
Separator::class,
Tab::class,
+ Table::class,
TemplateContent::class,
];
}
diff --git a/src/Form/Nodes/Group.php b/src/Form/Nodes/Group.php
index f0ca433a8ed..be06c5d6dd6 100644
--- a/src/Form/Nodes/Group.php
+++ b/src/Form/Nodes/Group.php
@@ -59,10 +59,14 @@ class Group extends Container
private ?string $instructions = null;
+ private string $instructionsPosition = 'before';
+
private ?string $tip = null;
private ?string $warning = null;
+ private ?string $layoutUid = null;
+
private ?int $width = null;
/** @var list|null */
@@ -155,6 +159,14 @@ public function instructions(?string $instructions): static
return $this;
}
+ /** Field appearance only. */
+ public function instructionsPosition(string $instructionsPosition): static
+ {
+ $this->instructionsPosition = $instructionsPosition;
+
+ return $this;
+ }
+
/** Field appearance only. */
public function tip(?string $tip): static
{
@@ -171,6 +183,14 @@ public function warning(?string $warning): static
return $this;
}
+ /** Field appearance only. */
+ public function layoutUid(?string $layoutUid): static
+ {
+ $this->layoutUid = $layoutUid;
+
+ return $this;
+ }
+
/** How wide the group itself should be within its own container. */
public function width(FieldWidth|int|null $width): static
{
@@ -212,10 +232,12 @@ public function props(): array
...($this->asField && $this->required ? ['required' => true] : []),
...Arr::whereNotNull([
'instructions' => $this->instructions,
+ 'instructionsPosition' => $this->instructionsPosition !== 'before' ? $this->instructionsPosition : null,
'tip' => $this->tip,
'tipHtml' => $this->noticeHtml($this->tip),
'warning' => $this->warning,
'warningHtml' => $this->noticeHtml($this->warning),
+ 'layoutUid' => $this->layoutUid,
'width' => $this->width,
'dependsOn' => $this->dependsOn,
]),
@@ -236,6 +258,7 @@ private static function fieldHtml(
->label($label)
->required((bool) ($props['required'] ?? false))
->instructions($props['instructions'] ?? null)
+ ->instructionsPosition((string) ($props['instructionsPosition'] ?? 'before'))
->tip($props['tip'] ?? null)
->warning($props['warning'] ?? null)
->input(
@@ -248,6 +271,7 @@ private static function fieldHtml(
->attributes([
'class' => isset($props['width']) ? "width-{$props['width']}" : null,
'data-form-node' => $node->uid,
+ 'data-layout-element' => $props['layoutUid'] ?? null,
])
->attributes(self::visibilityAttributes($props))
->toHtml();
diff --git a/src/Form/Nodes/Table.php b/src/Form/Nodes/Table.php
new file mode 100644
index 00000000000..e32bc56e99b
--- /dev/null
+++ b/src/Form/Nodes/Table.php
@@ -0,0 +1,514 @@
+ */
+ private array $columns = [];
+
+ /** @var list> */
+ private array $rows = [];
+
+ private ?string $dataUrl = null;
+
+ private int $perPage = 100;
+
+ /** @var list */
+ private array $perPageOptions = [50, 100, 250];
+
+ private ?string $moveToPageUrl = null;
+
+ private ?string $emptyMessage = null;
+
+ private ?string $createLabel = null;
+
+ private ?string $createUrl = null;
+
+ /** @var list|null */
+ private ?array $createMenuItems = null;
+
+ private bool $createActionInPageHeader = false;
+
+ private ?string $reorderUrl = null;
+
+ private ?string $reorderSuccessMessage = null;
+
+ private ?string $reorderFailMessage = null;
+
+ private ?string $deleteUrl = null;
+
+ private ?string $deleteConfirmMessage = null;
+
+ private bool $bulkDeletable = false;
+
+ private ?string $deleteModalUrl = null;
+
+ /** @var list> */
+ private array $bulkActions = [];
+
+ /** @var list> */
+ private array $statusActions = [];
+
+ /** @var list|null */
+ private ?array $statusFilterOptions = null;
+
+ private bool $columnsToggleable = false;
+
+ /** @var list */
+ private array $hiddenColumnsByDefault = [];
+
+ private bool $searchable = false;
+
+ private ?string $searchPlaceholder = null;
+
+ private bool $bordered = false;
+
+ public function __construct(private readonly string $uid) {}
+
+ public static function make(string $uid): self
+ {
+ return new self($uid);
+ }
+
+ /**
+ * Columns with `sortable => true` get clickable headers that cycle ascending, descending,
+ * then back to the rows' own order. See {@see rows()} for the `_sort` override.
+ *
+ * @param list $columns
+ */
+ public function columns(array $columns): static
+ {
+ $this->columns = $columns;
+
+ return $this;
+ }
+
+ /**
+ * Rows are keyed by column `key`, with an `id` when reordering or deleting.
+ * Cells accept scalars, `['label' => string, 'url' => ?string]` links, lists of
+ * links, `['label' => string, 'items' => list]` menus, `['icon' => string,
+ * 'label' => ?string]` icons, or `['html' => string]` markup. HTML is rendered
+ * without sanitization in both renderers. Encode untrusted content with
+ * {@see Html::encode()} before passing it.
+ *
+ * `_deletable => false` suppresses deletion of one row. `_status` accepts a
+ * boolean or status string and renders an indicator in the first column.
+ * `_search` overrides client-side search text; otherwise columns' text is used.
+ * `_sort` maps column keys to values to sort by client-side instead of the cell's text.
+ *
+ * @param list> $rows
+ */
+ public function rows(array $rows): static
+ {
+ $this->rows = self::prepareRows($rows);
+ $this->dataUrl = null;
+
+ return $this;
+ }
+
+ /**
+ * Resolve `_status` on rows returned by a {@see dataUrl()} endpoint; {@see rows()}
+ * calls this automatically for upfront rows.
+ *
+ * @param list> $rows
+ * @return list>
+ */
+ public static function prepareRows(array $rows): array
+ {
+ return array_map(self::resolveRowStatus(...), $rows);
+ }
+
+ /** @param array $row */
+ private static function resolveRowStatus(array $row): array
+ {
+ if (! array_key_exists('_status', $row) || $row['_status'] === null) {
+ return $row;
+ }
+
+ $status = $row['_status'];
+ $status = is_bool($status) ? ($status ? 'enabled' : 'disabled') : $status;
+
+ $row['_status'] = [
+ 'value' => $status,
+ 'fill' => self::statusFill($status),
+ 'label' => ucfirst($status),
+ ];
+
+ return $row;
+ }
+
+ private static function statusFill(string $status): string
+ {
+ return (Color::tryFromStatus($status) ?? Color::Gray)->value;
+ }
+
+ /**
+ * Fetch rows by posting `{page, per_page, search, status, sort}` to `$url`, where `sort`
+ * is `[{field, direction}]` for a sortable column key, or omitted for the rows' own order.
+ * The response must contain `{data: , pagination: {total, per_page, current_page,
+ * last_page, next_page_url, prev_page_url, from, to}}`. Pass the page's rows through
+ * {@see prepareRows()} first. Calling this clears {@see rows()}, and vice versa.
+ *
+ * Users can switch `per_page` between `$perPageOptions` (plus `$perPage`), so the
+ * endpoint must honor the posted value rather than assume `$perPage`.
+ *
+ * @param list|null $perPageOptions
+ */
+ public function dataUrl(string $url, int $perPage = 100, ?array $perPageOptions = null): static
+ {
+ $this->dataUrl = $url;
+ $this->perPage = $perPage;
+ $this->rows = [];
+
+ if ($perPageOptions !== null) {
+ $this->perPageOptions = $perPageOptions;
+ }
+
+ return $this;
+ }
+
+ /**
+ * In {@see dataUrl()} mode, posts `{id, page, per_page}` to `$url` to move a row across
+ * pages. The endpoint computes the new absolute position.
+ */
+ public function moveToPageUrl(string $url): static
+ {
+ $this->moveToPageUrl = $url;
+
+ return $this;
+ }
+
+ public function emptyMessage(?string $emptyMessage): static
+ {
+ $this->emptyMessage = $emptyMessage;
+
+ return $this;
+ }
+
+ public function createAction(?string $label, ?string $url): static
+ {
+ $this->createLabel = $label;
+ $this->createUrl = $url;
+ $this->createMenuItems = null;
+
+ return $this;
+ }
+
+ /**
+ * Render a create button with a menu of links instead of one {@see createAction()} URL.
+ *
+ * @param list $items
+ */
+ public function createActionMenu(string $label, array $items): static
+ {
+ $this->createLabel = $label;
+ $this->createUrl = null;
+ $this->createMenuItems = $items;
+
+ return $this;
+ }
+
+ /**
+ * Render the {@see createAction()} or {@see createActionMenu()} button in the page header
+ * instead of the table's toolbar.
+ */
+ public function createActionInPageHeader(bool $inPageHeader = true): static
+ {
+ $this->createActionInPageHeader = $inPageHeader;
+
+ return $this;
+ }
+
+ /**
+ * Enables drag-to-reorder; the new order posts to `$url` as `{ids: list}`.
+ * `$successMessage`/`$failMessage` are shown as a toast after the request settles — omit
+ * either (or both) to fall back to a generic message client-side.
+ */
+ public function reorderable(string $url, ?string $successMessage = null, ?string $failMessage = null): static
+ {
+ $this->reorderUrl = $url;
+ $this->reorderSuccessMessage = $successMessage;
+ $this->reorderFailMessage = $failMessage;
+
+ return $this;
+ }
+
+ /**
+ * Adds a per-row delete action, posting `{id: }` to `$url`. Individual rows can
+ * opt out via `_deletable => false` in {@see rows()}.
+ *
+ * `$bulk` enables row selection and posts `{ids: }` to the same endpoint.
+ * Enable it only if the endpoint handles `ids` as well as `id`.
+ *
+ * `$modalUrl` replaces the per-row confirmation with a modal Form, for deletions that need
+ * more input (where to move a deleted record's data, say). It's requested via GET with
+ * `{id: }` and must return JSON `{form: FormPayload, title?: string,
+ * submitLabel?: string}`; submitting posts the Form's values plus `id` to `$url`. Bulk
+ * deletion still uses the plain confirmation.
+ */
+ public function deletable(string $url, ?string $confirmMessage = null, bool $bulk = false, ?string $modalUrl = null): static
+ {
+ $this->deleteUrl = $url;
+ $this->deleteConfirmMessage = $confirmMessage;
+ $this->bulkDeletable = $bulk;
+ $this->deleteModalUrl = $modalUrl;
+
+ return $this;
+ }
+
+ /**
+ * Adds items to the selection footer's "Actions" menu. Each action posts
+ * `{ids: , ...params}` to its `url`.
+ *
+ * Entries may be `['label' => string, 'url' => string, 'params'? => array,
+ * 'allowMultiple'? => bool]` or groups with `['label'? => string, 'items' => list]`.
+ * An omitted group label leaves its items unheaded; `icon` is ignored. `allowMultiple`
+ * defaults to true and only disables the UI for multiple rows. Endpoints must enforce
+ * their own constraints.
+ *
+ * @param list> $actions
+ */
+ public function bulkActions(array $actions): static
+ {
+ $this->bulkActions = $actions;
+
+ return $this;
+ }
+
+ /**
+ * Adds a separate "Set status" menu to the selection footer. Items use the same
+ * shape as single {@see bulkActions()} entries, plus an optional `fill` (a colored
+ * status dot — any `craft-indicator` `fill` value); the button label is fixed.
+ *
+ * @param list> $items
+ */
+ public function statusActions(array $items): static
+ {
+ $this->statusActions = $items;
+
+ return $this;
+ }
+
+ /**
+ * Adds a status dropdown to the toolbar, defaulting to enabled/disabled. Filters
+ * {@see rows()} locally on `_status`, or sends the chosen `status` to the {@see dataUrl()}
+ * endpoint and resets to page 1. "All" sends no `status`.
+ *
+ * @param list|null $options
+ */
+ public function statusFilter(?array $options = null): static
+ {
+ $this->statusFilterOptions = $options ?? [
+ ['value' => 'enabled', 'label' => t('Enabled')],
+ ['value' => 'disabled', 'label' => t('Disabled')],
+ ];
+
+ return $this;
+ }
+
+ /**
+ * Adds a "View" menu for showing, hiding and reordering columns, and choosing the sort
+ * of any sortable {@see columns()}. The first column is always shown. Every other
+ * column starts visible unless its key is in `$hiddenByDefault`.
+ *
+ * @param list $hiddenByDefault
+ */
+ public function toggleableColumns(array $hiddenByDefault = []): static
+ {
+ $this->columnsToggleable = true;
+ $this->hiddenColumnsByDefault = $hiddenByDefault;
+
+ return $this;
+ }
+
+ /**
+ * Search {@see rows()} locally, or send `search` to the {@see dataUrl()} endpoint
+ * and reset to page 1. See {@see rows()} for the `_search` override.
+ */
+ public function searchable(?string $placeholder = null): static
+ {
+ $this->searchable = true;
+ $this->searchPlaceholder = $placeholder;
+
+ return $this;
+ }
+
+ public function bordered(bool $bordered = true): static
+ {
+ $this->bordered = $bordered;
+
+ return $this;
+ }
+
+ public static function renderHtml(NodePayload $node, FormPayload $payload, FormHtmlRenderer $renderer): string
+ {
+ $columns = $node->props['columns'];
+ $rows = $node->props['rows'];
+
+ $createAction = match (true) {
+ $node->props['createUrl'] !== null && $node->props['createLabel'] !== null => Html::a(
+ Html::encode($node->props['createLabel']),
+ $node->props['createUrl'],
+ ['class' => ['btn', 'submit', 'add', 'icon']],
+ ),
+ ! empty($node->props['createMenuItems']) => Html::encode($node->props['createLabel'] ?? '').': '.implode(', ', array_map(
+ fn (array $item) => Html::a(Html::encode($item['label']), $item['url']),
+ $node->props['createMenuItems'],
+ )),
+ default => '',
+ };
+
+ if ($node->props['dataUrl'] !== null) {
+ // Without JavaScript, endpoint rows cannot be fetched or treated as an empty table.
+ $table = Html::tag('p', Html::encode(t('This table requires JavaScript.')), [
+ 'class' => ['zilch'],
+ ]);
+ } elseif (empty($rows)) {
+ $table = Html::tag('p', Html::encode($node->props['emptyMessage'] ?? ''), [
+ 'class' => ['zilch'],
+ ]);
+ } else {
+ $renderLink = fn (array $link): string => $link['url'] !== null
+ ? Html::a(Html::encode($link['label']), $link['url'])
+ : Html::encode($link['label']);
+
+ $firstColumnKey = $columns[0]['key'] ?? null;
+
+ $renderCell = function (array $column, array $row) use ($renderLink, $firstColumnKey): string {
+ $value = $row[$column['key']] ?? '';
+
+ $rendered = match (true) {
+ is_array($value) && array_key_exists('items', $value) => implode(', ', array_map($renderLink, $value['items'])),
+ is_array($value) && array_key_exists('icon', $value) => Html::encode($value['label'] ?? ''),
+ is_array($value) && array_key_exists('html', $value) => $value['html'],
+ is_array($value) && array_is_list($value) => implode(', ', array_map($renderLink, $value)),
+ is_array($value) => $renderLink($value),
+ default => Html::encode((string) $value),
+ };
+
+ if ($column['key'] === $firstColumnKey && ! empty($row['_status']['label'])) {
+ $rendered = Html::encode($row['_status']['label']).': '.$rendered;
+ }
+
+ return $rendered;
+ };
+
+ $head = Html::tag('tr', implode('', array_map(
+ fn (array $column) => Html::tag('th', Html::encode($column['label'])),
+ $columns,
+ )));
+
+ $body = implode('', array_map(
+ fn (array $row) => Html::tag('tr', implode('', array_map(
+ fn (array $column) => Html::tag('td', $renderCell($column, $row)),
+ $columns,
+ ))),
+ $rows,
+ ));
+
+ $table = Html::tag('table', Html::tag('thead', $head).Html::tag('tbody', $body), [
+ 'class' => ['data', 'fullwidth'],
+ ]);
+ }
+
+ return Html::tag('div', $createAction.$table, [
+ 'class' => ['grid', 'gap-2'],
+ 'data-form-node' => $node->uid,
+ ]);
+ }
+
+ public function component(): string
+ {
+ return 'craft:admin-table';
+ }
+
+ public function uid(): ?string
+ {
+ return $this->uid;
+ }
+
+ public function props(): array
+ {
+ return [
+ 'columns' => $this->columns,
+ 'rows' => $this->rows,
+ 'dataUrl' => $this->dataUrl,
+ 'perPage' => $this->perPage,
+ 'perPageOptions' => $this->resolvePerPageOptions(),
+ 'moveToPageUrl' => $this->moveToPageUrl,
+ 'emptyMessage' => $this->emptyMessage,
+ 'createLabel' => $this->createLabel,
+ 'createUrl' => $this->createUrl,
+ 'createMenuItems' => $this->createMenuItems,
+ 'createActionInPageHeader' => $this->createActionInPageHeader,
+ 'reorderUrl' => $this->reorderUrl,
+ 'reorderSuccessMessage' => $this->reorderSuccessMessage,
+ 'reorderFailMessage' => $this->reorderFailMessage,
+ 'deleteUrl' => $this->deleteUrl,
+ 'deleteConfirmMessage' => $this->deleteConfirmMessage,
+ 'bulkDeletable' => $this->bulkDeletable,
+ 'deleteModalUrl' => $this->deleteModalUrl,
+ 'bulkActions' => $this->bulkActions,
+ 'statusActions' => $this->statusActions,
+ 'statusFilterOptions' => $this->resolveStatusFilterOptions(),
+ 'columnsToggleable' => $this->columnsToggleable,
+ 'hiddenColumnsByDefault' => $this->hiddenColumnsByDefault,
+ 'searchable' => $this->searchable,
+ 'searchPlaceholder' => $this->searchPlaceholder,
+ 'bordered' => $this->bordered,
+ ];
+ }
+
+ /** @return list */
+ private function resolveStatusFilterOptions(): array
+ {
+ if ($this->statusFilterOptions === null) {
+ return [];
+ }
+
+ return [
+ ['value' => '', 'label' => t('All'), 'fill' => null],
+ ...array_map(fn (array $option) => [
+ 'value' => $option['value'],
+ 'label' => $option['label'],
+ 'fill' => self::statusFill($option['value']),
+ ], $this->statusFilterOptions),
+ ];
+ }
+
+ /** @return list */
+ private function resolvePerPageOptions(): array
+ {
+ $options = array_unique([...$this->perPageOptions, $this->perPage]);
+ sort($options);
+
+ return $options;
+ }
+
+ public function getControl(): ?Control
+ {
+ return null;
+ }
+
+ public function children(): array
+ {
+ return [];
+ }
+}
diff --git a/src/Http/Controllers/Elements/PerformElementActionController.php b/src/Http/Controllers/Elements/PerformElementActionController.php
index 5b0631a8aea..59e78106c89 100644
--- a/src/Http/Controllers/Elements/PerformElementActionController.php
+++ b/src/Http/Controllers/Elements/PerformElementActionController.php
@@ -5,6 +5,7 @@
namespace CraftCms\Cms\Http\Controllers\Elements;
use CraftCms\Cms\Element\Actions\Duplicate;
+use CraftCms\Cms\Element\Contracts\ElementActionInterface;
use CraftCms\Cms\Element\Contracts\ElementInterface;
use CraftCms\Cms\Element\Contracts\NestedElementInterface;
use CraftCms\Cms\Element\CurrentElementIndex;
@@ -207,6 +208,17 @@ public function __invoke(
: null;
}
- return $this->asSuccess($result['message'], $responseData);
+ return $this->asSuccess($result['message'], $responseData, $this->actionRedirect($action));
+ }
+
+ /**
+ * Where an action that sends the user elsewhere set its response to
+ * redirect to.
+ */
+ private function actionRedirect(ElementActionInterface $action): ?string
+ {
+ $response = $action->getResponse();
+
+ return $response?->isRedirect() ? $response->headers->get('Location') : null;
}
}
diff --git a/src/Http/Middleware/HandleInertiaRequests.php b/src/Http/Middleware/HandleInertiaRequests.php
index daab3e1f99c..552c0ac67fd 100644
--- a/src/Http/Middleware/HandleInertiaRequests.php
+++ b/src/Http/Middleware/HandleInertiaRequests.php
@@ -56,6 +56,11 @@ public function handle(Request $request, Closure $next)
$htmlStack = app(HtmlStack::class);
app(InternalAssetRegistry::class)->register(CpAsset::class);
+
+ foreach (Cp::sharedModules() as $specifier => $url) {
+ $htmlStack->jsImport($specifier, $url);
+ }
+
View::composer('app', function ($view) use ($htmlStack) {
$view->with([
'headHtml' => $htmlStack->headHtml(),
diff --git a/tests/Feature/Http/Controllers/Elements/PerformElementActionControllerTest.php b/tests/Feature/Http/Controllers/Elements/PerformElementActionControllerTest.php
index 10dd937993a..c0948c6723f 100644
--- a/tests/Feature/Http/Controllers/Elements/PerformElementActionControllerTest.php
+++ b/tests/Feature/Http/Controllers/Elements/PerformElementActionControllerTest.php
@@ -25,6 +25,7 @@
use CraftCms\Cms\User\Elements\User;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Facades\Event;
+use Symfony\Component\HttpFoundation\RedirectResponse;
use Symfony\Component\HttpFoundation\Response;
use function Pest\Laravel\actingAs;
@@ -153,6 +154,34 @@ public function performAction(ElementQueryInterface $query): bool
->and($response->getContent())->toBe('downloaded');
});
+it('passes a redirecting action response on as the JSON redirect', function () {
+ $entry = EntryModel::factory()->createElement();
+
+ $action = new class extends ElementAction
+ {
+ public function performAction(ElementQueryInterface $query): bool
+ {
+ $this->setResponse(new RedirectResponse('https://example.test/somewhere'));
+
+ return true;
+ }
+ };
+
+ Event::listen(function (ElementActionsResolving $event) use ($action) {
+ if ($event->elementType === Entry::class) {
+ $event->actions[] = clone $action;
+ }
+ });
+
+ ($this->performElementAction)([
+ 'elementType' => Entry::class,
+ 'elementAction' => $action::class,
+ 'elementIds' => [$entry->id],
+ ])
+ ->assertOk()
+ ->assertJsonPath('redirect', 'https://example.test/somewhere');
+});
+
it('includes exporter metadata in the refreshed element response', function (string $context) {
$entry = EntryModel::factory()->createElement();
diff --git a/tests/Feature/Http/Controllers/Settings/UserSettingsControllerTest.php b/tests/Feature/Http/Controllers/Settings/UserSettingsControllerTest.php
index cc441488a91..a264ccd344f 100644
--- a/tests/Feature/Http/Controllers/Settings/UserSettingsControllerTest.php
+++ b/tests/Feature/Http/Controllers/Settings/UserSettingsControllerTest.php
@@ -9,6 +9,7 @@
use CraftCms\Cms\Http\Controllers\Settings\Users\UserFieldsController;
use CraftCms\Cms\Http\Controllers\Settings\Users\UserGroupsController;
use CraftCms\Cms\Http\Controllers\Settings\Users\UserSettingsController;
+use CraftCms\Cms\Http\Controllers\Settings\VolumesController;
use CraftCms\Cms\Support\Facades\ProjectConfig;
use CraftCms\Cms\Support\Str;
use CraftCms\Cms\User\Elements\User;
@@ -139,10 +140,16 @@
->pluck('control')
->firstWhere('path', ['photoVolumeUid']);
$values = collect($control['props']['options'])->pluck('value');
+ $create = collect($control['props']['options'])->firstWhere('value', '__createVolume__');
return $values->contains((string) $publicVolume->uid)
&& $values->contains((string) $privateVolume->uid)
- && $values->contains('__createVolume__');
+ && $create['data']['create'] === [
+ 'url' => action([VolumesController::class, 'create']),
+ 'resultKey' => 'volume',
+ 'labelField' => 'name',
+ 'valueField' => 'uid',
+ ];
}));
});
diff --git a/tests/Unit/Form/CompositeControlsTest.php b/tests/Unit/Form/CompositeControlsTest.php
index befb7ac48e7..615265d21bd 100644
--- a/tests/Unit/Form/CompositeControlsTest.php
+++ b/tests/Unit/Form/CompositeControlsTest.php
@@ -167,6 +167,20 @@ function compositeControlsCrawler(ControlMode $mode = ControlMode::Editable): Cr
->and($crawler->filter('textarea[name="settings[rows][site-one][name]"]')->text())->toBe('Primary');
});
+it('passes a custom add row label through Table payloads and HTML', function () {
+ $form = Form::make([
+ Field::make()->control(Table::make('rows')
+ ->columns(['name' => ['heading' => 'Name', 'type' => 'singleline']])
+ ->allowAdd()
+ ->addRowLabel('Add a coupon')),
+ ]);
+ $payload = app(FormResolver::class)->resolve($form, new FormContext(namespace: 'settings'));
+ $crawler = new Crawler(app(FormHtmlRenderer::class)->render($payload));
+
+ expect($payload->nodes[0]->control->props['addRowLabel'])->toBe('Add a coupon')
+ ->and($crawler->filter('craft-button[command="--add-row"]')->text())->toContain('Add a coupon');
+});
+
it('renders text expanders for text and textarea Controls', function () {
$triggers = [
['trigger' => '@', 'boundary' => 'whitespace', 'label' => 'People', 'source' => 'users/text-expander-options'],
diff --git a/tests/Unit/Form/GroupFieldAppearanceTest.php b/tests/Unit/Form/GroupFieldAppearanceTest.php
index 9ce722a537c..f0b9969b8c8 100644
--- a/tests/Unit/Form/GroupFieldAppearanceTest.php
+++ b/tests/Unit/Form/GroupFieldAppearanceTest.php
@@ -48,6 +48,8 @@ function renderGroup(Group $group): Crawler
locationGroup()
->asField()
->instructions('The location where assets can be selected from.')
+ ->instructionsPosition('after')
+ ->layoutUid('layout-element-uid')
->width(FieldWidth::Half),
);
$field = $crawler->filter('craft-field[fieldset]');
@@ -55,6 +57,8 @@ function renderGroup(Group $group): Crawler
expect($field)->toHaveCount(1)
->and($field->attr('label'))->toBe('Asset Location')
->and($field->attr('class'))->toContain('width-50')
+ ->and($field->attr('instructions-position'))->toBe('after')
+ ->and($field->attr('data-layout-element'))->toBe('layout-element-uid')
->and($crawler->filter('legend'))->toHaveCount(0)
->and($field->filter('craft-field.width-33'))->toHaveCount(1)
->and($field->filter('craft-field.width-66'))->toHaveCount(1);
diff --git a/tests/Unit/Form/ScalarControlsTest.php b/tests/Unit/Form/ScalarControlsTest.php
index b012b3c7900..9c5c2588b71 100644
--- a/tests/Unit/Form/ScalarControlsTest.php
+++ b/tests/Unit/Form/ScalarControlsTest.php
@@ -6,6 +6,7 @@
use CraftCms\Cms\Form\Controls\Choice;
use CraftCms\Cms\Form\Controls\Color;
use CraftCms\Cms\Form\Controls\Combobox;
+use CraftCms\Cms\Form\Controls\Combobox\CreateOption as ComboboxCreateOption;
use CraftCms\Cms\Form\Controls\Date;
use CraftCms\Cms\Form\Controls\DateTime;
use CraftCms\Cms\Form\Controls\Money;
@@ -222,6 +223,30 @@ function scalarControlsCrawler(ControlMode $mode = ControlMode::Editable): Crawl
->and(fn () => Combobox::make('path')->limit(0))->toThrow(InvalidArgumentException::class);
});
+it('renders a create option in an ordinary combobox', function () {
+ $payload = app(FormResolver::class)->resolve(
+ Form::make([Field::make()->control(Combobox::make('category')->options([
+ ['label' => 'Existing', 'value' => 42],
+ new ComboboxCreateOption('Create category', '/categories/new', 'category'),
+ ]))]),
+ new FormContext(values: ['category' => '42']),
+ );
+ $crawler = new Crawler(app(FormHtmlRenderer::class)->render($payload));
+ $options = json_decode((string) $crawler->filter('craft-combobox')->attr('options'), true);
+
+ expect($options)->toBe([
+ ['label' => 'Existing', 'value' => '42'],
+ ['label' => 'Create category', 'value' => '__add__', 'data' => [
+ 'create' => [
+ 'url' => '/categories/new',
+ 'resultKey' => 'category',
+ 'labelField' => 'name',
+ 'valueField' => 'id',
+ ],
+ ]],
+ ]);
+});
+
it('renders an icon-only choice button with an accessible name and no label', function () {
$crawler = renderChoice(
Choice::make('choice')
diff --git a/tests/Unit/Form/TableNodeDeleteModalTest.php b/tests/Unit/Form/TableNodeDeleteModalTest.php
new file mode 100644
index 00000000000..3abd7709d1e
--- /dev/null
+++ b/tests/Unit/Form/TableNodeDeleteModalTest.php
@@ -0,0 +1,19 @@
+deletable('locations/delete', modalUrl: 'locations/delete-modal')
+ ->props();
+
+ expect($props['deleteUrl'])->toBe('locations/delete')
+ ->and($props['deleteModalUrl'])->toBe('locations/delete-modal')
+ ->and($props['bulkDeletable'])->toBeFalse();
+});
+
+it('has no delete modal URL by default', function () {
+ expect(Table::make('locations')->deletable('locations/delete')->props()['deleteModalUrl'])->toBeNull();
+});
diff --git a/tests/Unit/Http/Middleware/HandleInertiaRequestsTest.php b/tests/Unit/Http/Middleware/HandleInertiaRequestsTest.php
index 9b9781cef7e..3a6f42e4274 100644
--- a/tests/Unit/Http/Middleware/HandleInertiaRequestsTest.php
+++ b/tests/Unit/Http/Middleware/HandleInertiaRequestsTest.php
@@ -2,6 +2,7 @@
declare(strict_types=1);
+use CraftCms\Cms\Cp\Cp;
use CraftCms\Cms\Http\Middleware\HandleInertiaRequests;
use CraftCms\Cms\Support\File;
use Illuminate\Http\Request;
@@ -24,3 +25,22 @@
File::deleteDirectory($publicPath);
}
});
+
+it('shares the CP\'s Vue build and element index with plugin bundles through the import map', function () {
+ $publicPath = storage_path('framework/testing/inertia-assets');
+ $originalPublicPath = public_path();
+
+ File::deleteDirectory($publicPath);
+ File::ensureDirectoryExists("{$publicPath}/vendor/craft/build");
+ File::put("{$publicPath}/vendor/craft/build/manifest.json", '{"resources/js/vue.ts":{"file":"assets/vue-abc123.js","isEntry":true},"resources/js/elements.ts":{"file":"assets/elements-def456.js","isEntry":true}}');
+ app()->usePublicPath($publicPath);
+
+ try {
+ expect(Cp::sharedModules())->toHaveKey('vue')
+ ->and(Cp::sharedModules()['vue'])->toEndWith('/vendor/craft/build/assets/vue-abc123.js')
+ ->and(Cp::sharedModules()['@craftcms/cms/elements'])->toEndWith('/vendor/craft/build/assets/elements-def456.js');
+ } finally {
+ app()->usePublicPath($originalPublicPath);
+ File::deleteDirectory($publicPath);
+ }
+});
diff --git a/vite.config.js b/vite.config.js
index 75da770be88..2086d9680bd 100644
--- a/vite.config.js
+++ b/vite.config.js
@@ -285,6 +285,12 @@ export default defineConfig(({mode}) => {
build: {
emptyOutDir: true,
+ rollupOptions: {
+ // App builds drop entry exports by default, which would empty the
+ // `vue.ts`/`elements.ts` entries that plugin bundles import through
+ // the import map.
+ preserveEntrySignatures: 'exports-only',
+ },
},
optimizeDeps: {
@@ -344,9 +350,11 @@ export default defineConfig(({mode}) => {
laravel({
input: [
'resources/js/cp.ts',
+ 'resources/js/elements.ts',
'yii2-adapter/resources/js/element-editor.ts',
'resources/js/legacy.ts',
'resources/js/uploads.ts',
+ 'resources/js/vue.ts',
'resources/css/cp.css',
'workbench/resources/js/cp.ts',
],