Skip to content

[6.x] Port the element editor to Inertia - #19425

Merged
brandonkelly merged 12 commits into
6.xfrom
feature/inertia-element-editor
Aug 12, 2026
Merged

[6.x] Port the element editor to Inertia#19425
brandonkelly merged 12 commits into
6.xfrom
feature/inertia-element-editor

Conversation

@brianjhanson

Copy link
Copy Markdown
Contributor

Description

Ports the element edit screen to Inertia and moves the entry, asset, and user editors onto it, replacing the CpScreenResponse screens EditElementController produced for them.

ElementEditViewModel is the shared payload — public methods are payload keys, and each element type extends it with its own keys plus its save action — while useElementEditPage and ElementEditPage.vue hold the shared client pipeline. The field layout and the sidebar meta fields both compile to FormPayloads and render through FormRenderer, bridged into a single Inertia form, so saving posts ordinary nested input names to the existing save actions with no translation layer.

Everything the legacy editor did comes with it: multi-site controls and per-site statuses, provisional drafts and autosave, named drafts and revisions with their apply and revert controls, header and alternate save actions, the element action menu, preview target links, activity polling with upstream-change notices, and dismissible tips.

This replaces the stack of #19389, #19390, #19395, #19396, #19397, #19399, #19403, #19404, #19405, #19406, #19420 and #19421 — one commit per PR, in the same order, each carrying that PR's description as its commit message.

Ports the full-page entry edit screen to Inertia, rendering the field layout through the Form system.

`ElementEditViewModel` is the shared payload for element edit screens, mirroring `ContentIndexViewModel`: public methods are payload keys, and element types extend it with their own keys plus a `saveUrl()`. `EntryEditViewModel` and `content/Edit.vue` are the entry-specific pieces, while `useElementEditPage` and `ElementEditPage.vue` hold the shared client pipeline so the remaining element types only supply what their save action needs.

The field layout is compiled to a `FormPayload` through the same `FieldLayoutCompiler` the legacy editor and slideouts already use, and rendered by `FormRenderer`. Saving posts through Inertia to the existing `entries/save-entry` action — the Form payload submits ordinary nested input names, so the save controller reads it without a translation layer.

Drafts, revisions, and provisional drafts still fall through to `EditElementController`, keeping their notices and apply/revert controls intact until those screens are ported later in this stack. That controller also continues to serve slideouts and the element types that haven't been ported.

The sidebar meta fields remain a server-rendered island here and move onto the Form system in #19390.

Originally reviewed in #19389.
Moves the editor sidebar's meta fields onto the Form system, so they submit as Inertia form state instead of being read out of the DOM at save time.

Adds `ElementInterface::sidebarForm()`, implemented on `HasControlPanelUI` with `metaFieldsNodes()` as the per-element-type extension point — the Form-system counterpart to `getSidebarHtml()` and `metaFieldsHtml()`, which stay in place for the legacy editor and slideouts. `Entry::metaFieldsNodes()` covers entry type, slug, parent, authors, post date, and expiry date under the same visibility rules as `metaFieldsHtml()`; the base implementation contributes the status and notes fields.

The sidebar renders as a second `FormRenderer` bridged into the same Inertia form as the field layout. Two root-scoped `useInertiaFormRenderer` bridges can share one form because each instance only clears the root keys it wrote itself, and both are constructed before either receives a mutation — `useInertiaFormRenderer.test.ts` pins that behavior. With every value now in Inertia state, the unsaved-changes guard reduces to `form.isDirty`.

Per-site statuses are not included. The expand control behind them is wired up by `Craft.ElementEditor`, which never ran on the Inertia screen, so it lands with the multi-site work later in the stack.

Originally reviewed in #19390.
Restores the element editor's multi-site controls on the Inertia screen.

Breadcrumbs now lead with a site crumb whose menu links to the same element on every other editable site it propagates to, carrying the current query params across so switching sites doesn't drop the draft, revision, or return URL. The crumb is omitted for non-localized elements and anywhere fewer than two editable sites apply.

The sidebar's status control follows the legacy editor: with two or more editable sites it renders an "Enabled for all sites" switch plus a per-site switch for each of them, collapsed into a group. Sites the element hasn't propagated to yet are absent from `siteStatusesForElement()`, so they default to enabled the same way the legacy editor defaults them. A single editable site keeps the plain `enabled` switch.

