Skip to content

[6.x] Let elements and plugins add buttons to the editor footer - #19921

Open
brianjhanson wants to merge 4 commits into
6.xfrom
cms-19910-additional-buttons
Open

brianjhanson wants to merge 4 commits into
6.xfrom
cms-19910-additional-buttons

Conversation

@brianjhanson

@brianjhanson brianjhanson commented Oct 9, 2026 •

Copy link
Copy Markdown
Contributor

The Inertia element editor had no way for element types or plugins to add a button that doesn't submit the form, such as a link, a download or a modal. Craft 5 supported this with getAdditionalButtons() and additionalButtonsHtml().

This PR adds a descriptor-based API for those buttons and moves the additional-buttons layout slot to the end of the content footer, where they render.

CleanShot 2026-10-09 at 11 38 35@2x

Descriptors replace the element-level HTML API, so that API now lives only in the Yii adapter. Core no longer declares getAdditionalButtons() or fires ElementAdditionalButtonsResolving. The adapter still renders Craft 5 HTML buttons in the same slot.

Where it lives
additionalButtonDescriptors() (public, fires the event) Core
defineAdditionalButtonDescriptors() (element-type hook) Core
ElementAdditionalButtonDescriptorsResolving (plugin event) Core
getAdditionalButtons() (HTML) and EVENT_DEFINE_ADDITIONAL_BUTTONS Yii adapter only
CpScreenResponse::additionalButtonsHtml() / additionalButtonsTemplate() Core. These are screen-level rather than element-level, and there's no non-HTML alternative for server-rendered screens yet

Usage (v6)

Each button is a descriptor with a label, an optional icon and variant (default outline), and a behavior. Behaviors are the same ones the element action menu dispatches, so the buttons work without submitting the editor's form. They render at the end of the editor's footer, on full pages and in slideouts.

Custom element types

Override defineAdditionalButtonDescriptors():

use CraftCms\Cms\Support\Url;

protected function defineAdditionalButtonDescriptors(): array
{
    return [
        [
            'label' => t('View report'),
            'icon' => 'chart-line',
            'behavior' => [
                'type' => 'link',
                'href' => $this->getReportUrl(),
                'newTab' => true,
            ],
        ],
        [
            'label' => t('Export'),
            'icon' => 'download',
            'behavior' => [
                'type' => 'download',
                'actionUrl' => Url::actionUrl('my-plugin/export'),
                'params' => ['elementId' => $this->id],
            ],
        ],
    ];
}

Plugins adding buttons to other element types

Listen for ElementAdditionalButtonDescriptorsResolving:

use CraftCms\Cms\Element\Events\ElementAdditionalButtonDescriptorsResolving;
use CraftCms\Cms\Entry\Elements\Entry;
use CraftCms\Cms\Support\Url;
use Illuminate\Support\Facades\Event;

Event::listen(function (ElementAdditionalButtonDescriptorsResolving $event) {
    if (! $event->element instanceof Entry || ! $event->element->id) {
        return;
    }

    $params = [
        'elementId' => $event->element->getCanonicalId(),
        'siteId' => $event->element->siteId,
    ];

    // Opens a server-built form in a modal, then reloads the editor.
    $event->items[] = [
        'label' => 'Add note',
        'icon' => 'note-sticky',
        'variant' => 'secondary',
        'behavior' => [
            'type' => 'formModal',
            'modalUrl' => Url::actionUrl('my-plugin/notes/modal'),
            'actionUrl' => Url::actionUrl('my-plugin/notes/save'),
            'params' => $params,
        ],
    ];

    // Opens another CP screen in a slideout.
    $event->items[] = [
        'label' => 'Author',
        'icon' => 'user',
        'behavior' => [
            'type' => 'slideout',
            'url' => $event->element->getAuthor()?->getCpEditUrl(),
        ],
    ];
});

formModal endpoints work like this:

  • modalUrl is fetched with GET and params. It returns {ui, title?, submitLabel?}, where ui is a resolved UiPayload.
  • actionUrl receives a POST of the form values merged with params. It returns {message?}, or a 422 with errors keyed by field path.

