From 074c6e39aea8c8700078f5d60bb98bd5ebc6d320 Mon Sep 17 00:00:00 2001 From: Nathaniel Hammond Date: Fri, 2 Oct 2026 14:03:33 +0100 Subject: [PATCH 1/8] First pass making nested elements extendable --- CHANGELOG.md | 21 ++ docs/forms.md | 31 +++ .../composables/useElementEditor.test.ts | 4 +- .../js/modules/elements/nested-owner.test.ts | 4 +- resources/js/modules/elements/nested-owner.ts | 22 +- .../js/modules/forms/FormRenderer.test.ts | 4 +- .../entry-field-layout-form-host.test.ts | 8 +- .../NestedElementBlocksControl.vue | 12 +- ...tions.vue => NestedElementCardActions.vue} | 54 ++--- .../nested-elements/NestedElements.test.ts | 188 ++++++++++++++++ .../forms/nested-elements/NestedElements.vue | 59 +++++ ...triesCards.vue => NestedElementsCards.vue} | 54 ++--- ...Shell.vue => NestedElementsCardsShell.vue} | 35 +-- ...NestedElementsControl.integration.test.ts} | 20 +- ....test.ts => NestedElementsControl.test.ts} | 70 +++--- .../nested-elements/NestedElementsControl.vue | 31 +++ ...ton.vue => NestedElementsCreateButton.vue} | 4 +- ...ex.test.ts => NestedElementsIndex.test.ts} | 44 ++-- ...triesIndex.vue => NestedElementsIndex.vue} | 141 ++++++------ .../nested-elements/NestedEntriesControl.vue | 57 ----- ...test.ts => nested-element-actions.test.ts} | 34 +-- ...y-actions.ts => nested-element-actions.ts} | 80 +++---- .../nested-elements-context.ts | 19 ++ .../nested-elements/nested-elements.test.ts | 70 ++++++ .../{nested-entries.ts => nested-elements.ts} | 111 ++++++---- .../nested-elements/nested-entries-context.ts | 19 -- .../nested-elements/nested-entries.test.ts | 58 ----- .../nested-elements/nested-index.fixture.ts | 16 +- ...tions.ts => useNestedElementOperations.ts} | 188 ++++++++-------- ...test.ts => useNestedElementsQuery.test.ts} | 20 +- ...riesQuery.ts => useNestedElementsQuery.ts} | 42 ++-- resources/js/modules/forms/register.ts | 4 +- resources/js/modules/matrix/interop.ts | 2 +- resources/js/pages/users/Addresses.vue | 206 +++--------------- src/Address/Elements/Address.php | 16 ++ src/Cp/Html/ElementHtml.php | 4 +- src/Element/Concerns/HasControlPanelUI.php | 17 ++ src/Element/Contracts/ElementInterface.php | 9 + .../NestedIndexConfigProviderInterface.php | 28 +++ src/Element/NestedElementManager.php | 81 +++++-- src/Element/Queries/AddressQuery.php | 11 +- src/Entry/Elements/Entry.php | 7 + src/Field/Addresses.php | 85 ++++---- src/Field/Matrix.php | 58 +---- src/Form/Controls/NestedElements.php | 173 +++++++++++++++ src/Form/Controls/NestedEntries.php | 159 +------------- src/Form/FormControlTypes.php | 2 + .../Elements/EditElementController.php | 14 +- .../Elements/ElementEditorController.php | 119 ++++++++++ .../PerformElementActionController.php | 18 +- .../Entries/EditEntryController.php | 92 ++------ src/Http/EmbeddedNestedElementScope.php | 8 +- .../ViewModels/UserAddressesViewModel.php | 105 ++------- .../Elements/AddressRequestAttributesTest.php | 37 ++++ .../Element/NestedElementManagerTest.php | 92 +++++++- .../Element/Queries/AddressQueryTest.php | 27 +++ .../Field/AddressesFormControlTest.php | 106 +++++++++ .../Field/MatrixFormControlIdentityTest.php | 54 ----- .../Elements/ElementEditorControllerTest.php | 107 +++++++++ .../Elements/ElementIndexControllerTest.php | 5 +- .../User/AddressesControllerTest.php | 61 +++++- 61 files changed, 1954 insertions(+), 1273 deletions(-) rename resources/js/modules/forms/nested-elements/{NestedEntryCardActions.vue => NestedElementCardActions.vue} (56%) create mode 100644 resources/js/modules/forms/nested-elements/NestedElements.test.ts create mode 100644 resources/js/modules/forms/nested-elements/NestedElements.vue rename resources/js/modules/forms/nested-elements/{NestedEntriesCards.vue => NestedElementsCards.vue} (77%) rename resources/js/modules/forms/nested-elements/{NestedEntriesCardsShell.vue => NestedElementsCardsShell.vue} (71%) rename resources/js/modules/forms/nested-elements/{NestedEntriesControl.integration.test.ts => NestedElementsControl.integration.test.ts} (96%) rename resources/js/modules/forms/nested-elements/{NestedEntriesControl.test.ts => NestedElementsControl.test.ts} (93%) create mode 100644 resources/js/modules/forms/nested-elements/NestedElementsControl.vue rename resources/js/modules/forms/nested-elements/{NestedEntriesCreateButton.vue => NestedElementsCreateButton.vue} (98%) rename resources/js/modules/forms/nested-elements/{NestedEntriesIndex.test.ts => NestedElementsIndex.test.ts} (96%) rename resources/js/modules/forms/nested-elements/{NestedEntriesIndex.vue => NestedElementsIndex.vue} (77%) delete mode 100644 resources/js/modules/forms/nested-elements/NestedEntriesControl.vue rename resources/js/modules/forms/nested-elements/{nested-entry-actions.test.ts => nested-element-actions.test.ts} (85%) rename resources/js/modules/forms/nested-elements/{nested-entry-actions.ts => nested-element-actions.ts} (77%) create mode 100644 resources/js/modules/forms/nested-elements/nested-elements-context.ts create mode 100644 resources/js/modules/forms/nested-elements/nested-elements.test.ts rename resources/js/modules/forms/nested-elements/{nested-entries.ts => nested-elements.ts} (66%) delete mode 100644 resources/js/modules/forms/nested-elements/nested-entries-context.ts delete mode 100644 resources/js/modules/forms/nested-elements/nested-entries.test.ts rename resources/js/modules/forms/nested-elements/{useNestedEntryOperations.ts => useNestedElementOperations.ts} (78%) rename resources/js/modules/forms/nested-elements/{useNestedEntriesQuery.test.ts => useNestedElementsQuery.test.ts} (95%) rename resources/js/modules/forms/nested-elements/{useNestedEntriesQuery.ts => useNestedElementsQuery.ts} (91%) create mode 100644 src/Element/Contracts/NestedIndexConfigProviderInterface.php create mode 100644 src/Form/Controls/NestedElements.php create mode 100644 src/Http/Controllers/Elements/ElementEditorController.php create mode 100644 tests/Feature/Address/Elements/AddressRequestAttributesTest.php create mode 100644 tests/Feature/Field/AddressesFormControlTest.php create mode 100644 tests/Feature/Http/Controllers/Elements/ElementEditorControllerTest.php diff --git a/CHANGELOG.md b/CHANGELOG.md index 447d42c7a2a..4e97ce96131 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -109,6 +109,9 @@ - Added support for disabling Matrix entries for individual sites. ([#19686](https://github.com/craftcms/cms/pull/19686)) - Restored Matrix fields’ configured Cards and Cards Grid view modes in element forms. - Restored Matrix fields’ configured Index view mode in element forms. +- Fixed a bug where Matrix Index views could show content from the wrong site. +- Fixed a bug where reopening a Matrix entry from an Index view could show its canonical content instead of the owner draft’s content. +- Addresses fields’ configured Cards and Index view modes are now used in element forms. - `CraftCms\Cms\Field\Contracts\FieldInterface::modifyQuery()` now accepts an `Illuminate\Database\Query\Builder` object for its `$query` argument, and has a new `CraftCms\Cms\Element\Queries\Contracts\ElementQueryInterface $elementQuery` argument, and a `void` return type. ([#19562](https://github.com/craftcms/cms/pull/19562), [#19585](https://github.com/craftcms/cms/pull/19585)) - Moved legacy relation-field settings HTML and entry-title input HTML into the Yii adapter. ([#19591](https://github.com/craftcms/cms/pull/19591)) - Restored “Copy value from site” functionality. ([#19683](https://github.com/craftcms/cms/pull/19683)) @@ -133,8 +136,18 @@ - Added Markdown comments to element activity timelines, with support for editing, removing, structured user mentions, and email notifications. - Added `CraftCms\Cms\Activity\Contracts\ShouldBeRetained`, allowing activity event types such as comments to opt out of activity garbage collection. - Restored expand/collapse and drag interactions on structure element index views. ([#19691](https://github.com/craftcms/cms/pull/19691)) +- Fixed a bug where the Delete element action reported success when some elements couldn't be deleted. ([#19743](https://github.com/craftcms/cms/pull/19743)) +- Fixed a bug where plugin-provided download element actions didn’t trigger file downloads from element indexes. ([#19730](https://github.com/craftcms/cms/pull/19730)) +- Fixed a bug where saving an element draft could send a redundant autosave request. ([#19704](https://github.com/craftcms/cms/pull/19704)) +- Fixed a bug where `resave` and `update-statuses` commands reported skipped revisions as root element lookup errors. +- Fixed a bug where nested element cards’ Copy, Duplicate, and Delete actions were always disabled. +- Fixed a bug where nested elements without their own edit page, such as addresses, couldn’t be opened from their cards. +- Fixed a bug where nested element slideouts for element types other than entries always used the legacy editor. ### Users & Auth +- The user Addresses screen now uses the shared nested element manager, including duplicating, deleting, and the element index view for users with many addresses. +- Fixed a bug where addresses couldn’t be saved from an element editor slideout. +- Fixed a bug where element actions couldn’t find a user’s addresses. - Added support for sending queued Laravel notifications to `CraftCms\Cms\User\Elements\User` elements. ([#19541](https://github.com/craftcms/cms/pull/19541)) - Added the `authGuard` and `authPasswordBroker` general config settings, allowing Craft authentication to use a dedicated Laravel guard, provider, and password broker. ([#19598](https://github.com/craftcms/cms/issues/19598)) @@ -172,6 +185,14 @@ - `asFailure()` now flashes its message even when the response has validation errors. - Deprecated the `flash` Inertia shared prop. `messages` should be used instead. - Removed `useFlash()`, `useFlashMessages()`, and the `FlashMessages` Vue component. `useMessages()` should be used instead. +- Added `CraftCms\Cms\Form\Controls\NestedElements` and the `craft:nested-elements` Vue control, which manage any nested element type as cards or an embedded element index. +- Added `CraftCms\Cms\Element\NestedElementManager::formControl()`. +- Added `CraftCms\Cms\Element\Contracts\NestedIndexConfigProviderInterface`, for fields and owner elements that provide their nested elements’ embedded index config. +- Added `CraftCms\Cms\Http\Controllers\Elements\ElementEditorController`, the shared base for Inertia element edit screens. +- Added `CraftCms\Cms\Element\Contracts\ElementInterface::editControllerClass()`. Element types that return a controller get their Inertia editor in nested element slideouts and `elements/edit` URLs. +- Added `NestedElements.vue` and `savedNestedOwner()`, for managing an owner’s nested elements outside an element editor. +- Deprecated `CraftCms\Cms\Form\Controls\NestedEntries`. `NestedElements` should be used instead. +- Removed the `pasteableEntryTypeIds` nested element manager setting. `pasteableData` is used instead. - Stopped loading the deprecated `XRegExp` library by default. Plugins that require it can register `craft\web\assets\xregexp\XregexpAsset`. ([#19621](https://github.com/craftcms/cms/pull/19621)) ### System diff --git a/docs/forms.md b/docs/forms.md index 2bb31e9932c..3114f96d737 100644 --- a/docs/forms.md +++ b/docs/forms.md @@ -126,6 +126,37 @@ Return zero or one root Node. It may contain children or a composite Control. Th mode. Listen for `FieldLayoutFormResolving` to add, remove, or reorder typed Nodes after compilation; do not mutate rendered HTML or persisted layout data. +### Nested elements + +Elements nested in an owner — Matrix and Addresses fields, a user's addresses, or a plugin's own nested element type — +are managed by a `NestedElementManager`, which builds the shared `NestedElements` Control (`craft:nested-elements`). It +renders the elements as cards or an embedded element index outside the owner's form, and handles creating, editing in +a slideout, reordering, pasting, duplicating, and deleting them, including preparing the owner's draft first: + +```php +use CraftCms\Cms\Form\Contracts\Control; + +public function formControl(FieldContext $context): Control +{ + return $this->manager()->formControl($context->path, $context->element, 'cards', [ + 'canCreate' => true, + 'sortable' => true, + 'maxElements' => $this->maxItems, + ]); +} +``` + +The view mode is `cards`, `cards-grid`, or `index`. An index's later requests (paging, inline saves, element actions) +rebuild its config without the original call, so a field or owner element whose index needs more than +`NestedElementManager::defaultIndexConfig()` implements `NestedIndexConfigProviderInterface` to supply it. + +Screens that manage an owner's nested elements outside an element editor render `NestedElements.vue` directly with the +Control's props, passing `savedNestedOwner()` as its `owner` so changes apply to the saved owner and the screen +re-renders afterwards (see `pages/users/Addresses.vue`). + +A nested element type gets the Inertia editor in those slideouts by returning its `ElementEditorController` from +`ElementInterface::editControllerClass()`. + ### FieldLayout component settings Field layout components — tabs and layout elements — describe the form shown in the designer's settings slideout by diff --git a/resources/js/modules/elements/composables/useElementEditor.test.ts b/resources/js/modules/elements/composables/useElementEditor.test.ts index 37c816bf5a9..529971f8c1e 100644 --- a/resources/js/modules/elements/composables/useElementEditor.test.ts +++ b/resources/js/modules/elements/composables/useElementEditor.test.ts @@ -351,8 +351,8 @@ describe('useElementEditor', () => { component: 'craft:field', props: {label: 'Cards', instructions: null, required: false}, control: { - type: 'CraftCms\\Cms\\Form\\Controls\\NestedEntries', - component: 'craft:nested-entries', + type: 'CraftCms\\Cms\\Form\\Controls\\NestedElements', + component: 'craft:nested-elements', props: { viewMode: 'cards', manager: null, diff --git a/resources/js/modules/elements/nested-owner.test.ts b/resources/js/modules/elements/nested-owner.test.ts index 027e0e5ef3f..448a4c2d73e 100644 --- a/resources/js/modules/elements/nested-owner.test.ts +++ b/resources/js/modules/elements/nested-owner.test.ts @@ -5,8 +5,8 @@ import type {FormPayload} from '@/modules/forms/types'; describe('nestedOwnerId', () => { it('resolves the requested nested block owner rather than the root element', () => { const control = (path: string[], ownerId: number) => ({ - type: 'NestedEntries', - component: 'craft:nested-entries', + type: 'NestedElements', + component: 'craft:nested-elements', mode: 'editable', path, deltaGroup: path, diff --git a/resources/js/modules/elements/nested-owner.ts b/resources/js/modules/elements/nested-owner.ts index 3069e8ea6a7..d06f2ea6fde 100644 --- a/resources/js/modules/elements/nested-owner.ts +++ b/resources/js/modules/elements/nested-owner.ts @@ -30,7 +30,7 @@ export function nestedOwnerContext( let context: NestedOwnerContext | null = null; visitControls(form.nodes, (control) => { if ( - control.component !== 'craft:nested-entries' || + control.component !== 'craft:nested-elements' || !pathsMatch(control.path, path) ) { return; @@ -54,6 +54,26 @@ export function nestedOwnerContext( return context; } +/** + * An owner that's always saved and never drafted, e.g. a user on their own + * Addresses screen: nested element changes apply to it directly, and the + * screen re-renders the manager through `refresh()`. + */ +export function savedNestedOwner( + ownerId: number, + refresh: () => Promise +): NestedOwnerEditor { + return { + prepare: async () => ({ + ownerId, + ownerIsDerivative: false, + ownerIsInDerivativeTree: false, + ownerIsUnpublishedDraft: false, + }), + refresh, + }; +} + export const NestedOwnerEditorKey: InjectionKey = Symbol( 'nested-owner-editor' ); diff --git a/resources/js/modules/forms/FormRenderer.test.ts b/resources/js/modules/forms/FormRenderer.test.ts index 8259208e42e..e1f16554d01 100644 --- a/resources/js/modules/forms/FormRenderer.test.ts +++ b/resources/js/modules/forms/FormRenderer.test.ts @@ -2479,8 +2479,8 @@ describe('FormRenderer', () => { deltaGroup: ['settings', 'matrix'], }), node({ - type: 'CraftCms\\Cms\\Form\\Controls\\NestedEntries', - component: 'craft:nested-entries', + type: 'CraftCms\\Cms\\Form\\Controls\\NestedElements', + component: 'craft:nested-elements', props: { viewMode: 'cards', cards: [], diff --git a/resources/js/modules/forms/entry-field-layout-form-host.test.ts b/resources/js/modules/forms/entry-field-layout-form-host.test.ts index ad1065d0941..e94a62dd71c 100644 --- a/resources/js/modules/forms/entry-field-layout-form-host.test.ts +++ b/resources/js/modules/forms/entry-field-layout-form-host.test.ts @@ -125,8 +125,8 @@ it('submits Entry Form values and preserves refresh context', async () => { component: 'test:owner-prepare', props: {}, control: { - type: 'NestedEntries', - component: 'craft:nested-entries', + type: 'NestedElements', + component: 'craft:nested-elements', mode: 'editable', path: ['editor', 'cards'], deltaGroup: ['editor', 'cards'], @@ -438,8 +438,8 @@ it('submits Entry Form values and preserves refresh context', async () => { component: 'test:owner-prepare', props: {}, control: { - type: 'NestedEntries', - component: 'craft:nested-entries', + type: 'NestedElements', + component: 'craft:nested-elements', mode: 'editable', path: ['editor', 'matrix', 'entries', 'block-c', 'cards'], deltaGroup: ['editor', 'matrix', 'entries', 'block-c', 'cards'], diff --git a/resources/js/modules/forms/nested-elements/NestedElementBlocksControl.vue b/resources/js/modules/forms/nested-elements/NestedElementBlocksControl.vue index 0da95e9f6fd..43089dc9d60 100644 --- a/resources/js/modules/forms/nested-elements/NestedElementBlocksControl.vue +++ b/resources/js/modules/forms/nested-elements/NestedElementBlocksControl.vue @@ -44,8 +44,8 @@ import {useSelectable} from '@/common/composables/useSelectable'; import SelectableCardList from '@/common/components/SelectableCardList.vue'; import FormNodeList from '../FormNodeList.vue'; - import NestedEntriesCreateButton from './NestedEntriesCreateButton.vue'; - import {isPasteable} from './nested-entries'; + import NestedElementsCreateButton from './NestedElementsCreateButton.vue'; + import {isPasteable} from './nested-elements'; import type {ActionItems} from '@/common/types'; import {useMessages} from '@/modules/messages/useMessages'; import { @@ -252,9 +252,11 @@ const fits = isPasteable(elements, { elementType, - entryTypeIds: Object.values(create.entryTypeIds), + pasteableData: { + attribute: 'entryTypeId', + values: Object.values(create.entryTypeIds), + }, room: true, - requireEntryTypeId: true, }); return fits ? elements : []; @@ -1580,7 +1582,7 @@
- ; singleColumn: boolean; editable: boolean; busy: boolean; reorderable: boolean; - operations: NestedEntryOperations; - quickEdit: NestedEntryQuickEdit; + operations: NestedElementOperations; + quickEdit: NestedElementQuickEdit; editOpener?: HTMLElement | null; }>(); function actionIds(): number[] { - return props.selection.isSelected(props.entry.id) + return props.selection.isSelected(props.element.id) ? [...props.selection.selectedIds.value] - : [props.entry.id]; + : [props.element.id]; } function actions() { const ids = actionIds(); - const selectedEntries = props.entries.filter((entry) => - ids.includes(entry.id) + const selectedElements = props.elements.filter((element) => + ids.includes(element.id) ); - const index = props.entries.findIndex( - (candidate) => candidate.id === props.entry.id + const index = props.elements.findIndex( + (candidate) => candidate.id === props.element.id ); - return nestedEntryActions({ - entry: props.entry, - selectedEntries, + return nestedElementActions({ + element: props.element, + selectedElements, ids, index, - count: props.entries.length, + count: props.elements.length, editable: props.editable, busy: props.busy, canPaste: props.operations.canPaste.value, @@ -56,21 +56,23 @@ function editLabel(): string { return t('Edit {title}', { - title: props.entry.cardAttributes?.data?.label ?? `#${props.entry.id}`, + title: + props.element.cardAttributes?.data?.label ?? `#${props.element.id}`, }); } function actionLabel(): string { return t('Actions for {title}', { - title: props.entry.cardAttributes?.data?.label ?? `#${props.entry.id}`, + title: + props.element.cardAttributes?.data?.label ?? `#${props.element.id}`, }); }