From 46f96350a808c208aeb5c117c3a37844ad7f6f71 Mon Sep 17 00:00:00 2001 From: Brian Hanson Date: Mon, 9 Nov 2026 16:06:02 -0600 Subject: [PATCH] Move legacy element action triggers to the Yii2 adapter Core element actions no longer write JavaScript for the legacy jQuery element index. getTriggerHtml() and getTriggerId() are removed from ElementActionInterface and ElementAction, along with the trigger JS in Delete, DeleteAssets, Duplicate, DeleteForSite, Restore, SetStatus, SuspendUsers, and UnsuspendUsers. The adapter's LegacyElementActions adds triggers back to the action configs the legacy index receives. Actions that define getTriggerHtml(), such as Craft 5 plugin actions, still supply their own. The built-in actions get a legacyTrigger descriptor, which the legacy index turns into a Craft.ElementActionTrigger using handlers in action-triggers.js. craft\base\ElementAction keeps both methods for Craft 5 plugins. Co-Authored-By: Claude Opus 5.5 --- CHANGELOG-WIP.md | 1 + CHANGELOG.md | 1 + .../src/element-index/BaseElementIndex.js | 3 + .../src/element-index/action-triggers.js | 168 ++++++++++++++++++ .../modules/element-action-trigger/compat.ts | 3 +- .../element-action-trigger.ts | 4 +- src/Asset/Actions/DeleteAssets.php | 77 +------- src/Element/Actions/Delete.php | 71 -------- src/Element/Actions/DeleteForSite.php | 24 --- src/Element/Actions/Duplicate.php | 33 ---- src/Element/Actions/ElementAction.php | 10 -- src/Element/Actions/Restore.php | 26 --- src/Element/Actions/SetStatus.php | 24 --- .../Contracts/ElementActionInterface.php | 13 -- src/Element/ElementHelper.php | 2 - src/User/Actions/SuspendUsers.php | 32 ---- src/User/Actions/UnsuspendUsers.php | 30 ---- yii2-adapter/legacy/base/ElementAction.php | 19 ++ yii2-adapter/legacy/helpers/ElementHelper.php | 3 +- .../elementactions/SetStatus/trigger.twig | 0 .../src/Element/LegacyElementActions.php | 141 +++++++++++++++ .../src/Mixins/NestedElementManagerMixin.php | 4 +- yii2-adapter/src/Yii2ServiceProvider.php | 3 + .../Element/LegacyElementActionsTest.php | 124 +++++++++++++ 24 files changed, 468 insertions(+), 348 deletions(-) create mode 100644 packages/craftcms-legacy/cpcompat/src/element-index/action-triggers.js rename {resources => yii2-adapter/resources}/templates/_components/elementactions/SetStatus/trigger.twig (100%) create mode 100644 yii2-adapter/src/Element/LegacyElementActions.php create mode 100644 yii2-adapter/tests-laravel/Element/LegacyElementActionsTest.php diff --git a/CHANGELOG-WIP.md b/CHANGELOG-WIP.md index d6108b92fb9..596d5397ea1 100644 --- a/CHANGELOG-WIP.md +++ b/CHANGELOG-WIP.md @@ -1001,6 +1001,7 @@ Craft 6 now uses [Laravel's authorization system](https://laravel.com/docs/12.x/ - Deprecated `craft\base\ElementAction`, `craft\base\ElementActionInterface`, `craft\elements\actions\DeleteActionInterface`, and the legacy `craft\elements\actions\*` classes. The corresponding `CraftCms\Cms\Element\Actions\*`, `CraftCms\Cms\Asset\Actions\*`, and `CraftCms\Cms\User\Actions\*` classes should be used instead. - Deprecated `craft\base\ElementExporter`, `craft\base\ElementExporterInterface`, and the legacy `craft\elements\exporters\*` classes. The corresponding `CraftCms\Cms\Element\Exporters\*` classes should be used instead. - Removed the element action classes that only had client-side behavior, including the legacy `craft\elements\actions\Copy`, `Edit`, `View`, `PreviewAsset`, `CopyUrl`, `CopyReferenceTag`, `DownloadAssetFile`, `EditImage`, `MoveAssets`, `RenameFile`, `ReplaceFile`, `ShowInFolder`, `MoveToSection`, `NewChild`, `NewSiblingBefore`, and `NewSiblingAfter` classes. `CraftCms\Cms\Element\Actions\ClientAction` objects should be used instead. ([#20272](https://github.com/craftcms/cms/pull/20272)) +- Removed `getTriggerHtml()` and `getTriggerId()` from `CraftCms\Cms\Element\Contracts\ElementActionInterface` and `CraftCms\Cms\Element\Actions\ElementAction`. The legacy element index’s triggers for the built-in element actions are now provided by the Yii2 adapter, and `craft\base\ElementAction` still has both methods, for element actions that render their own trigger. ##### Validation Craft 6 introduces a new validation system that uses Laravel's Validator instead of Yii2's model validation. diff --git a/CHANGELOG.md b/CHANGELOG.md index d4d28bd0daf..b33122f807b 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,7 @@ ## Unreleased +- Removed `getTriggerHtml()` and `getTriggerId()` from `CraftCms\Cms\Element\Contracts\ElementActionInterface` and `CraftCms\Cms\Element\Actions\ElementAction`. The legacy element index’s triggers for the built-in element actions are now provided by the Yii2 adapter, and `craft\base\ElementAction` still has both methods, for element actions that render their own trigger. - Fixed an error that occurred when changing a password from the **My Account** → **Password** screen. ([#20585](https://github.com/craftcms/cms/pull/20585)) - Fixed a bug where asset indexing included the default asset transformer’s volume directories. ([#20589](https://github.com/craftcms/cms/pull/20589)) diff --git a/packages/craftcms-legacy/cpcompat/src/element-index/BaseElementIndex.js b/packages/craftcms-legacy/cpcompat/src/element-index/BaseElementIndex.js index 35659377240..07457951295 100644 --- a/packages/craftcms-legacy/cpcompat/src/element-index/BaseElementIndex.js +++ b/packages/craftcms-legacy/cpcompat/src/element-index/BaseElementIndex.js @@ -3,6 +3,8 @@ /** global: $ */ /** global: jQuery */ +import {createActionTriggers} from './action-triggers'; + /** * Element index class */ @@ -3850,6 +3852,7 @@ Craft.BaseElementIndex = Garnish.Base.extend( await Craft.appendHeadHtml(this.actionsHeadHtml); await Craft.appendBodyHtml(this.actionsBodyHtml); + createActionTriggers(this.actions); Craft.initUiElements(this._$triggers); diff --git a/packages/craftcms-legacy/cpcompat/src/element-index/action-triggers.js b/packages/craftcms-legacy/cpcompat/src/element-index/action-triggers.js new file mode 100644 index 00000000000..f4cf1235b14 --- /dev/null +++ b/packages/craftcms-legacy/cpcompat/src/element-index/action-triggers.js @@ -0,0 +1,168 @@ +/** global: Craft */ +/** global: Garnish */ + +/** + * Trigger settings for the built-in element actions, keyed by the + * `legacyTrigger.handler` the server sends with each action's config. + * Each factory receives the rest of `legacyTrigger`. + */ +const handlers = { + delete: ({withDescendants, hardDelete, confirmationMessage}) => ({ + validateSelection: (selectedItems, elementIndex) => + everyElement(selectedItems, (element) => + Garnish.hasAttr(element, 'data-deletable') + ) && elementIndex.settings.canDeleteElements(selectedItems), + activate: (selectedItems, elementIndex) => + deleteElements(selectedItems, elementIndex, { + withDescendants, + hardDelete, + confirmationMessage, + }), + }), + + deleteAssets: ({hardDelete, confirmationMessage}) => ({ + requireId: false, + validateSelection: (selectedItems, elementIndex) => + everyElement(selectedItems, (element) => { + if (!Garnish.hasAttr(element, 'data-is-folder')) { + return Garnish.hasAttr(element, 'data-deletable'); + } + + // Only one folder at a time + if (selectedItems.length !== 1) { + return false; + } + + const sourcePath = element.data('source-path') || []; + return ( + sourcePath.length !== 0 && + !!sourcePath[sourcePath.length - 1].canDelete + ); + }) && elementIndex.settings.canDeleteElements(selectedItems), + activate: async (selectedItems, elementIndex) => { + const element = selectedItems.find('.element:first'); + + if (Garnish.hasAttr(element, 'data-is-folder')) { + const sourcePath = element.data('source-path'); + await elementIndex.deleteFolder(sourcePath[sourcePath.length - 1]); + elementIndex.updateElements(); + return; + } + + await deleteElements(selectedItems, elementIndex, { + hardDelete, + confirmationMessage, + }); + }, + }), + + duplicate: ({attribute}) => ({ + validateSelection: (selectedItems, elementIndex) => + everyElement(selectedItems, (element) => + Garnish.hasAttr(element, attribute) + ) && elementIndex.settings.canDuplicateElements(selectedItems), + beforeActivate: async (selectedItems, elementIndex) => { + await elementIndex.onBeforeDuplicateElements(selectedItems); + }, + afterActivate: async (selectedItems, elementIndex) => { + await elementIndex.onDuplicateElements(selectedItems); + }, + }), + + deleteForSite: () => ({ + validateSelection: (selectedItems, elementIndex) => + everyElement(selectedItems, (element) => + Garnish.hasAttr(element, 'data-deletable-for-site') + ) && elementIndex.settings.canDeleteElements(selectedItems), + }), + + restore: ({attribute}) => ({ + validateSelection: (selectedItems) => + everyElement(selectedItems, (element) => + Garnish.hasAttr(element, attribute) + ), + }), + + setStatus: () => ({ + validateSelection: (selectedItems) => + everyElement( + selectedItems, + (element) => + Garnish.hasAttr(element, 'data-savable') && + !Garnish.hasAttr(element, 'data-disallow-status') + ), + }), + + suspendUsers: ({currentUserId}) => ({ + bulk: true, + validateSelection: (selectedItems) => + everyElement( + selectedItems, + (element) => + Garnish.hasAttr(element, 'data-can-suspend') && + !Garnish.hasAttr(element, 'data-suspended') && + element.data('id') != currentUserId + ), + }), + + unsuspendUsers: () => ({ + bulk: true, + validateSelection: (selectedItems) => + everyElement( + selectedItems, + (element) => + Garnish.hasAttr(element, 'data-can-suspend') && + Garnish.hasAttr(element, 'data-suspended') + ), + }), +}; + +function everyElement(selectedItems, test) { + for (let i = 0; i < selectedItems.length; i++) { + if (!test(selectedItems.eq(i).find('.element'))) { + return false; + } + } + + return true; +} + +async function deleteElements(selectedItems, elementIndex, settings) { + await elementIndex.onBeforeDeleteElements(selectedItems); + elementIndex.setIndexBusy(); + const elementIds = elementIndex.getSelectedElementIds(); + + new Craft.ElementDeletionManager(elementIndex.elementType, elementIds, { + siteId: elementIndex.siteId, + ownerId: elementIndex.settings.criteria?.ownerId, + ...settings, + onLoadBlockers: () => { + elementIndex.setIndexAvailable(); + }, + onSuccess: async () => { + elementIndex.updateElements(true, true); + await elementIndex.onDeleteElements(selectedItems); + }, + }); +} + +/** + * Creates the triggers for the actions that came with a built-in trigger. + * + * @param {Object[]} actions + */ +export function createActionTriggers(actions) { + for (const action of actions) { + const {handler, ...params} = action.legacyTrigger ?? {}; + + if (!handlers[handler]) { + continue; + } + + new Craft.ElementActionTrigger({ + type: action.type, + triggerId: action.triggerId, + ...handlers[handler](params), + }); + } +} diff --git a/resources/js/modules/element-action-trigger/compat.ts b/resources/js/modules/element-action-trigger/compat.ts index 0d18ad93ada..cb701866549 100644 --- a/resources/js/modules/element-action-trigger/compat.ts +++ b/resources/js/modules/element-action-trigger/compat.ts @@ -1,6 +1,5 @@ import {ElementActionTrigger} from './element-action-trigger'; import {registerCraftGlobals} from '@/common/craft-global'; -// `new Craft.ElementActionTrigger(...)` is emitted by PHP element actions and -// created by the (still-legacy) element index. Plain modern ES class. +// Created by the legacy element index, and by plugin actions' trigger JS. registerCraftGlobals({ElementActionTrigger}); diff --git a/resources/js/modules/element-action-trigger/element-action-trigger.ts b/resources/js/modules/element-action-trigger/element-action-trigger.ts index febd6004581..59c24f8b025 100644 --- a/resources/js/modules/element-action-trigger/element-action-trigger.ts +++ b/resources/js/modules/element-action-trigger/element-action-trigger.ts @@ -10,8 +10,8 @@ declare const Craft: any; * ElementActionTrigger — a port of `Craft.ElementActionTrigger` onto * `@craftcms/garnish` `Base`. Wires an element-index action button: it * enables/disables based on the current selection and runs the action's - * `activate` handler. Booted by PHP element actions (e.g. ShowInFolder) and by - * the (still-legacy) element index, so exposed on `window.Craft`. + * `activate` handler. Created by the legacy element index and by plugin + * actions' trigger JS, so exposed on `window.Craft`. */ export class ElementActionTrigger extends Base { static defaults: any = { diff --git a/src/Asset/Actions/DeleteAssets.php b/src/Asset/Actions/DeleteAssets.php index b4a61854b9c..3c953cc111e 100644 --- a/src/Asset/Actions/DeleteAssets.php +++ b/src/Asset/Actions/DeleteAssets.php @@ -5,83 +5,8 @@ namespace CraftCms\Cms\Asset\Actions; use CraftCms\Cms\Element\Actions\Delete; -use CraftCms\Cms\Support\Facades\HtmlStack; /** * @since 6.0.0 */ -class DeleteAssets extends Delete -{ - #[\Override] - public function getTriggerHtml(): ?string - { - // Only enable for deletable elements, per canDelete() - HtmlStack::jsWithVars(fn ( - $triggerId, - $elementType, - $hardDelete, - $confirmationMessage, - ) => << { - new Craft.ElementActionTrigger({ - triggerId: $triggerId, - requireId: false, - validateSelection: (selectedItems, elementIndex) => { - for (let i = 0; i < selectedItems.length; i++) { - const element = selectedItems.eq(i).find('.element'); - if (Garnish.hasAttr(element, 'data-is-folder')) { - if (selectedItems.length !== 1) { - // only one folder at a time - return false; - } - const sourcePath = element.data('source-path') || []; - if (!sourcePath.length || !sourcePath[sourcePath.length - 1].canDelete) { - return false; - } - } else { - if (!Garnish.hasAttr(element, 'data-deletable')) { - return false; - } - } - } - - return elementIndex.settings.canDeleteElements(selectedItems); - }, - activate: async (selectedItems, elementIndex) => { - const element = selectedItems.find('.element:first'); - if (Garnish.hasAttr(element, 'data-is-folder')) { - const sourcePath = element.data('source-path'); - await elementIndex.deleteFolder(sourcePath[sourcePath.length - 1]); - elementIndex.updateElements(); - } else { - await elementIndex.onBeforeDeleteElements(selectedItems); - elementIndex.setIndexBusy(); - const elementIds = elementIndex.getSelectedElementIds(); - - new Craft.ElementDeletionManager($elementType, elementIds, { - siteId: elementIndex.siteId, - ownerId: elementIndex.settings.criteria?.ownerId, - hardDelete: $hardDelete, - confirmationMessage: $confirmationMessage, - onLoadBlockers: () => { - elementIndex.setIndexAvailable(); - }, - onSuccess: async () => { - elementIndex.updateElements(true, true); - await elementIndex.onDeleteElements(selectedItems); - }, - }); - } - }, - }); -})(); -JS, [ - $this->getTriggerId(), - $this->elementType, - $this->hard, - $this->confirmationMessage, - ]); - - return null; - } -} +class DeleteAssets extends Delete {} diff --git a/src/Element/Actions/Delete.php b/src/Element/Actions/Delete.php index 8af6c27520d..57d40c7dbd1 100644 --- a/src/Element/Actions/Delete.php +++ b/src/Element/Actions/Delete.php @@ -11,7 +11,6 @@ use CraftCms\Cms\Element\Queries\Contracts\ElementQueryInterface; use CraftCms\Cms\Support\Facades\Drafts; use CraftCms\Cms\Support\Facades\Elements; -use CraftCms\Cms\Support\Facades\HtmlStack; use CraftCms\Cms\Support\Facades\Workflows; use Illuminate\Support\Facades\DB; use Illuminate\Support\Facades\Gate; @@ -32,21 +31,6 @@ class Delete extends ElementAction implements DeleteActionInterface public ?string $successMessage = null; - private readonly string $triggerId; - - public function __construct(object|array $config = []) - { - parent::__construct($config); - - $this->triggerId = sprintf('action-trigger-%s', mt_rand()); - } - - #[Override] - public function getTriggerId(): string - { - return $this->triggerId; - } - public function canHardDelete(): bool { return ! $this->withDescendants; @@ -57,61 +41,6 @@ public function setHardDelete(): void $this->hard = true; } - public function getTriggerHtml(): ?string - { - // Only enable for deletable elements, per canDelete() - HtmlStack::jsWithVars(fn ( - $triggerId, - $elementType, - $withDescendants, - $hardDelete, - $confirmationMessage, - ) => << { - new Craft.ElementActionTrigger({ - triggerId: $triggerId, - validateSelection: (selectedItems, elementIndex) => { - for (let i = 0; i < selectedItems.length; i++) { - if (!Garnish.hasAttr(selectedItems.eq(i).find('.element'), 'data-deletable')) { - return false; - } - } - - return elementIndex.settings.canDeleteElements(selectedItems); - }, - activate: async (selectedItems, elementIndex) => { - await elementIndex.onBeforeDeleteElements(selectedItems); - elementIndex.setIndexBusy(); - const elementIds = elementIndex.getSelectedElementIds(); - - new Craft.ElementDeletionManager($elementType, elementIds, { - siteId: elementIndex.siteId, - ownerId: elementIndex.settings.criteria?.ownerId, - withDescendants: $withDescendants, - hardDelete: $hardDelete, - confirmationMessage: $confirmationMessage, - onLoadBlockers: () => { - elementIndex.setIndexAvailable(); - }, - onSuccess: async () => { - elementIndex.updateElements(true, true); - await elementIndex.onDeleteElements(selectedItems); - }, - }) - }, - }) -})(); -JS, [ - $this->getTriggerId(), - $this->elementType, - $this->withDescendants, - $this->hard, - $this->confirmationMessage, - ]); - - return null; - } - #[Override] public function getTriggerLabel(): string { diff --git a/src/Element/Actions/DeleteForSite.php b/src/Element/Actions/DeleteForSite.php index 0528318f1a8..453d51aacad 100644 --- a/src/Element/Actions/DeleteForSite.php +++ b/src/Element/Actions/DeleteForSite.php @@ -8,7 +8,6 @@ use CraftCms\Cms\Element\ElementHelper; use CraftCms\Cms\Element\Queries\Contracts\ElementQueryInterface; use CraftCms\Cms\Support\Facades\Elements; -use CraftCms\Cms\Support\Facades\HtmlStack; use Illuminate\Support\Facades\Gate; use function CraftCms\Cms\t; @@ -28,29 +27,6 @@ class DeleteForSite extends ElementAction */ public ?string $successMessage = null; - public function getTriggerHtml(): ?string - { - // Only enable for deletable elements, per canDelete() - HtmlStack::jsWithVars(fn ($type) => << { - new Craft.ElementActionTrigger({ - type: $type, - validateSelection: (selectedItems, elementIndex) => { - for (let i = 0; i < selectedItems.length; i++) { - if (!Garnish.hasAttr(selectedItems.eq(i).find('.element'), 'data-deletable-for-site')) { - return false; - } - } - - return elementIndex.settings.canDeleteElements(selectedItems); - }, - }) -})(); -JS, [static::class]); - - return null; - } - #[\Override] public function getTriggerLabel(): string { diff --git a/src/Element/Actions/Duplicate.php b/src/Element/Actions/Duplicate.php index c44597c7e6b..c6889a7efd4 100644 --- a/src/Element/Actions/Duplicate.php +++ b/src/Element/Actions/Duplicate.php @@ -8,7 +8,6 @@ use CraftCms\Cms\Element\Contracts\NestedElementInterface; use CraftCms\Cms\Element\Queries\Contracts\ElementQueryInterface; use CraftCms\Cms\Support\Facades\Elements; -use CraftCms\Cms\Support\Facades\HtmlStack; use CraftCms\Cms\Support\Facades\Structures; use Illuminate\Support\Facades\Gate; use Throwable; @@ -39,38 +38,6 @@ public function getTriggerLabel(): string : t('Duplicate'); } - public function getTriggerHtml(): ?string - { - // Only enable for duplicatable elements, per canDuplicate() - HtmlStack::jsWithVars(fn ($type, $attr) => << { - new Craft.ElementActionTrigger({ - type: $type, - validateSelection: (selectedItems, elementIndex) => { - for (let i = 0; i < selectedItems.length; i++) { - if (!Garnish.hasAttr(selectedItems.eq(i).find('.element'), $attr)) { - return false; - } - } - - return elementIndex.settings.canDuplicateElements(selectedItems); - }, - beforeActivate: async (selectedItems, elementIndex) => { - await elementIndex.onBeforeDuplicateElements(selectedItems); - }, - afterActivate: async (selectedItems, elementIndex) => { - await elementIndex.onDuplicateElements(selectedItems); - }, - }) -})(); -JS, [ - static::class, - $this->asDrafts ? 'data-duplicatable-as-draft' : 'data-duplicatable', - ]); - - return null; - } - /** * Duplicating with descendants skips elements already duplicated as a * descendant of another selected element, which a chunk can't know about. diff --git a/src/Element/Actions/ElementAction.php b/src/Element/Actions/ElementAction.php index aa636fdb241..1f9f1eb3411 100644 --- a/src/Element/Actions/ElementAction.php +++ b/src/Element/Actions/ElementAction.php @@ -56,21 +56,11 @@ public function setElementType(string $elementType): void $this->elementType = $elementType; } - public function getTriggerId(): string - { - return sprintf('%s-actiontrigger', static::class); - } - public function getTriggerLabel(): string { return static::displayName(); } - public function getTriggerHtml(): ?string - { - return null; - } - public function getConfirmationMessage(): ?string { return null; diff --git a/src/Element/Actions/Restore.php b/src/Element/Actions/Restore.php index a34cf0f847c..ce876427293 100644 --- a/src/Element/Actions/Restore.php +++ b/src/Element/Actions/Restore.php @@ -6,7 +6,6 @@ use CraftCms\Cms\Element\Queries\Contracts\ElementQueryInterface; use CraftCms\Cms\Support\Facades\Elements; -use CraftCms\Cms\Support\Facades\HtmlStack; use Illuminate\Support\Facades\Gate; use function CraftCms\Cms\t; @@ -48,31 +47,6 @@ public function getTriggerLabel(): string return t('Restore'); } - public function getTriggerHtml(): ?string - { - // Only enable for restorable/savable elements - HtmlStack::jsWithVars(fn ($type, $attribute) => << { - new Craft.ElementActionTrigger({ - type: $type, - validateSelection: (selectedItems, elementIndex) => { - for (let i = 0; i < selectedItems.length; i++) { - if (!Garnish.hasAttr(selectedItems.eq(i).find('.element'), $attribute)) { - return false; - } - } - return true; - }, - }) -})(); -JS, [ - static::class, - $this->restorableElementsOnly ? 'data-restorable' : 'data-savable', - ]); - - return '
'.$this->getTriggerLabel().'
'; - } - #[\Override] public function performAction(ElementQueryInterface $query): bool { diff --git a/src/Element/Actions/SetStatus.php b/src/Element/Actions/SetStatus.php index ab71f96dc35..bfab12d5375 100644 --- a/src/Element/Actions/SetStatus.php +++ b/src/Element/Actions/SetStatus.php @@ -9,13 +9,11 @@ use CraftCms\Cms\Element\Queries\Contracts\ElementQueryInterface; use CraftCms\Cms\Element\Validation\ElementRules; use CraftCms\Cms\Support\Facades\Elements; -use CraftCms\Cms\Support\Facades\HtmlStack; use CraftCms\Cms\Support\Facades\Sites; use Illuminate\Support\Facades\Gate; use Illuminate\Validation\Rule; use function CraftCms\Cms\t; -use function CraftCms\Cms\template; /** * @since 6.0.0 @@ -45,28 +43,6 @@ public function getRules(): array ]; } - public function getTriggerHtml(): ?string - { - HtmlStack::jsWithVars(fn ($type) => << { - new Craft.ElementActionTrigger({ - type: $type, - validateSelection: (selectedItems, elementIndex) => { - for (let i = 0; i < selectedItems.length; i++) { - const element = selectedItems.eq(i).find('.element'); - if (!Garnish.hasAttr(element, 'data-savable') || Garnish.hasAttr(element, 'data-disallow-status')) { - return false; - } - } - return true; - }, - }) -})(); -JS, [static::class]); - - return template('_components/elementactions/SetStatus/trigger'); - } - #[\Override] public function performAction(ElementQueryInterface $query): bool { diff --git a/src/Element/Contracts/ElementActionInterface.php b/src/Element/Contracts/ElementActionInterface.php index 05d4b995019..114fde0c71b 100644 --- a/src/Element/Contracts/ElementActionInterface.php +++ b/src/Element/Contracts/ElementActionInterface.php @@ -41,21 +41,8 @@ public function supportsQueue(): bool; */ public function setElementType(string $elementType): void; - /** - * Returns the ID the trigger element should have. - * - * This should be overridden with something unique if the same action can be - * included multiple times for the same elements. - * - * If this is overridden, ensure you configure the `Craft.ElementActionTrigger` - * JavaScript object with a `triggerId` setting, set to the same value. - */ - public function getTriggerId(): string; - public function getTriggerLabel(): string; - public function getTriggerHtml(): ?string; - public function getConfirmationMessage(): ?string; public function performAction(ElementQueryInterface $query): bool; diff --git a/src/Element/ElementHelper.php b/src/Element/ElementHelper.php index e04d9dda03c..db4fad94ffe 100644 --- a/src/Element/ElementHelper.php +++ b/src/Element/ElementHelper.php @@ -731,8 +731,6 @@ public static function actionConfig(ElementActionInterface $action): array 'destructive' => $action->isDestructive(), 'download' => $action->isDownload(), 'name' => $action->getTriggerLabel(), - 'trigger' => $action->getTriggerHtml(), - 'triggerId' => $action->getTriggerId(), 'confirm' => $action->getConfirmationMessage(), 'settings' => $action->getSettings() ?: null, ]; diff --git a/src/User/Actions/SuspendUsers.php b/src/User/Actions/SuspendUsers.php index ce527bc2aee..b479c6e7575 100644 --- a/src/User/Actions/SuspendUsers.php +++ b/src/User/Actions/SuspendUsers.php @@ -7,7 +7,6 @@ use CraftCms\Cms\Element\Actions\ElementAction; use CraftCms\Cms\Element\Queries\Contracts\ElementQueryInterface; use CraftCms\Cms\Element\Queries\UserQuery; -use CraftCms\Cms\Support\Facades\HtmlStack; use CraftCms\Cms\Support\Facades\Users; use CraftCms\Cms\User\Contracts\CraftUser; use CraftCms\Cms\User\Elements\User; @@ -28,37 +27,6 @@ public function getTriggerLabel(): string return t('Suspend'); } - public function getTriggerHtml(): ?string - { - HtmlStack::jsWithVars(fn ($type, $userId) => << { - new Craft.ElementActionTrigger({ - type: $type, - bulk: true, - validateSelection: (selectedItems, elementIndex) => { - for (let i = 0; i < selectedItems.length; i++) { - const \$element = selectedItems.eq(i).find('.element'); - if ( - !Garnish.hasAttr(\$element, 'data-can-suspend') || - Garnish.hasAttr(\$element, 'data-suspended') || - \$element.data('id') == $userId - ) { - return false; - } - } - - return true; - } - }) -})(); -JS, [ - static::class, - currentUser()?->getCraftUserId(), - ]); - - return null; - } - #[Override] public function performAction(ElementQueryInterface $query): bool { diff --git a/src/User/Actions/UnsuspendUsers.php b/src/User/Actions/UnsuspendUsers.php index 830c650db77..33c9e7c6896 100644 --- a/src/User/Actions/UnsuspendUsers.php +++ b/src/User/Actions/UnsuspendUsers.php @@ -6,7 +6,6 @@ use CraftCms\Cms\Element\Actions\ElementAction; use CraftCms\Cms\Element\Queries\Contracts\ElementQueryInterface; -use CraftCms\Cms\Support\Facades\HtmlStack; use CraftCms\Cms\Support\Facades\Users; use CraftCms\Cms\User\Contracts\CraftUser; use CraftCms\Cms\User\Elements\User; @@ -27,35 +26,6 @@ public function getTriggerLabel(): string return t('Unsuspend'); } - public function getTriggerHtml(): ?string - { - HtmlStack::jsWithVars(fn ($type) => << { - new Craft.ElementActionTrigger({ - type: $type, - bulk: true, - validateSelection: (selectedItems, elementIndex) => { - for (let i = 0; i < selectedItems.length; i++) { - const \$element = selectedItems.eq(i).find('.element'); - if ( - !Garnish.hasAttr(\$element, 'data-can-suspend') || - !Garnish.hasAttr(\$element, 'data-suspended') - ) { - return false; - } - } - - return true; - } - }) -})(); -JS, [ - static::class, - ]); - - return null; - } - #[Override] public function performAction(ElementQueryInterface $query): bool { diff --git a/yii2-adapter/legacy/base/ElementAction.php b/yii2-adapter/legacy/base/ElementAction.php index 2a5562d3c89..cd99b1cad6c 100644 --- a/yii2-adapter/legacy/base/ElementAction.php +++ b/yii2-adapter/legacy/base/ElementAction.php @@ -22,6 +22,25 @@ */ abstract class ElementAction extends \CraftCms\Cms\Element\Actions\ElementAction implements ElementActionInterface { + /** + * Returns the ID the action’s trigger element should have in the legacy element index. + * + * Override this with something unique if the same action can be listed more than once, + * and pass the same value as the `triggerId` setting of `Craft.ElementActionTrigger`. + */ + public function getTriggerId(): string + { + return sprintf('%s-actiontrigger', static::class); + } + + /** + * Returns the action’s trigger markup for the legacy element index, or `null` to list it in the actions menu. + */ + public function getTriggerHtml(): ?string + { + return null; + } + public function getRules(): array { return LegacyYiiRules::mergeAttributeRules( diff --git a/yii2-adapter/legacy/helpers/ElementHelper.php b/yii2-adapter/legacy/helpers/ElementHelper.php index 7149d5228a0..fd00a1ce1a8 100644 --- a/yii2-adapter/legacy/helpers/ElementHelper.php +++ b/yii2-adapter/legacy/helpers/ElementHelper.php @@ -17,6 +17,7 @@ use CraftCms\Cms\Field\Field; use CraftCms\Cms\User\Elements\User as UserElement; use CraftCms\Cms\User\Users; +use CraftCms\Yii2Adapter\Element\LegacyElementActions; use Illuminate\Support\Facades\Context; use Illuminate\Support\HtmlString; @@ -566,7 +567,7 @@ public static function elementRevisionsUrl(ElementInterface $element): string */ public static function actionConfig(ElementActionInterface $action): array { - return LaravelElementHelper::actionConfig($action); + return app(LegacyElementActions::class)->actionConfig($action); } /** diff --git a/resources/templates/_components/elementactions/SetStatus/trigger.twig b/yii2-adapter/resources/templates/_components/elementactions/SetStatus/trigger.twig similarity index 100% rename from resources/templates/_components/elementactions/SetStatus/trigger.twig rename to yii2-adapter/resources/templates/_components/elementactions/SetStatus/trigger.twig diff --git a/yii2-adapter/src/Element/LegacyElementActions.php b/yii2-adapter/src/Element/LegacyElementActions.php new file mode 100644 index 00000000000..4ec33a82ff0 --- /dev/null +++ b/yii2-adapter/src/Element/LegacyElementActions.php @@ -0,0 +1,141 @@ +actionConfig($action); + + // Built-in triggers are found by ID, and an action can be listed twice (e.g. Delete with and without descendants) + if (isset($config['legacyTrigger'])) { + $count = $triggerIdCounts[$config['triggerId']] = ($triggerIdCounts[$config['triggerId']] ?? 0) + 1; + + if ($count > 1) { + $config['triggerId'] .= "-$count"; + } + } + + $data[] = $config; + } + + return $data; + } + + /** + * @return array + */ + public function actionConfig(ElementActionInterface $action): array + { + $config = ElementHelper::actionConfig($action); + $config['triggerId'] = method_exists($action, 'getTriggerId') + ? $action->getTriggerId() + : sprintf('%s-actiontrigger', $action::class); + + if ($this->definesOwnTrigger($action)) { + /** @phpstan-ignore method.notFound */ + $config['trigger'] = $action->getTriggerHtml(); + + return $config; + } + + $config['trigger'] = $this->triggerHtml($action); + $config['legacyTrigger'] = $this->legacyTrigger($action); + + return $config; + } + + private function definesOwnTrigger(ElementActionInterface $action): bool + { + if (!method_exists($action, 'getTriggerHtml')) { + return false; + } + + return new ReflectionMethod($action, 'getTriggerHtml')->getDeclaringClass()->getName() !== LegacyElementAction::class; + } + + private function triggerHtml(ElementActionInterface $action): ?string + { + return match (true) { + $action instanceof SetStatus => template('yii2-adapter/_components/elementactions/SetStatus/trigger'), + $action instanceof Restore => '
' . $action->getTriggerLabel() . '
', + default => null, + }; + } + + /** + * @return array|null + */ + private function legacyTrigger(ElementActionInterface $action): ?array + { + return match (true) { + $action instanceof DeleteAssets => [ + 'handler' => 'deleteAssets', + 'hardDelete' => $action->hard, + 'confirmationMessage' => $action->confirmationMessage, + ], + $action instanceof Delete => [ + 'handler' => 'delete', + 'withDescendants' => $action->withDescendants, + 'hardDelete' => $action->hard, + 'confirmationMessage' => $action->confirmationMessage, + ], + $action instanceof Duplicate => [ + 'handler' => 'duplicate', + 'attribute' => $action->asDrafts ? 'data-duplicatable-as-draft' : 'data-duplicatable', + ], + $action instanceof DeleteForSite => ['handler' => 'deleteForSite'], + $action instanceof Restore => [ + 'handler' => 'restore', + 'attribute' => $action->restorableElementsOnly ? 'data-restorable' : 'data-savable', + ], + $action instanceof SetStatus => ['handler' => 'setStatus'], + $action instanceof SuspendUsers => [ + 'handler' => 'suspendUsers', + 'currentUserId' => currentUser()?->getCraftUserId(), + ], + $action instanceof UnsuspendUsers => ['handler' => 'unsuspendUsers'], + default => null, + }; + } +} diff --git a/yii2-adapter/src/Mixins/NestedElementManagerMixin.php b/yii2-adapter/src/Mixins/NestedElementManagerMixin.php index 00f8eb59d25..1e788860cf5 100644 --- a/yii2-adapter/src/Mixins/NestedElementManagerMixin.php +++ b/yii2-adapter/src/Mixins/NestedElementManagerMixin.php @@ -11,7 +11,6 @@ use CraftCms\Cms\Cp\Html\ElementHtml; use CraftCms\Cms\Cp\Icons; use CraftCms\Cms\Element\Contracts\ElementInterface; -use CraftCms\Cms\Element\ElementHelper; use CraftCms\Cms\Element\NestedElementManager; use CraftCms\Cms\Support\Facades\HtmlStack; use CraftCms\Cms\Support\Facades\InputNamespace; @@ -19,6 +18,7 @@ use CraftCms\Cms\Support\Json; use CraftCms\Cms\Support\Str; use CraftCms\Yii2Adapter\Cp\Html\ElementIndexHtml; +use CraftCms\Yii2Adapter\Element\LegacyElementActions; use function CraftCms\Cms\t; @@ -168,7 +168,7 @@ private function indexSettings(): Closure new MoveDown($owner, $attribute), ] as $action) { HtmlStack::startJsBuffer(); - $actionConfig = ElementHelper::actionConfig($action); + $actionConfig = app(LegacyElementActions::class)->actionConfig($action); $actionConfig['bodyHtml'] = HtmlStack::clearJsBuffer(); $indexSettings['actions'][] = $actionConfig; } diff --git a/yii2-adapter/src/Yii2ServiceProvider.php b/yii2-adapter/src/Yii2ServiceProvider.php index 429b4055bf6..924be28af6b 100644 --- a/yii2-adapter/src/Yii2ServiceProvider.php +++ b/yii2-adapter/src/Yii2ServiceProvider.php @@ -24,6 +24,7 @@ use CraftCms\Cms\Database\MigrationRepository; use CraftCms\Cms\Database\Migrator as CoreMigrator; use CraftCms\Cms\Database\Table; +use CraftCms\Cms\Element\ElementActions; use CraftCms\Cms\Element\Events\ElementEditorPayloadResolving; use CraftCms\Cms\Field\Events\FieldCachesInvalidated; use CraftCms\Cms\Field\Events\MatrixBlockHtmlRendering; @@ -65,6 +66,7 @@ use CraftCms\Yii2Adapter\Cp\Html\LegacyMenuHtml; use CraftCms\Yii2Adapter\Cp\LegacySettings; use CraftCms\Yii2Adapter\Database\Migrator; +use CraftCms\Yii2Adapter\Element\LegacyElementActions; use CraftCms\Yii2Adapter\Element\LegacyElementEditorScreen; use CraftCms\Yii2Adapter\Field\MatrixBlockHtml; use CraftCms\Yii2Adapter\FieldLayout\LegacyLayoutElementHtml; @@ -150,6 +152,7 @@ public function register(): void $this->app->singleton(AssetFileKinds::class, LegacyAssetFileKinds::class); $this->app->singleton(Settings::class, LegacySettings::class); $this->app->singleton(MenuHtml::class, LegacyMenuHtml::class); + $this->app->singleton(ElementActions::class, LegacyElementActions::class); $this->app->singleton(GqlArguments::class, LegacyGqlArguments::class); $this->app->singleton(GqlDirectives::class, LegacyGqlDirectives::class); $this->app->singleton(GqlTypes::class, LegacyGqlTypes::class); diff --git a/yii2-adapter/tests-laravel/Element/LegacyElementActionsTest.php b/yii2-adapter/tests-laravel/Element/LegacyElementActionsTest.php new file mode 100644 index 00000000000..423523e8c6d --- /dev/null +++ b/yii2-adapter/tests-laravel/Element/LegacyElementActionsTest.php @@ -0,0 +1,124 @@ +actingAs(User::first()); + $this->elementActions = app(ElementActions::class); +}); + +function legacyActionConfig(string $type, string $elementType = Entry::class, array $config = []): array +{ + $actions = app(ElementActions::class); + + return $actions->serializeActions([ + $actions->createAction(['type' => $type, ...$config], $elementType), + ])[0]; +} + +it('replaces the element actions service', function(): void { + expect($this->elementActions)->toBeInstanceOf(LegacyElementActions::class); +}); + +it('gives built-in actions a trigger descriptor', function(): void { + expect(legacyActionConfig(Delete::class, config: ['withDescendants' => true])) + ->toMatchArray([ + 'triggerId' => Delete::class . '-actiontrigger', + 'trigger' => null, + 'legacyTrigger' => [ + 'handler' => 'delete', + 'withDescendants' => true, + 'hardDelete' => false, + 'confirmationMessage' => null, + ], + ]) + ->and(legacyActionConfig(DeleteAssets::class, Asset::class)['legacyTrigger']['handler'])->toBe('deleteAssets') + ->and(legacyActionConfig(Duplicate::class, config: ['asDrafts' => true])['legacyTrigger']) + ->toBe(['handler' => 'duplicate', 'attribute' => 'data-duplicatable-as-draft']) + ->and(legacyActionConfig(Restore::class, config: ['restorableElementsOnly' => true])['legacyTrigger']) + ->toBe(['handler' => 'restore', 'attribute' => 'data-restorable']) + ->and(legacyActionConfig(SuspendUsers::class, UserElement::class)['legacyTrigger']) + ->toBe(['handler' => 'suspendUsers', 'currentUserId' => User::first()->id]); +}); + +it('renders trigger markup for the actions that have it', function(): void { + expect(legacyActionConfig(SetStatus::class)['trigger'])->toContain('data-param="status"') + ->and(legacyActionConfig(Restore::class)['trigger'])->toBe('
Restore
'); +}); + +it('gives repeated built-in actions distinct trigger IDs', function(): void { + $serialized = $this->elementActions->serializeActions([ + $this->elementActions->createAction(Delete::class, Entry::class), + $this->elementActions->createAction(['type' => Delete::class, 'withDescendants' => true], Entry::class), + ]); + + expect(array_column($serialized, 'triggerId'))->toBe([ + Delete::class . '-actiontrigger', + Delete::class . '-actiontrigger-2', + ]); +}); + +it('uses the trigger an action defines itself', function(): void { + $action = new class() extends SetStatus { + public function getTriggerId(): string + { + return 'custom-trigger'; + } + + public function getTriggerHtml(): ?string + { + return ''; + } + }; + $action->setElementType(Entry::class); + + expect($this->elementActions->serializeActions([$action])[0]) + ->toMatchArray(['triggerId' => 'custom-trigger', 'trigger' => '']) + ->not->toHaveKey('legacyTrigger'); +}); + +it('lists legacy actions without their own trigger in the actions menu', function(): void { + $action = new class() extends LegacyElementAction { + }; + $action->setElementType(Entry::class); + + expect($this->elementActions->serializeActions([$action])[0]) + ->toMatchArray(['triggerId' => $action::class . '-actiontrigger', 'trigger' => null, 'legacyTrigger' => null]); +}); + +it('has a legacy index handler for every built-in trigger', function(): void { + $handlers = collect([ + [Delete::class, Entry::class], + [DeleteAssets::class, Asset::class], + [Duplicate::class, Entry::class], + [DeleteForSite::class, Entry::class], + [Restore::class, Entry::class], + [SetStatus::class, Entry::class], + [SuspendUsers::class, UserElement::class], + [UnsuspendUsers::class, UserElement::class], + ])->map(fn(array $action) => legacyActionConfig(...$action)['legacyTrigger']['handler']); + + $script = file_get_contents(dirname(__DIR__, 3) . '/packages/craftcms-legacy/cpcompat/src/element-index/action-triggers.js'); + preg_match_all('/^ (\w+): \(/m', $script, $matches); + + expect($matches[1])->toEqualCanonicalizing($handlers->all()); +});