`useSiteStatuses` reconciles the two directions: the global switch applies its value to every site, and per-site changes roll back up — `true` when all are on, `false` when none are, indeterminate when they disagree. Because both are ordinary Form Controls writing into the shared Inertia form, this is value reconciliation with no DOM involved, unlike the lightswitch bookkeeping the legacy editor needs.

Copying a field's value to other sites is not included; it hangs off per-field controls rather than the sidebar and is better handled with the field-level work.

Originally reviewed in #19395.
Renders provisional drafts in the Inertia editor and autosaves into them, which is what makes the port usable: the legacy editor creates a provisional draft the moment anyone types, so before this every previously-edited entry fell through to the old screen.

`EditEntryController` now only defers to `EditElementController` for named drafts and revisions. Provisional drafts render here, with a notice and a Discard control, and `saveUrl`/`applyDraftUrl` let the client pick its target at submit time — autosave can create a draft partway through editing a canonical element, and from that point saving means applying the draft rather than saving the element underneath it.

`useElementAutosave` posts to the shared `elements/save-draft` action, debounced and serialized so a burst of typing collapses into one trailing request. It is driven by the Form renderers' change callbacks rather than a watch on the Inertia form, because the form starts empty and gains its keys dynamically. Re-baselining after a save is suspended so it can't recreate the draft the save just consumed, and the draft id resyncs from the server after each visit. With edits safe in a draft, the unsaved-changes prompt now only fires when autosave hasn't caught up.

Also fixes a save-blocking bug from the meta-fields PR: the `DateTime` control defaults to a 30-minute increment, so an entry whose post time fell between steps failed native form validation and the Save button silently did nothing. The legacy editor never hit this because it submits through JS. Post and expiry dates now accept the stored precision.

Originally reviewed in #19396.
Adds the header buttons and the alternate save actions beside the Save button.

`headerActions()` covers "Create a draft" (a canonical element the user may branch, posting to `elements/save-draft` with `dropProvisional` so a named draft is created rather than the provisional one being promoted) and "Save as a new …" for users who can't save the element but may duplicate it. `formActions()` serializes `getAltActions()` into the Save button's menu — Save and continue editing, Save and add another, Save as a new … — with redirects encrypted the way the save controllers expect to decrypt them.

Both render through `FormActions`, which already supported additional buttons and action items. The layout's own default action is switched off for this screen, since the element supplies its own "Save and continue editing" and the two would otherwise both appear.

`submitAction()` routes every one of these through the existing save pipeline rather than reimplementing submission per action: a pending-action reference overrides the target URL and merges the action's params for exactly one submission, so they inherit the elevated-session handling, error plumbing, and processing state already in place. Action params take precedence over the provisional-draft targeting, which is what lets "Create a draft" opt out of it.

Actions posting to a shared `elements/*` endpoint now carry the generic identity params those endpoints resolve by. The type-specific save controllers key off their own params, so a request built for one is not understood by the other — this previously surfaced as a 400 when applying a draft, and again here.

Applying a named draft and reverting a revision belong beside these buttons, but those screens still render through the legacy editor, so they arrive with the draft and revision work rather than as buttons nothing can reach.

Originally reviewed in #19397.
Renders named drafts and revisions in the Inertia editor, removing the last fallback to the legacy `EditElementController` for entries.

Revisions render read-only with the notice explaining why, and offer "Revert content from this revision". Named drafts get "Apply draft" and a Save button labelled for what it saves — "Save draft" for a draft, "Create entry" for an unpublished one — which required a `submitButtonLabel` pass-through on the layout and form actions. An outdated draft still merges the canonical element's newer changes on the way in, and now says so above the editor; that logic moved back out of the legacy controller along with everything else.

The drafts-and-revisions switcher returns beside the breadcrumbs. Its groups arrive flattened, with headings carried as `display` items, because the action menu's item contract has no nested-group shape.

Also fixes a bug that predates this PR: `provisional` was sent on every draft-targeted request once a draft existed. The server narrows its draft lookup when that param is present, so a named draft stopped resolving — applying one appeared to succeed and silently did nothing. It's now sent only when the draft really is provisional, or when the request is the one creating it, matching what the legacy editor does.

Originally reviewed in #19399.
Brings the element action menu to the Inertia editor: Validate, Copy, the entry type and section settings slideouts, Delete for this site, and Delete.

