Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
11 changes: 11 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,17 @@
- The global `axios` is now only loaded in the control panel when `craftcms/yii2-adapter` is installed. `Craft.sendActionRequest()` or `actionClient` from `@craftcms/ui` should be used instead.
- Removed `Cp.$axios`.
- Fixed a bug where legacy embedded element indexes were missing actions, exporters, and reorder controls. ([#19789](https://github.com/craftcms/cms/pull/19789))
- Added `CraftCms\Cms\Form\Controls\NestedElements` and `CraftCms\Cms\Element\NestedElementManager::formControl()`, allowing plugins to manage custom nested element types as cards or embedded element indexes. ([#19792](https://github.com/craftcms/cms/pull/19792))
- Unified entry edit pages and nested element slideouts under the generic element editor, with a default `CraftCms\Cms\Http\ViewModels\ElementEditViewModel` for custom element types. ([#19792](https://github.com/craftcms/cms/pull/19792))
- Added `CraftCms\Cms\Element\Events\ElementEditorPayloadResolving`. ([#19792](https://github.com/craftcms/cms/pull/19792))
- The user Addresses screen now uses the shared nested element manager, including duplicating, deleting, and the element index view for users with many addresses. ([#19792](https://github.com/craftcms/cms/pull/19792))
- Removed the `pasteableEntryTypeIds` nested element manager setting. `pasteableData` should be used instead. ([#19792](https://github.com/craftcms/cms/pull/19792))
- Fixed a bug where Addresses fields’ configured Cards and Index view modes weren’t used in element forms. ([#19792](https://github.com/craftcms/cms/pull/19792))
- Fixed a bug where nested element cards’ Copy, Duplicate, and Delete actions were always disabled. ([#19792](https://github.com/craftcms/cms/pull/19792))
- Fixed a bug where nested elements without their own edit page, such as addresses, couldn’t be opened from their cards. ([#19792](https://github.com/craftcms/cms/pull/19792))
- Fixed a bug where nested element slideouts for element types other than entries always used the legacy editor. ([#19792](https://github.com/craftcms/cms/pull/19792))
- Fixed a bug where addresses couldn’t be saved from an element editor slideout. ([#19792](https://github.com/craftcms/cms/pull/19792))
- Fixed a bug where element actions couldn’t find a user’s addresses. ([#19792](https://github.com/craftcms/cms/pull/19792))
- Fixed a bug where users’ breadcrumb chips weren’t getting hyperlinked.
- Fixed a bug where `craft:up` could fail on installs that didn’t have a migrations table yet. ([#19796](https://github.com/craftcms/cms/pull/19796))
- Fixed a bug where subsequent embedded index requests lost configuration supplied by non-Matrix nested element managers. ([#19788](https://github.com/craftcms/cms/pull/19788))
Expand Down
37 changes: 37 additions & 0 deletions docs/forms.md
Original file line number Diff line number Diff line change
Expand Up @@ -126,6 +126,43 @@ 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`. The frontend retains the initial index's display settings and
sends them with later requests, including paging and inline saves. Fields and owner elements do not need a
configuration provider. The frontend uses `maxElements` to disable operations when the whole selection will not
fit. The backend resolves the owner and nested element scope on each request and checks the element type's or
field's policies. Authoritative limits belong in those policies and field validation. A posted display setting
cannot bypass them. As in 5.x, duplication can partially succeed if the selection no longer fits when it runs.

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`).

Nested element types use the shared Inertia element editor automatically. They supply their field layout and
`sidebarForm()`, and can extend `ElementEditViewModel` through `ElementInterface::editViewModelClass()` when they
need additional payload or a different save action. `ElementEditorPayloadResolving` lets listeners modify the
prepared payload for edit screens and autosave responses. With the Yii adapter installed, existing editor HTML events
and `prepareEditScreen()` customizations are rendered around the native forms.

### FieldLayout component settings

Field layout components — tabs and layout elements — describe the form shown in the designer's settings slideout by
Expand Down
38 changes: 12 additions & 26 deletions docs/slideouts.md
Original file line number Diff line number Diff line change
Expand Up @@ -240,32 +240,18 @@ open and keeps saving as the user types, so an opener that refreshes on it shoul

### The element editor

The `elements/edit` screen is the exception: it doesn't submit itself. Drafts, autosaving,
provisional drafts, delta submission and tab error indicators all live in `Craft.ElementEditor`, so
the panel hands that screen over to it rather than reimplementing any of it — the same deal
`ElementEditorSlideout` strikes for the jQuery stack.

`useElementEditor()` builds the editor and gives it the shell's regions: the panel `<form>`, the
content and details columns, the header as a spinner host. Everything else is adapted through
callbacks — `updateTabs`/`getTabManager` onto a `Craft.Tabs` the bridge owns, submit results onto
the panel's own success and error handling.

Two wrinkles worth knowing:

- **Settings arrive as `screen.elementEditorSettings`,** not through `$(container).data()`.
`EditElementController` branches on `$request->inertia()` and calls `CpScreenResponse::screenData()`
instead of emitting the usual script. That script looks the container up by id, and it runs while
Vue still has the panel's subtree detached from the document, so the lookup finds nothing. Any
screen with the same problem can use `screenData()` the same way.
- **The editor is built on a `ready` signal, not on mount.** Fragments are appended asynchronously
(`appendHeadHtml()` resolves only once the screen's assets have loaded), and the editor snapshots
the form to detect changes — snapshotting an empty form would read every field as an edit. So
`cp/Screen` waits until *all* of its fragments have reported in, then calls the callback provided
under `ScreenContentReadyKey`.

Once the editor is running it owns the screen: `SlideoutScreen` routes Save to it, hides the Save
button on a static screen (a revision), and lets it rename Cancel to "Close" once a provisional
draft exists.
Inertia requests to `elements/edit` render the shared `elements/Edit` page. Entry edit URLs
use the same controller. Every element type has a default `ElementEditViewModel`; types can
extend it to customize their payload and save parameters.

`useElementEditor()` drives the native Form renderers, drafts, autosave, and saves in both pages
and slideouts. The slideout provides its own payload and owner context through the same editor.
With the Yii adapter installed, element HTML events and `prepareEditScreen()` can wrap or replace the native content and sidebar;
inputs added by those customizations are included in saves, and registered assets load after the
content is ready.

Legacy jQuery slideouts continue to request JSON without the Inertia header. They receive the
legacy `CpScreenResponse` and use `Craft.ElementEditor`.

### Unsaved changes

Expand Down
15 changes: 10 additions & 5 deletions packages/craftcms-ui/src/utilities/dom.ts
Original file line number Diff line number Diff line change
Expand Up @@ -262,13 +262,15 @@ export function serializeFormInputs(container: HTMLElement): string {
* Names are kept verbatim — PHP-style bracket names (`settings[path]`) stay
* flat keys, matching what the server would see after parsing the string
* form. Repeated names are grouped into arrays rather than last-one-wins.
* An excluded subtree can contain a native form whose state is tracked separately.
*/
export function serializeFormInputsAsObject(
container: HTMLElement
container: HTMLElement,
exclude?: HTMLElement | null
): Record<string, string | string[]> {
const object: Record<string, string | string[]> = {};

for (const [name, value] of collectFormInputs(container)) {
for (const [name, value] of collectFormInputs(container, exclude)) {
const existing = object[name];

if (existing === undefined) {
Expand All @@ -283,14 +285,17 @@ export function serializeFormInputsAsObject(
return object;
}

function collectFormInputs(container: HTMLElement): URLSearchParams {
function collectFormInputs(
container: HTMLElement,
exclude?: HTMLElement | null
): URLSearchParams {
const params = new URLSearchParams();
const controls = container.querySelectorAll<
HTMLInputElement | HTMLSelectElement | HTMLTextAreaElement
>('input[name], select[name], textarea[name]');

for (const control of controls) {
if (control.disabled) {
if (control.disabled || exclude?.contains(control)) {
continue;
}

Expand Down Expand Up @@ -321,7 +326,7 @@ function collectFormInputs(container: HTMLElement): URLSearchParams {
}

for (const host of container.querySelectorAll<FormValueHost>('*')) {
if (!host.tagName.includes('-')) {
if (!host.tagName.includes('-') || exclude?.contains(host)) {
continue;
}

Expand Down
77 changes: 50 additions & 27 deletions resources/js/modules/elements/components/ElementEditor.vue
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,13 @@
* save controls and details column go.
*/
import {t} from '@craftcms/ui';
import {computed, nextTick, provide, useTemplateRef} from 'vue';
import {
computed,
nextTick,
provide,
useTemplateRef,
type Component,
} from 'vue';
import {router, usePage} from '@inertiajs/vue3';
import DynamicHtmlRenderer from '@/common/components/DynamicHtmlRenderer.vue';
import MetadataDetailsContent from '@/common/components/MetadataDetailsContent.vue';
Expand Down Expand Up @@ -43,8 +49,17 @@
* piece of the pipeline (e.g. an entry's `entryId`/`sectionId`).
*/
saveData?: () => FormValues;
transform?: (data: object) => FormValues;
formWrapper?: Component;
showDetails?: boolean;
}>();

const editor = useElementEditor({
saveData: props.saveData,
transform: props.transform,
root: () => contentEl.value,
});

const {
activity,
activityTimelineVersion,
Expand All @@ -68,10 +83,7 @@
submitAction,
updatePayload,
workflowReviewLocked,
} = useElementEditor({
saveData: props.saveData,
root: () => contentEl.value,
});
} = editor;

provide(NestedOwnerEditorKey, {
async prepare(path) {
Expand Down Expand Up @@ -115,6 +127,7 @@

const hasDetails = computed(
() =>
Boolean(props.showDetails) ||
Boolean(sidebarPayload.value) ||
Boolean(payload.metadataHtml) ||
Boolean(payload.activityTimelineUrl) ||
Expand Down Expand Up @@ -370,18 +383,23 @@
<div ref="content" class="py-3">
<CpContainer>
<component
:is="hasUntabbedFields ? 'craft-field-group' : 'div'"
v-if="formPayload"
:is="formWrapper ?? 'div'"
v-bind="formWrapper ? {editor, region: 'content'} : {}"
>
<FormRenderer
ref="renderer"
:payload="formPayload"
:errors="errors"
:refresh="formPayload.refreshable ? refreshLayout : undefined"
:modified="autosave.modified.value"
:disabled="workflowReviewLocked"
@update:mutation="onMutation"
/>
<component
:is="hasUntabbedFields ? 'craft-field-group' : 'div'"
v-if="formPayload"
>
<FormRenderer
ref="renderer"
:payload="formPayload"
:errors="errors"
:refresh="formPayload.refreshable ? refreshLayout : undefined"
:modified="autosave.modified.value"
:disabled="workflowReviewLocked"
@update:mutation="onMutation"
/>
</component>
</component>

<slot :payload="payload" />
Expand All @@ -406,17 +424,22 @@
<slot name="details-header" :payload="payload" />

<MetadataDetailsContent :html="payload.metadataHtml">
<template v-if="sidebarPayload" #default>
<craft-field-group>
<FormRenderer
ref="sidebarRenderer"
:payload="sidebarPayload"
:errors="sidebarErrors"
:modified="autosave.modified.value"
:disabled="workflowReviewLocked"
@update:mutation="onSidebarMutation"
/>
</craft-field-group>
<template #default>
<component
:is="formWrapper ?? 'div'"
v-bind="formWrapper ? {editor, region: 'sidebar'} : {}"
>
<craft-field-group v-if="sidebarPayload">
<FormRenderer
ref="sidebarRenderer"
:payload="sidebarPayload"
:errors="sidebarErrors"
:modified="autosave.modified.value"
:disabled="workflowReviewLocked"
@update:mutation="onSidebarMutation"
/>
</craft-field-group>
</component>
</template>
</MetadataDetailsContent>
</template>
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -34,6 +34,8 @@ export interface ElementAutosaveOptions {
* the server needs to resolve it through the owner it's being edited in.
*/
params?: () => FormValues;
/** Adapts the editor's form state into the save request. */
transform?: (data: object) => FormValues;
/** How long to wait after the last edit before saving, per change kind. */
debounceMs?: Partial<Record<FormChangeKind, number>>;
/**
Expand Down Expand Up @@ -120,7 +122,7 @@ export function useElementAutosave<T extends object>(
siteId: options.siteId,
...options.params?.(),
};
Object.assign(payload, form.data());
Object.assign(payload, options.transform?.(form.data()) ?? form.data());

// No draft yet means this request creates one; an existing provisional
// draft is targeted by id and stays provisional.
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down
15 changes: 13 additions & 2 deletions resources/js/modules/elements/composables/useElementEditor.ts
Original file line number Diff line number Diff line change
Expand Up @@ -85,6 +85,7 @@ export interface ElementContextMenuItem {

/** The shared payload every {@link ElementEditViewModel} emits. */
export interface ElementEditPayload {
editorContainerId?: string;
elementId: number | null;
canonicalId: number | null;
elementType: string;
Expand Down Expand Up @@ -165,6 +166,8 @@ interface Options {
* whatever the type's save action needs to resolve the element it's saving.
*/
saveData?: () => FormValues;
/** Adapts form state before autosave and explicit submissions. */
transform?: (data: object) => FormValues;
/**
* Where the screen's content is rendered. Failed saves announce invalid
* nested elements from here, so the nested element fields inside hear it.
Expand All @@ -180,7 +183,7 @@ interface Options {
* Element-type pages supply only what their save action needs via
* {@link Options.saveData}; everything else comes from the shared payload.
*/
export function useElementEditor({saveData, root}: Options = {}) {
export function useElementEditor({saveData, root, transform}: Options = {}) {
// Not `usePage()`: inside a slideout that's the page *behind* the panel, so
// the editor would read the index's props and find no payload at all. This
// resolves to the panel's own props there, and to `usePage()` on a full page.
Expand Down Expand Up @@ -286,6 +289,9 @@ export function useElementEditor({saveData, root}: Options = {}) {
return {
...props.nestedContext,
...(props.fresh ? {fresh: 1} : {}),
...(props.editorContainerId
? {editorContainerId: props.editorContainerId}
: {}),
};
}

Expand All @@ -298,6 +304,7 @@ export function useElementEditor({saveData, root}: Options = {}) {
draftId: props.draftId,
isProvisional: props.isProvisionalDraft,
enabled: props.canAutosave,
transform: requestValues,
// Autosave moves the draft's `dateUpdated` without a visit, so the poller
// has to re-baseline against what the save just wrote — otherwise it reads
// our own keystrokes back as an edit from elsewhere. `activity` is
Expand Down Expand Up @@ -462,6 +469,10 @@ export function useElementEditor({saveData, root}: Options = {}) {
}
}

function requestValues(data: object): FormValues {
return transform?.(data) ?? ({...data} as FormValues);
}

/**
* Re-renders the field layout from the server, keeping unsaved values.
*
Expand Down Expand Up @@ -603,7 +614,7 @@ export function useElementEditor({saveData, root}: Options = {}) {
const transformed =
pendingAction.value?.includeFormData === false
? {}
: {...saveData?.(), ...data};
: {...saveData?.(), ...requestValues(data)};
// Shared `elements/*` actions need generic identity params: the
// type-specific ones (an entry's `entryId`) mean nothing there.
if (autosave.draftId.value !== null) {
Expand Down
4 changes: 2 additions & 2 deletions resources/js/modules/elements/nested-owner.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down
Loading
Loading