The full list of behaviors is link, download, submit, slideout, formModal and copyUrl, plus the element-specific ones the action menu uses. See ElementActionBehavior in useElementActionMenu.ts.

Other screens

  • Your own Vue (Inertia) page: fill the slot with the global layout slot component.

    <script setup>
      const LayoutSlot = resolveComponent('craft:layout-slot');
    </script>
    
    <template>
      <LayoutSlot name="additional-buttons">
        <craft-button type="button" @click="sync">Sync now</craft-button>
      </LayoutSlot>
    </template>
  • Your own server-rendered screen (a CpScreenResponse without an inertiaPage()): $response->additionalButtonsHtml(...) or additionalButtonsTemplate(...) still work, and now render in the slot on full pages.

  • Someone else's screen that isn't an element editor, e.g. a core settings page: there's no API for this, as in Craft 5. A LayoutSlot on a page the plugin doesn't own renders nothing. A screen-level descriptor event would be a natural follow-up.

Layout slots for Vue pages

  • additional-buttons now sits at the end of the content footer, after a spacer.
  • The new footer-meta slot sits right after the save controls. The element editor puts its autosave status there.
  • Slideouts now render the same ContentFooter as full pages, with the same slots, including content-footer. Their Cancel/Close button sits at the far right, after additional-buttons.
[Save ▾] [footer-meta] ·········· [additional-buttons] [Cancel (slideouts)]

What happens to plugins using the Craft 5 API

With the Yii adapter installed, the Craft 5 APIs keep working and render into the same additional-buttons slot:

  • Overriding getAdditionalButtons(): string on an element class: the adapter adds getAdditionalButtons() to every element, with the Craft 5 signature, so overrides (including ones calling parent::) keep working. The returned HTML is rendered in the editor's footer.
  • Element::EVENT_DEFINE_ADDITIONAL_BUTTONS handlers: the adapter's getAdditionalButtons() fires the event directly, including for handlers registered under Craft 5 class names like craft\elements\Entry, so their HTML is rendered too.
  • $response->additionalButtonsHtml() / additionalButtonsTemplate() called from prepareEditScreen(): rendered alongside the above.
  • JavaScript registered while building that HTML is included with the editor's legacy assets. It only runs once the button markup is in the DOM, so handlers that bind to the buttons by ID or selector still find them.
  • A deprecation warning is logged whenever any of these produce HTML. It points to defineAdditionalButtonDescriptors() and ElementAdditionalButtonDescriptorsResolving.

Behavior changes to be aware of:

  • Position: Craft 5 showed these buttons at the top of the edit page, beside Save. They now render at the end of the footer.
  • Slideouts: Craft 5 only showed additional buttons on full pages. Element editor slideouts now show them too.
  • Without the Yii adapter: core doesn't call getAdditionalButtons() or fire EVENT_DEFINE_ADDITIONAL_BUTTONS, so that HTML isn't rendered. Port to descriptors.
  • Bridged cp/Screen pages (a CpScreenResponse without an inertiaPage()): additionalButtonsHtml() used to be sent to the page but never rendered. It now renders in additional-buttons on full pages. Slideouts don't receive it, as in Craft 5.