`actionMenuDescriptors()` is the counterpart to `getActionMenuItems()` — the same actions, but each item names what it does rather than pairing markup with an inline script. The client dispatches those behaviors itself, so nothing depends on registered jQuery handlers. Element types extend it through `extraActionMenuDescriptors()` the way they extend the HTML items; entries add their settings slideouts there, and the entry type slideout still follows the sidebar's unsaved selection rather than the stored value.

Deletion continues to run through `ElementDeletionManager`, so blocking relations and references can be reassigned before the element goes. The manager was already ported to TypeScript, so the Vue handler constructs it directly.

"Edit" is never offered — this is the edit screen — and "View in a new tab" drops out once preview targets exist, matching the legacy rules.

Custom icons are qualified with their family. The action menu's icon renderer resolves a bare name against the default set, so `clone-dashed` was 404ing for the Copy item until its `custom-icons/` prefix was carried through.

Plugin-provided items still come through the HTML pairing and aren't rendered here yet; that contract is unchanged and needs its own decision.

Originally reviewed in #19403.
Adds the View control to the Inertia editor, linking each of the element's preview targets at the front end.

The legacy editor mints preview tokens from JavaScript, juggling a token queue and rewriting hrefs once a token is live. Every input to that is known server-side, so the links arrive ready to follow instead: a live element points at its own URL, and anything not publicly visible — a draft, a disabled entry, a future post date — points at `preview/create-token`, which mints the token and redirects to the tokenized URL. The controller grants the matching session authorization, as the legacy screen did.

`PreviewController::createToken()` read its params from the request body only. Craft 5 read them with `getParam()`, i.e. query or body, which is what the View links depend on — they're plain GET hrefs. It now reads either, taking only the token's own keys, since a control panel URL carries others (`site`) that `RouteToken` rejects outright.

Live Preview itself is not included. `Craft.Preview` is built around a `Craft.ElementEditor` instance — it reads its settings, serializes its form, and asks it for tokenized URLs — and none of that exists on this screen. Wiring it up means writing an adapter that impersonates the legacy editor, which is worth doing deliberately rather than as a side effect of this change.

Originally reviewed in #19404.
Adds collaborative awareness to the Inertia editor: who else is working on the element, and whether it has changed underneath the person editing it.

`useElementActivity` polls `elements/recent-activity` on the same fifteen-second cadence the legacy editor uses, rendering each active user's avatar beside the save controls with their activity message. When the element's or its canonical's last-modified stamp comes back different from what the screen rendered with, a notice offers to reload — naming the draft when the draft itself moved, and the element type otherwise.

Polling pauses while the tab is hidden and re-reads on return. The legacy editor keeps polling regardless, but a backgrounded tab has nobody to show avatars to, and the timestamps are compared fresh on the next visible poll either way. A failed poll is swallowed: this is ambient information nobody asked for, and the next tick may well succeed.

Revisions don't poll. They're read-only, so there is nothing to collaborate on and nothing to go stale.

Originally reviewed in #19405.
Two remaining pieces of the element editor.

Dismissible tips now dismiss. `CalloutNode` renders a dismiss control for tips marked dismissible and remembers them by layout element UID, under the same local storage key the legacy editor uses, so a tip dismissed on either editor stays dismissed on both. Storage failures are swallowed — dismissal is a convenience, not something worth interrupting anyone over. The payload already carried `dismissible`; nothing acted on it.

The sidebar's site statuses now also cover supported sites the element does not propagate to, switched off, so the element can be added to them. The legacy editor hides these behind an "Add a site…" select that builds fields on demand; listing them reaches the same outcome without the dynamic DOM work.

Note that entries don't currently produce non-propagating supported sites — a section site either propagates or isn't supported at all — so that path is dormant for them and matters only for element types whose `getSupportedSites()` marks a site as non-propagating. The accompanying test covers the ordinary multi-site case rather than claiming to exercise it.

Copying a field's value from another site is not included, and isn't a gap in this editor: Craft 5 renders a "Copy value from site…" item into each translatable field's action menu, and the Craft 6 port never carried it over, so the feature is unreachable on the legacy editor too. `elements/copy-values-from-site` still exists and still works. Restoring it means giving Form-rendered fields an action menu, which is a Form system decision rather than an element editor one.

Originally reviewed in #19406.
Ports the asset edit screen onto the shared Inertia element editor, replacing the `CpScreenResponse` the generic `EditElementController` produced for it. `assets/edit/{id}{slug}` now routes to `EditAssetController`, which renders `assets/Edit` from an `AssetEditViewModel`.

