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()); +});