Breaking changes for 6.x code

  • Removed CraftCms\Cms\Element\Contracts\ElementInterface::getAdditionalButtons() and its HasControlPanelUI implementation.
  • Removed CraftCms\Cms\Element\Events\ElementAdditionalButtonsResolving.
  • Removed EditElementController::additionalButtons(). It built the legacy header buttons (preview, Create a draft, Apply draft, revert, and the element's own HTML) for the non-Inertia branch, but that branch only answers JSON slideout requests, whose payload never included them. The Inertia editor builds these from editorActions, previewTargets and the new descriptors.

v6 code calling $element->getAdditionalButtons() or listening for ElementAdditionalButtonsResolving should use additionalButtonDescriptors(), defineAdditionalButtonDescriptors() and ElementAdditionalButtonDescriptorsResolving instead. These were added during the 6.x alphas.

Testing

  • tests/Feature/Element/ElementAdditionalButtonsTest.php covers the element-type hook, the event, and that the buttons are sent as additionalButtons, apart from editorActions.buttons.
  • yii2-adapter/tests-laravel/Bridge/ElementEditorCompatibilityTest.php covers:
    • rendering the v5 getAdditionalButtons() override, the EVENT_DEFINE_ADDITIONAL_BUTTONS handler and additionalButtonsHtml(), including their JS;
    • firing handlers registered under Craft 5 class names (craft\elements\Entry);
    • sending nothing when no buttons are defined.
  • The full adapter Laravel suite, core element and controller suites, and full PHPStan pass.
  • resources/js/common/layouts/screens/page/ContentFooter.test.ts is a regression test: it fills, empties and refills a footer slot.

Fixes #19910.

brianjhanson and others added 2 commits October 9, 2026 11:31
Element types override defineAdditionalButtonDescriptors() and plugins
listen for ElementAdditionalButtonDescriptorsResolving to add footer
buttons. These buttons use the action menu's behavior descriptors, so they
can open links, downloads, modals and slideouts without submitting the form.

The additional-buttons layout slot now sits at the end of the content
footer, and the new footer-meta slot beside the save controls holds the
autosave status. Slideouts now render the same ContentFooter as full pages.

The Yii adapter renders Craft 5 additional buttons HTML from
getAdditionalButtons(), EVENT_DEFINE_ADDITIONAL_BUTTONS and
additionalButtonsHtml() into the same slot. Bridged cp/Screen pages now
render additionalButtonsHtml(), which was previously dropped.

Fixes #19910

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
@github-actions

github-actions Bot commented Oct 9, 2026 •

Copy link
Copy Markdown

📚 Storybook previews

@craftcms/ui — open Storybook

No changed components detected in this Storybook.

resources/js — open Storybook

No changed components detected in this Storybook.

@brianjhanson
brianjhanson marked this pull request as ready for review October 9, 2026 16:39
@brianjhanson
brianjhanson requested a review from riasvdv October 9, 2026 16:39
@riasvdv

riasvdv commented Oct 9, 2026

Copy link
Copy Markdown
Contributor

We should probably remove the old methods from src/ and only have them in the adapter if we have alternatives for them

Descriptors replace the HTML API, so core no longer declares
getAdditionalButtons() or fires ElementAdditionalButtonsResolving. The
only core caller built HTML that the legacy JSON slideout response
never sent.

The adapter now adds getAdditionalButtons() to elements and fires
EVENT_DEFINE_ADDITIONAL_BUTTONS directly, so Craft 5 overrides and
handlers still render in the Inertia editor.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
@brianjhanson

Copy link
Copy Markdown
Contributor Author

@riasvdv removed in bd8b246

@riasvdv riasvdv left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

We should remove any other HTML methods that currently have an alternative in either descriptors or maybe as a Form Node/Control, but that can be done in a follow up PR

…uttons

# Conflicts:
#	resources/js/modules/elements/composables/useElementActionMenu.ts
@github-actions

Copy link
Copy Markdown

This PR had conflicts with 6.x. Claude attempted an automatic resolution and pushed a merge commit. Please review the merge commit carefully before approving/merging.

Claude's response

I resolved the merge conflict and committed the merge as d1303e3. It has not been pushed.

Conflict found: only one file conflicted, resources/js/modules/elements/composables/useElementActionMenu.ts.

  • This branch split the menu logic in two: createElementActionDispatcher, which runs a menu item's action, and createElementActionMenu, which builds the menu items around it.
  • 6.x (in 6bd48e6) moved the inline submitNativeForm and replaceFile helpers out to shared modules, @/common/utils/forms and @/modules/assets/replaceAssetFile.

How I resolved it: I kept this branch's split and removed the two inline helpers, as 6.x did. Git had already merged 6.x's new imports and its replaceAssetFile(behavior.assetId, reload) call cleanly. Both sides' changes are kept.

No part of the resolution was ambiguous, so I didn't add any CONFLICT-REVIEW comments. A TypeScript type check printed no errors for this file, but I didn't run the test suites.

This branch has not been deployed

No deployments
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