Assets have no drafts, revisions, or editable status, so most of the editor's machinery stays dormant here. What's left is the compiled field layout (title, alt text, custom fields), the Filename meta field, the file preview, and the asset's own actions. Saving posts to the generic `elements/save` — assets have no store action of their own — resolved from the identity attributes every element edit screen already submits.

`Asset::metaFieldsNodes()` renders Filename as a Form Control alongside the existing `metaFieldsHtml()`. Renaming validates the folder path and the filename together as `newLocation`, but the field posts `newFilename`, so the messages have to move to the name the Control answers to or they never reach the field that produced them. That remapping is a new `ElementInterface::formErrors()` hook, defaulting to the raw error bag; `ElementResponse::failure()` uses it for the Inertia branch only, since the legacy editor and the slideouts still apply errors by the validated attribute.

The asset's own actions — preview, download, show in folder, replace file, image editor, volume and filesystem settings — arrive as behavior descriptors rather than markup plus an inline script. The three that hand off to a legacy modal or uploader reload the screen afterwards rather than patching the file's details into the DOM, which is both shorter and more correct than the legacy JS that hand-edits the filename input, file size, dimensions, and thumbnail.

Two fixes to shared editor code, both surfaced by this screen because an in-place save with no redirect is the normal path for an element with no drafts:

- `useElementEditPage` captured `page.props` once. It's a computed, so the payload froze at whatever the screen first rendered with — the title, notices, and timestamps never updated after saving in place. Now read through `toReactive`.
- The activity poller compared against the timestamps it was constructed with, so a successful save reported our own write as someone else's change. It re-baselines on save.

`canAutosave` now also requires the element type to have drafts. Autosaving means writing a provisional draft; without one there's nothing to write to.

The file preview is still server-rendered HTML. Previewing and image editing both open legacy modals and the markup carries the JS that wires them up, so it arrives as a fragment rather than something the Vue side rebuilds.

Originally reviewed in #19420.
Ports the account Profile screen onto the shared Inertia element editor. Both `myaccount` and `users/{userId}` now render `users/Edit` from a `UserEditViewModel`, replacing two different things: the DOM-scraping `users/Profile` page for the current user, and the `EditElementController` `CpScreenResponse` for everyone else. `UserProfileViewModel` and `Profile.vue` are removed with them.

The account's native fields — username, full name, email, photo, affiliated site — are already native field-layout elements with Form Controls, so the compiled field layout covers the whole form. Saving posts to `users/save-user`, which the generic element save can't stand in for: it refuses the sensitive attributes a user account is mostly made of.

The screen list and the account navigation move out of `EditUserTrait` into an `EditUserScreens` service, since the ported screen and the sibling `CpScreenResponse` screens (Permissions, Preferences, Addresses, Password, Passkeys, Sign-in Providers) both need them. The trait keeps its behavior and delegates; the screen-name constants now live on the service.

`User::showStatusField()` returns false. `statusFieldHtml()` already rendered nothing — a user's status follows from the account actions, not an editable switch — but the Form path had no equivalent signal and would have rendered a Status lightswitch the legacy screen never showed.

The account actions (activate, suspend, unsuspend, unlock, deactivate, the password-reset emails and requirements, impersonation) arrive as behavior descriptors. Two need dispatch the client didn't have:

- `submit` takes `requireElevatedSession`, for signing in as another user.
- `copyUrl` fetches a single-use URL behind an elevated session and offers it for copying — the activation, password-reset, and impersonation URLs, which grant access to the account.

`redirectUrl` is encrypted, since the save controllers decrypt whatever `redirect` a request posts rather than trusting it. A brand-new account still lands on its permissions after being created.

Two of the tests this replaces were passing vacuously: `action([UsersController::class, 'edit'])` resolves to `myaccount`, which is registered first, so "for other users" was never actually rendering another user's screen, and the `assertDontSee('users/Profile')` it asserted would have passed regardless against the escaped page JSON. They now address the screen by URL.

Originally reviewed in #19421.
@github-actions

Copy link
Copy Markdown

📚 Storybook previews

@craftcms/uiopen Storybook

No changed components detected in this Storybook.

resources/jsopen Storybook

No changed components detected in this Storybook.

@brandonkelly
brandonkelly merged commit bee8149 into 6.x Aug 12, 2026
23 checks passed
@brandonkelly
brandonkelly deleted the feature/inertia-element-editor branch August 12, 2026 21:57
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants