diff --git a/CHANGELOG-WIP.md b/CHANGELOG-WIP.md index a3cf06018d2..c99873698d0 100644 --- a/CHANGELOG-WIP.md +++ b/CHANGELOG-WIP.md @@ -434,8 +434,14 @@ Craft 6 now uses [Laravel's authorization system](https://laravel.com/docs/12.x/ - Added `CraftCms\Cms\Element\ElementActivity`, `CraftCms\Cms\Element\Data\ElementActivity`, `CraftCms\Cms\Element\Enums\ElementActivityType`, and `CraftCms\Cms\Support\Facades\ElementActivity`. - Deprecated `craft\services\Elements::getRecentActivity()`. `CraftCms\Cms\Element\ElementActivity::getRecentActivity()` should be used instead. - Deprecated `craft\services\Elements::trackActivity()`. `CraftCms\Cms\Element\ElementActivity::trackActivity()` should be used instead. +- Added `CraftCms\Cms\Element\Actions\ElementAction`, `CraftCms\Cms\Element\ElementActions`, `CraftCms\Cms\Element\Contracts\DeleteActionInterface`, `CraftCms\Cms\Element\Contracts\ElementActionInterface`, `CraftCms\Cms\Element\Events\AfterPerformAction`, `CraftCms\Cms\Element\Events\BeforePerformAction`, `CraftCms\Cms\Http\Controllers\Elements\PerformElementActionController`, and `CraftCms\Cms\Support\Facades\ElementActions`. +- Added Laravel-native element action classes under `CraftCms\Cms\Element\Actions`, `CraftCms\Cms\Asset\Actions`, `CraftCms\Cms\Entry\Actions`, and `CraftCms\Cms\User\Actions`. +- Added `CraftCms\Cms\Element\ElementExporters`, `CraftCms\Cms\Element\Contracts\ElementExporterInterface`, `CraftCms\Cms\Element\Exporters\ElementExporter`, `CraftCms\Cms\Http\Controllers\Elements\ExportElementIndexController`, and `CraftCms\Cms\Support\Facades\ElementExporters`. +- Added Laravel-native element exporter classes under `CraftCms\Cms\Element\Exporters`. - Deprecated `craft\errors\InvalidTypeException`. `CraftCms\Cms\Element\Exceptions\InvalidTypeException` should be used instead. - Deprecated `craft\errors\UnsupportedSiteException`. `CraftCms\Cms\Element\Exceptions\UnsupportedSiteException` should be used instead. +- 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\*`, `CraftCms\Cms\Entry\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. ### Validation diff --git a/routes/actions.php b/routes/actions.php index 2a5acf2aa63..67cfef0f96d 100644 --- a/routes/actions.php +++ b/routes/actions.php @@ -25,6 +25,8 @@ use CraftCms\Cms\Http\Controllers\Dashboard\Widgets\NewUsersController; use CraftCms\Cms\Http\Controllers\Dashboard\WidgetsController; use CraftCms\Cms\Http\Controllers\EditionController; +use CraftCms\Cms\Http\Controllers\Elements\ExportElementIndexController; +use CraftCms\Cms\Http\Controllers\Elements\PerformElementActionController; use CraftCms\Cms\Http\Controllers\Entries\CreateEntryController; use CraftCms\Cms\Http\Controllers\Entries\MoveEntryToSectionController; use CraftCms\Cms\Http\Controllers\Entries\StoreEntryController; @@ -207,6 +209,10 @@ Route::post('app/switch-to-licensed-edition', [EditionController::class, 'switchToLicensedEdition']); }); + // Elements + Route::post('element-indexes/export', ExportElementIndexController::class); + Route::post('element-indexes/perform-action', PerformElementActionController::class); + // Entries Route::post('entries/create', CreateEntryController::class); Route::post('entries/save-entry', StoreEntryController::class); diff --git a/src/Address/Elements/Address.php b/src/Address/Elements/Address.php index 5aa53aeb82f..ade464044e8 100644 --- a/src/Address/Elements/Address.php +++ b/src/Address/Elements/Address.php @@ -11,13 +11,13 @@ use Craft; use craft\base\NestedElementInterface; use craft\base\NestedElementTrait; -use craft\elements\actions\Copy; use craft\elements\conditions\addresses\AddressCondition; use CraftCms\Cms\Address\Addresses; use CraftCms\Cms\Address\Models\Address as AddressModel; use CraftCms\Cms\Address\Validation\AddressRules; use CraftCms\Cms\Cms; use CraftCms\Cms\Database\Table; +use CraftCms\Cms\Element\Actions\Copy; use CraftCms\Cms\Element\Conditions\Contracts\ElementConditionInterface; use CraftCms\Cms\Element\Element; use CraftCms\Cms\Element\Queries\AddressQuery; diff --git a/src/Asset/Actions/CopyReferenceTag.php b/src/Asset/Actions/CopyReferenceTag.php new file mode 100644 index 00000000000..102e4beef37 --- /dev/null +++ b/src/Asset/Actions/CopyReferenceTag.php @@ -0,0 +1,45 @@ +elementType::refHandle(); + if ($refHandle === null) { + throw new RuntimeException("Element type \"$this->elementType\" doesn't have a reference handle."); + } + + HtmlStack::jsWithVars(fn ($type, $refHandle) => << { + new Craft.ElementActionTrigger({ + type: $type, + bulk: false, + activate: (selectedItems, elementIndex) => { + Craft.ui.createCopyTextPrompt({ + label: Craft.t('app', 'Copy the reference tag'), + value: '{' + $refHandle + ':' + selectedItems.find('.element').data('id') + '}', + }); + }, + }) +})(); +JS, [static::class, $refHandle]); + + return null; + } +} diff --git a/src/Asset/Actions/CopyUrl.php b/src/Asset/Actions/CopyUrl.php new file mode 100644 index 00000000000..48201870946 --- /dev/null +++ b/src/Asset/Actions/CopyUrl.php @@ -0,0 +1,40 @@ + << { + new Craft.ElementActionTrigger({ + type: $type, + bulk: false, + validateSelection: (selectedItems, elementIndex) => !!selectedItems.find('.element').data('url'), + activate: (selectedItems, elementIndex) => { + Craft.ui.createCopyTextPrompt({ + label: Craft.t('app', 'Copy the URL'), + value: selectedItems.find('.element').data('url'), + }); + }, + }) +})(); +JS, [static::class]); + + return null; + } +} diff --git a/src/Asset/Actions/DeleteAssets.php b/src/Asset/Actions/DeleteAssets.php new file mode 100644 index 00000000000..4fe3989c9e4 --- /dev/null +++ b/src/Asset/Actions/DeleteAssets.php @@ -0,0 +1,61 @@ + << { + const trigger = new Craft.ElementActionTrigger({ + type: $type, + 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 true; + }, + + activate: (selectedItems, elementIndex) => { + const element = selectedItems.find('.element:first'); + if (Garnish.hasAttr(element, 'data-is-folder')) { + const sourcePath = element.data('source-path'); + elementIndex.deleteFolder(sourcePath[sourcePath.length - 1]) + .then(() => { + elementIndex.updateElements(); + }); + } else { + elementIndex.submitAction(trigger.\$trigger.data('action'), Garnish.getPostData(trigger.\$trigger)); + } + }, + }); +})(); +JS, [static::class]); + + return null; + } +} diff --git a/src/Asset/Actions/DownloadAssetFile.php b/src/Asset/Actions/DownloadAssetFile.php new file mode 100644 index 00000000000..843b84dcd2c --- /dev/null +++ b/src/Asset/Actions/DownloadAssetFile.php @@ -0,0 +1,54 @@ + << { + new Craft.ElementActionTrigger({ + type: $type, + activate: (selectedItems, elementIndex) => { + var \$form = Craft.createForm().appendTo(Garnish.\$bod); + $(Craft.getCsrfInput()).appendTo(\$form); + $('', { + type: 'hidden', + name: 'action', + value: 'assets/download-asset' + }).appendTo(\$form); + selectedItems.each(function() { + $('', { + type: 'hidden', + name: 'assetId[]', + value: $(this).data('id') + }).appendTo(\$form); + }); + $('', { + type: 'submit', + value: 'Submit', + }).appendTo(\$form); + \$form.submit(); + \$form.remove(); + }, + }); +})(); +JS, [static::class]); + + return null; + } +} diff --git a/src/Asset/Actions/EditImage.php b/src/Asset/Actions/EditImage.php new file mode 100644 index 00000000000..dd7a9ea7fb3 --- /dev/null +++ b/src/Asset/Actions/EditImage.php @@ -0,0 +1,47 @@ +label ??= t('Edit Image'); + } + + #[\Override] + public function getTriggerLabel(): string + { + return $this->label; + } + + public function getTriggerHtml(): ?string + { + HtmlStack::jsWithVars(fn ($type) => << { + new Craft.ElementActionTrigger({ + type: $type, + bulk: false, + validateSelection: (selectedItems, elementIndex) => Garnish.hasAttr(selectedItems.find('.element'), 'data-editable-image'), + activate: (selectedItems, elementIndex) => { + const \$element = selectedItems.find('.element:first'); + new Craft.AssetImageEditor(\$element.data('id')); + }, + }); +})(); +JS, [static::class]); + + return null; + } +} diff --git a/src/Asset/Actions/MoveAssets.php b/src/Asset/Actions/MoveAssets.php new file mode 100644 index 00000000000..405e62d27e4 --- /dev/null +++ b/src/Asset/Actions/MoveAssets.php @@ -0,0 +1,101 @@ + << { + const groupItems = function(\$items) { + const \$folders = \$items.has('.element[data-is-folder]'); + const \$assets = \$items.not(\$folders); + return [\$folders, \$assets]; + }; + + const peerFiles = function(\$folders, \$assets) { + return !!(\$folders.length || \$assets.has('.element[data-peer-file]').length) + }; + + new Craft.ElementActionTrigger({ + type: $actionClass, + bulk: true, + requireId: false, + validateSelection: (selectedItems, elementIndex) => { + for (let i = 0; i < selectedItems.length; i++) { + if (!Garnish.hasAttr(selectedItems.eq(i).find('.element'), 'data-movable')) { + return false; + } + } + return elementIndex.getMoveTargetSourceKeys(peerFiles(...groupItems(selectedItems))).length; + }, + activate: (selectedItems, elementIndex) => { + const [\$folders, \$assets] = groupItems(selectedItems); + const selectedFolderIds = \$folders.toArray().map((item) => { + return parseInt($(item).find('.element:first').data('folder-id')); + }); + const disabledFolderIds = selectedFolderIds.slice(); + if (elementIndex.sourcePath.length) { + const currentFolder = elementIndex.sourcePath[elementIndex.sourcePath.length - 1]; + if (currentFolder.folderId) { + disabledFolderIds.push(currentFolder.folderId); + } + } + const selectedAssetIds = \$assets.toArray().map((item) => { + return parseInt($(item).data('id')); + }); + + new Craft.VolumeFolderSelectorModal({ + sources: elementIndex.getMoveTargetSourceKeys(peerFiles(\$folders, \$assets)), + showTitle: true, + modalTitle: Craft.t('app', 'Move to'), + selectBtnLabel: Craft.t('app', 'Move'), + disabledFolderIds: disabledFolderIds, + indexSettings: { + defaultSource: elementIndex.sourceKey, + defaultSourcePath: elementIndex.sourcePath, + }, + onSelect: async ([targetFolder]) => { + const mover = new Craft.AssetMover(); + const moveParams = await mover.getMoveParams(selectedFolderIds, selectedAssetIds); + if (!moveParams.proceed) { + return; + } + const totalFoldersMoved = await mover.moveFolders(selectedFolderIds, targetFolder.folderId, elementIndex.currentFolderId); + const totalAssetsMoved = await mover.moveAssets(selectedAssetIds, targetFolder.folderId, elementIndex.currentFolderId); + const totalItemsMoved = totalFoldersMoved + totalAssetsMoved; + if (totalItemsMoved) { + mover.successNotice( + moveParams, + Craft.t('app', '{totalItems, plural, =1{Item} other{Items}} moved.', { + totalItems: totalItemsMoved, + }) + ); + elementIndex.updateElements(true); + } + }, + }); + }, + }) +})(); +JS, [ + static::class, + ]); + + return null; + } +} diff --git a/src/Asset/Actions/PreviewAsset.php b/src/Asset/Actions/PreviewAsset.php new file mode 100644 index 00000000000..44a6fb4a11c --- /dev/null +++ b/src/Asset/Actions/PreviewAsset.php @@ -0,0 +1,52 @@ +label ??= t('Preview file'); + } + + #[\Override] + public function getTriggerLabel(): string + { + return $this->label; + } + + public function getTriggerHtml(): ?string + { + HtmlStack::jsWithVars(fn ($type) => << { + new Craft.ElementActionTrigger({ + type: $type, + bulk: false, + validateSelection: (selectedItems, elementIndex) => selectedItems.length === 1, + activate: (selectedItems, elementIndex) => { + const \$element = selectedItems.find('.element'); + const settings = {}; + if (\$element.data('image-width')) { + settings.startingWidth = \$element.data('image-width'); + settings.startingHeight = \$element.data('image-height'); + } + new Craft.PreviewFileModal(\$element.data('id'), elementIndex.view.elementSelect, settings); + }, + }); +})(); +JS, [static::class]); + + return null; + } +} diff --git a/src/Asset/Actions/RenameFile.php b/src/Asset/Actions/RenameFile.php new file mode 100644 index 00000000000..50809b89693 --- /dev/null +++ b/src/Asset/Actions/RenameFile.php @@ -0,0 +1,87 @@ + << { + new Craft.ElementActionTrigger({ + type: $type, + bulk: false, + validateSelection: (selectedItems, elementIndex) => Garnish.hasAttr(selectedItems.find('.element'), 'data-movable'), + activate: (selectedItems, elementIndex) => { + const \$element = selectedItems.find('.element') + const assetId = \$element.data('id'); + let oldName = \$element.data('filename'); + + const newName = prompt($prompt, oldName); + + if (!newName || newName == oldName) + { + return; + } + + elementIndex.setIndexBusy(); + + let currentFolderId = elementIndex.\$source.data('folder-id'); + const currentFolder = elementIndex.sourcePath[elementIndex.sourcePath.length - 1]; + if (currentFolder && currentFolder.folderId) { + currentFolderId = currentFolder.folderId; + } + + const data = { + assetId: assetId, + folderId: currentFolderId, + filename: newName + }; + + Craft.sendActionRequest('POST', 'assets/move-asset', {data}) + .then(response => { + if (response.data.conflict) { + alert(response.data.conflict); + this.activate(selectedItems); + return; + } + + if (response.data.success) { + elementIndex.updateElements(); + + // If assets were just merged we should get the reference tags updated right away + Craft.cp.runQueue(); + } + }) + .catch(({response}) => { + alert(response.data.message) + }) + .finally(() => { + elementIndex.setIndexAvailable(); + }); + }, + }); +})(); +JS, + [ + static::class, + t('Enter the new filename'), + ]); + + return null; + } +} diff --git a/src/Asset/Actions/ReplaceFile.php b/src/Asset/Actions/ReplaceFile.php new file mode 100644 index 00000000000..9ab07dc37f8 --- /dev/null +++ b/src/Asset/Actions/ReplaceFile.php @@ -0,0 +1,74 @@ + << { + new Craft.ElementActionTrigger({ + type: $type, + bulk: false, + validateSelection: (selectedItems, elementIndex) => Garnish.hasAttr(selectedItems.find('.element'), 'data-replaceable'), + activate: (selectedItems, elementIndex) => { + $('.replaceFile').remove(); + + const \$element = selectedItems.find('.element'); + const \$fileInput = $('').appendTo(Garnish.\$bod); + const settings = elementIndex._currentUploaderSettings; + + settings.dropZone = null; + settings.fileInput = \$fileInput; + settings.paramName = 'replaceFile'; + settings.replace = true; + settings.events = {}; + + const fileuploaddone = settings.events?.fileuploaddone; + settings.events = Object.assign({}, settings.events || {}, { + fileuploaddone: (event, data = null) => { + const result = event instanceof CustomEvent ? event.detail : data.result; + if (!result.error) { + Craft.cp.displayNotice(Craft.t('app', 'New file uploaded.')); + // update the element row + if (Craft.broadcaster) { + Craft.broadcaster.postMessage({ + event: 'saveElement', + id: result.assetId, + }); + } + } + if (fileuploaddone) { + fileuploaddone(event, data); + } + } + }); + + const tempUploader = Craft.createUploader(elementIndex.uploader.fsType, \$fileInput, settings); + tempUploader.setParams({ + assetId: \$element.data('id') + }); + + \$fileInput.click(); + }, + }); +})(); +JS, [static::class]); + + return null; + } +} diff --git a/src/Asset/Actions/ShowInFolder.php b/src/Asset/Actions/ShowInFolder.php new file mode 100644 index 00000000000..634e2c48c63 --- /dev/null +++ b/src/Asset/Actions/ShowInFolder.php @@ -0,0 +1,60 @@ +elementType !== Asset::class) { + throw new RuntimeException('Show in folder is only available for Assets.'); + } + + HtmlStack::jsWithVars(fn ($type) => << { + new Craft.ElementActionTrigger({ + type: $type, + bulk: false, + activate: (selectedItem, elementIndex) => { + const data = { + 'assetId': selectedItem.find('.element:first').data('id') + } + + Craft.sendActionRequest('POST', 'assets/show-in-folder', {data}) + .then(({data}) => { + elementIndex.sourcePath = data.sourcePath; + elementIndex.stopSearching(); + + // prevent searching in subfolders - we want the exact folder the asset belongs to + elementIndex.setSelecetedSourceState('includeSubfolders', false); + + // search for the selected asset's filename + elementIndex.\$search.val(data.filename); + elementIndex.\$search.trigger('input'); + }) + .catch((e) => { + Craft.cp.displayError(e?.response?.data?.message); + }); + }, + }) +})(); +JS, [static::class]); + + return null; + } +} diff --git a/src/Asset/Elements/Asset.php b/src/Asset/Elements/Asset.php index bea08d66a12..b5396f9e213 100644 --- a/src/Asset/Elements/Asset.php +++ b/src/Asset/Elements/Asset.php @@ -9,21 +9,20 @@ use craft\controllers\ElementIndexesController; use craft\controllers\ElementSelectorModalsController; use craft\db\QueryAbortedException; -use craft\elements\actions\CopyReferenceTag; -use craft\elements\actions\CopyUrl; -use craft\elements\actions\DeleteAssets; -use craft\elements\actions\DownloadAssetFile; -use craft\elements\actions\EditImage; -use craft\elements\actions\MoveAssets; -use craft\elements\actions\PreviewAsset; -use craft\elements\actions\RenameFile; -use craft\elements\actions\ReplaceFile; -use craft\elements\actions\Restore; -use craft\elements\actions\ShowInFolder; use craft\elements\conditions\assets\AssetCondition; use craft\errors\AssetException; use craft\validators\AssetLocationValidator; use CraftCms\Aliases\Aliases; +use CraftCms\Cms\Asset\Actions\CopyReferenceTag; +use CraftCms\Cms\Asset\Actions\CopyUrl; +use CraftCms\Cms\Asset\Actions\DeleteAssets; +use CraftCms\Cms\Asset\Actions\DownloadAssetFile; +use CraftCms\Cms\Asset\Actions\EditImage; +use CraftCms\Cms\Asset\Actions\MoveAssets; +use CraftCms\Cms\Asset\Actions\PreviewAsset; +use CraftCms\Cms\Asset\Actions\RenameFile; +use CraftCms\Cms\Asset\Actions\ReplaceFile; +use CraftCms\Cms\Asset\Actions\ShowInFolder; use CraftCms\Cms\Asset\AssetsHelper; use CraftCms\Cms\Asset\Data\Volume; use CraftCms\Cms\Asset\Data\VolumeFolder; @@ -42,6 +41,7 @@ use CraftCms\Cms\Cp\Html\ElementHtml; use CraftCms\Cms\Database\Table; use CraftCms\Cms\Edition; +use CraftCms\Cms\Element\Actions\Restore; use CraftCms\Cms\Element\Conditions\Contracts\ElementConditionInterface; use CraftCms\Cms\Element\Data\EagerLoadPlan; use CraftCms\Cms\Element\Element; diff --git a/src/Component/ComponentHelper.php b/src/Component/ComponentHelper.php index e46a84f3ce6..f7f9fb20068 100644 --- a/src/Component/ComponentHelper.php +++ b/src/Component/ComponentHelper.php @@ -10,6 +10,10 @@ use CraftCms\Cms\Support\Arr; use CraftCms\Cms\Support\Json; use CraftCms\Cms\Support\Typecast; +use CraftCms\Cms\Support\Utils; +use DateTime; +use ReflectionNamedType; +use ReflectionProperty; use RuntimeException; class ComponentHelper @@ -138,4 +142,25 @@ public static function mergeSettings(array $config): array return array_merge($config, $settings); } + + /** + * Return all DateTime attributes for given model. + */ + public static function datetimeAttributes(object $model): array + { + $datetimeAttributes = []; + + $attributes = Utils::getPublicReflectionProperties($model, function (ReflectionProperty $property) { + $type = $property->getType(); + + return $type instanceof ReflectionNamedType && $type->getName() === DateTime::class; + }); + + foreach ($attributes as $property) { + $datetimeAttributes[] = $property->getName(); + } + + // Include datetimeAttributes() for now + return $datetimeAttributes; + } } diff --git a/src/Element/Actions/ChangeSortOrder.php b/src/Element/Actions/ChangeSortOrder.php new file mode 100644 index 00000000000..509455128c3 --- /dev/null +++ b/src/Element/Actions/ChangeSortOrder.php @@ -0,0 +1,112 @@ + << { + new Craft.ElementActionTrigger({ + type: $type, + bulk: true, + validateSelection: (selectedItems, elementIndex) => { + return ( + elementIndex.sortable && + elementIndex.totalResults && + elementIndex.totalResults > elementIndex.settings.batchSize + ); + }, + activate: (selectedItems, elementIndex) => { + const totalPages = Math.ceil(elementIndex.totalResults / elementIndex.settings.batchSize); + const container = $('
'); + const flex = $('
', {class: 'flex flex-nowrap'}); + const select = Craft.ui.createSelect({ + options: [...Array(totalPages).keys()].map(num => ({label: num + 1, value: num + 1})), + value: elementIndex.page === totalPages ? elementIndex.page - 1 : elementIndex.page + 1, + }).appendTo(flex); + select.find('option[value=' + elementIndex.page + ']').attr('disabled', 'disabled'); + const button = Craft.ui.createSubmitButton({ + label: Craft.t('app', 'Move'), + spinner: true, + }).appendTo(flex); + Craft.ui.createField(flex, { + label: Craft.t('app', 'Choose a page'), + }).appendTo(container); + const hud = new Garnish.HUD(elementIndex.\$actionMenuBtn, container); + + button.one('activate', async () => { + const page = parseInt(select.find('select').val()); + moveToPage(selectedItems, elementIndex, page, button, hud); + }); + }, + }) + + async function moveToPage(selectedItems, elementIndex, page, button, hud) { + button.addClass('loading'); + await elementIndex.settings.onBeforeMoveElementsToPage(selectedItems, page); + + const data = Object.assign($params, { + elementIds: elementIndex.getSelectedElementIds(), + offset: (page - 1) * elementIndex.settings.batchSize, + }) + + const elementEditor = elementIndex.\$container.closest('form').data('elementEditor'); + if (elementEditor) { + data.ownerId = elementEditor.getDraftElementId(data.ownerId); + } + + let response; + try { + response = await Craft.sendActionRequest('POST', 'nested-elements/reorder', {data}); + } catch (e) { + Craft.cp.displayError(e?.response?.data?.error); + return; + } finally { + button.removeClass('loading'); + } + + hud.hide(); + Craft.cp.displayNotice(response.data.message); + await elementIndex.settings.onMoveElementsToPage(selectedItems, page); + elementIndex.setPage(page); + elementIndex.updateElements(true, true) + } +})(); +JS, + [ + static::class, + [ + 'ownerElementType' => $this->owner::class, + 'ownerId' => $this->owner->id, + 'ownerSiteId' => $this->owner->siteId, + 'attribute' => $this->attribute, + ], + ]); + + return null; + } +} diff --git a/src/Element/Actions/Copy.php b/src/Element/Actions/Copy.php new file mode 100644 index 00000000000..839604ad1f4 --- /dev/null +++ b/src/Element/Actions/Copy.php @@ -0,0 +1,48 @@ + << { + 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-copyable')) { + return false; + } + } + + return true; + }, + activate: (selectedItems, elementIndex) => { + let elements = $(); + selectedItems.each((i, item) => { + elements = elements.add($(item).find('.element:first')); + }); + Craft.cp.copyElements(elements); + }, + }) +})(); +JS, [static::class]); + + return null; + } +} diff --git a/src/Element/Actions/Delete.php b/src/Element/Actions/Delete.php new file mode 100644 index 00000000000..a9c9c24ec26 --- /dev/null +++ b/src/Element/Actions/Delete.php @@ -0,0 +1,199 @@ +withDescendants; + } + + public function setHardDelete(): void + { + $this->hard = true; + } + + 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')) { + return false; + } + } + + return elementIndex.settings.canDeleteElements(selectedItems); + }, + beforeActivate: async (selectedItems, elementIndex) => { + await elementIndex.settings.onBeforeDeleteElements(selectedItems); + }, + afterActivate: async (selectedItems, elementIndex) => { + await elementIndex.settings.onDeleteElements(selectedItems); + }, + }) +})(); +JS, [static::class]); + + if ($this->hard) { + return Html::tag('div', $this->getTriggerLabel(), [ + 'class' => ['btn', 'formsubmit'], + ]); + } + + return null; + } + + #[\Override] + public function getTriggerLabel(): string + { + if ($this->hard) { + return t('Delete permanently'); + } + + if ($this->withDescendants) { + return t('Delete (with descendants)'); + } + + return t('Delete'); + } + + #[\Override] + public static function isDestructive(): bool + { + return true; + } + + public function getConfirmationMessage(): ?string + { + if (isset($this->confirmationMessage)) { + return $this->confirmationMessage; + } + + if ($this->hard) { + return t('Are you sure you want to permanently delete the selected {type}?', [ + 'type' => $this->elementType::pluralLowerDisplayName(), + ]); + } + + if ($this->withDescendants) { + return t('Are you sure you want to delete the selected {type} along with their descendants?', [ + 'type' => $this->elementType::pluralLowerDisplayName(), + ]); + } + + return t('Are you sure you want to delete the selected {type}?', [ + 'type' => $this->elementType::pluralLowerDisplayName(), + ]); + } + + #[\Override] + public function performAction(ElementQueryInterface $query): bool + { + $withDescendants = $this->withDescendants && ! $this->hard; + + if ($withDescendants) { + $query + ->with([ + [ + 'descendants', + [ + 'orderByDesc' => 'structureelements.lft', + 'status' => null, + ], + ], + ]) + ->orderByDesc('structureelements.lft'); + } + + $deletedElementIds = []; + $deleteOwnership = []; + + foreach ($query->all() as $element) { + if (! Gate::check('view', $element)) { + continue; + } + if (! Gate::check('delete', $element)) { + continue; + } + if (! isset($deletedElementIds[$element->id])) { + if ($withDescendants) { + foreach ($element->getDescendants()->all() as $descendant) { + if ( + ! isset($deletedElementIds[$descendant->id]) && + Gate::check('view', $descendant) && + Gate::check('delete', $descendant) + ) { + $this->deleteElement($descendant, $deleteOwnership); + $deletedElementIds[$descendant->id] = true; + } + } + } + $this->deleteElement($element, $deleteOwnership); + $deletedElementIds[$element->id] = true; + } + } + + foreach ($deleteOwnership as $ownerId => $elementIds) { + DB::table(Table::ELEMENTS_OWNERS) + ->whereIn('elementId', $elementIds) + ->where('ownerId', $ownerId) + ->delete(); + } + + if (isset($this->successMessage)) { + $this->setMessage($this->successMessage); + } else { + $this->setMessage(t('{type} deleted.', [ + 'type' => $this->elementType::pluralDisplayName(), + ])); + } + + return true; + } + + private function deleteElement( + ElementInterface $element, + array &$deleteOwnership, + ): void { + // If the element primarily belongs to a different element, (and we're not hard deleting) just delete the ownership + if (! $this->hard && $element instanceof NestedElementInterface) { + $ownerId = $element->getOwnerId(); + if ($ownerId && $element->getPrimaryOwnerId() !== $ownerId) { + $deleteOwnership[$ownerId][] = $element->id; + + return; + } + } + + \CraftCms\Cms\Support\Facades\Elements::deleteElement($element, $this->hard); + } +} diff --git a/src/Element/Actions/DeleteForSite.php b/src/Element/Actions/DeleteForSite.php new file mode 100644 index 00000000000..c3ea463c406 --- /dev/null +++ b/src/Element/Actions/DeleteForSite.php @@ -0,0 +1,93 @@ + << { + 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 + { + return t('Delete for site'); + } + + #[\Override] + public static function isDestructive(): bool + { + return true; + } + + public function getConfirmationMessage(): ?string + { + return $this->confirmationMessage ?? t('Are you sure you want to delete the selected {type} for this site?', [ + 'type' => $this->elementType::pluralLowerDisplayName(), + ]); + } + + #[\Override] + public function performAction(ElementQueryInterface $query): bool + { + // Ignore any elements the user doesn’t have permission to delete + $elements = array_filter( + $query->all(), + fn (ElementInterface $element) => ( + Gate::check('view', $element) && + Gate::check('deleteForSite', $element) + ), + ); + + Elements::deleteElementsForSite($elements); + + if (isset($this->successMessage)) { + $this->setMessage($this->successMessage); + } else { + $this->setMessage(t('{type} deleted for site.', [ + 'type' => $this->elementType::pluralDisplayName(), + ])); + } + + return true; + } +} diff --git a/src/Element/Actions/Duplicate.php b/src/Element/Actions/Duplicate.php new file mode 100644 index 00000000000..061075a22ec --- /dev/null +++ b/src/Element/Actions/Duplicate.php @@ -0,0 +1,161 @@ +deep + ? t('Duplicate (with descendants)') + : 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.settings.onBeforeDuplicateElements(selectedItems); + }, + afterActivate: async (selectedItems, elementIndex) => { + await elementIndex.settings.onDuplicateElements(selectedItems); + }, + }) +})(); +JS, [ + static::class, + $this->asDrafts ? 'data-duplicatable-as-draft' : 'data-duplicatable', + ]); + + return null; + } + + #[\Override] + public function performAction(ElementQueryInterface $query): bool + { + if ($this->deep) { + $query->orderBy('structureelements.lft'); + } + + $elements = $query->all(); + $successCount = 0; + $failCount = 0; + + $this->_duplicateElements($query, $elements, $successCount, $failCount); + + // Did all of them fail? + if ($successCount === 0) { + $this->setMessage(t('Could not duplicate elements due to validation errors.')); + + return false; + } + + if ($failCount !== 0) { + $this->setMessage(t('Could not duplicate all elements due to validation errors.')); + } else { + $this->setMessage(t('Elements duplicated.')); + } + + return true; + } + + private function _duplicateElements(ElementQueryInterface $query, array $elements, int &$successCount, int &$failCount, array &$duplicatedElementIds = [], ?ElementInterface $newParent = null): void + { + foreach ($elements as $element) { + $allowed = $this->asDrafts + ? Gate::check('duplicateAsDraft', $element) + : Gate::check('duplicate', $element); + + if (! $allowed) { + continue; + } + + // Make sure this element wasn't already duplicated, which could + // happen if it's the descendant of a previously duplicated element + // and $this->deep == true. + if (isset($duplicatedElementIds[$element->id])) { + continue; + } + + $attributes = [ + 'isProvisionalDraft' => false, + 'draftId' => null, + ]; + + // If the element was loaded for a non-primary owner, set its primary owner to it + if ($element instanceof NestedElementInterface) { + $attributes['primaryOwner'] = $element->getOwner(); + $attributes['sortOrder'] = null; // clear our sort order too + } + + try { + $duplicate = Elements::duplicateElement( + $element, + $attributes, + asUnpublishedDraft: $this->asDrafts, + ); + } catch (Throwable) { + // Validation error + $failCount++; + + continue; + } + + $successCount++; + $duplicatedElementIds[$element->id] = true; + + if ($newParent) { + // Append it to the duplicate of $element’s parent + Structures::append($element->structureId, $duplicate, $newParent); + } elseif ($element->structureId) { + // Place it right next to the original element + Structures::moveAfter($element->structureId, $duplicate, $element); + } + + if ($this->deep) { + // Don't use $element->children() here in case its lft/rgt values have changed + $children = $element::find() + ->siteId($element->siteId) + ->descendantOf($element->id) + ->descendantDist(1) + ->status(null) + ->all(); + + $this->_duplicateElements($query, $children, $successCount, $failCount, $duplicatedElementIds, $duplicate); + } + } + } +} diff --git a/src/Element/Actions/Edit.php b/src/Element/Actions/Edit.php new file mode 100644 index 00000000000..0e17799d1e9 --- /dev/null +++ b/src/Element/Actions/Edit.php @@ -0,0 +1,46 @@ +label ??= t('Edit'); + } + + #[\Override] + public function getTriggerLabel(): string + { + return $this->label; + } + + public function getTriggerHtml(): ?string + { + HtmlStack::jsWithVars(fn ($type) => << { + new Craft.ElementActionTrigger({ + type: $type, + bulk: false, + validateSelection: (selectedItems, elementIndex) => Garnish.hasAttr(selectedItems.find('.element'), 'data-savable'), + activate: (selectedItems, elementIndex) => { + const \$element = selectedItems.find('.element:first'); + Craft.createElementEditor(\$element.data('type'), \$element); + }, + }); +})(); +JS, [static::class]); + + return null; + } +} diff --git a/src/Element/Actions/ElementAction.php b/src/Element/Actions/ElementAction.php new file mode 100644 index 00000000000..7203499e103 --- /dev/null +++ b/src/Element/Actions/ElementAction.php @@ -0,0 +1,81 @@ + + */ + protected string $elementType; + + private ?string $message = null; + + private ?Response $response = null; + + public static function isDestructive(): bool + { + return false; + } + + public static function isDownload(): bool + { + return false; + } + + public function setElementType(string $elementType): void + { + $this->elementType = $elementType; + } + + public function getTriggerLabel(): string + { + return static::displayName(); + } + + public function getTriggerHtml(): ?string + { + return null; + } + + public function getConfirmationMessage(): ?string + { + return null; + } + + public function performAction(ElementQueryInterface $query): bool + { + return true; + } + + public function getMessage(): ?string + { + return $this->message; + } + + public function getResponse(): ?Response + { + return $this->response; + } + + protected function setMessage(string $message): void + { + $this->message = $message; + } + + protected function setResponse(Response $response): void + { + $this->response = $response; + } +} diff --git a/src/Element/Actions/MoveDown.php b/src/Element/Actions/MoveDown.php new file mode 100644 index 00000000000..461c20c4c0a --- /dev/null +++ b/src/Element/Actions/MoveDown.php @@ -0,0 +1,85 @@ + << { + new Craft.ElementActionTrigger({ + type: $type, + bulk: false, + validateSelection: (selectedItems, elementIndex) => { + return ( + elementIndex.sortable && + selectedItems.parent().children().last().data('id') !== selectedItems.data('id') + ); + }, + activate: async (selectedItems, elementIndex) => { + const selectedItemIndex = Object.values(elementIndex.view.getAllElements()).indexOf(selectedItems[0]); + const offset = selectedItemIndex + 1; + await elementIndex.settings.onBeforeReorderElements(selectedItems, offset); + + const data = Object.assign($params, { + elementIds: elementIndex.getSelectedElementIds(), + offset: offset, + }); + + // swap out the ownerId with the new draft ownerId + const elementEditor = elementIndex.\$container.closest('form').data('elementEditor'); + if (elementEditor) { + data.ownerId = elementEditor.getDraftElementId(data.ownerId); + } + + let response; + try { + response = await Craft.sendActionRequest('POST', 'nested-elements/reorder', {data}); + } catch (e) { + Craft.cp.displayError(response.data && response.data.error); + return; + } + + Craft.cp.displayNotice(response.data.message); + await elementIndex.settings.onReorderElements(selectedItems, offset); + elementIndex.updateElements(true, true); + }, + }); +})(); +JS, + [ + static::class, + [ + 'ownerElementType' => $this->owner::class, + 'ownerId' => $this->owner->id, + 'ownerSiteId' => $this->owner->siteId, + 'attribute' => $this->attribute, + ], + ]); + + return null; + } +} diff --git a/src/Element/Actions/MoveUp.php b/src/Element/Actions/MoveUp.php new file mode 100644 index 00000000000..8880cb9f462 --- /dev/null +++ b/src/Element/Actions/MoveUp.php @@ -0,0 +1,85 @@ + << { + new Craft.ElementActionTrigger({ + type: $type, + bulk: false, + validateSelection: (selectedItems, elementIndex) => { + return ( + elementIndex.sortable && + selectedItems.parent().children().first().data('id') !== selectedItems.data('id') + ); + }, + activate: async (selectedItems, elementIndex) => { + const selectedItemIndex = Object.values(elementIndex.view.getAllElements()).indexOf(selectedItems[0]); + const offset = selectedItemIndex - 1; + await elementIndex.settings.onBeforeReorderElements(selectedItems, offset); + + const data = Object.assign($params, { + elementIds: elementIndex.getSelectedElementIds(), + offset: offset, + }); + + // swap out the ownerId with the new draft ownerId + const elementEditor = elementIndex.\$container.closest('form').data('elementEditor'); + if (elementEditor) { + data.ownerId = elementEditor.getDraftElementId(data.ownerId); + } + + let response; + try { + response = await Craft.sendActionRequest('POST', 'nested-elements/reorder', {data}); + } catch (e) { + Craft.cp.displayError(response.data && response.data.error); + return; + } + + Craft.cp.displayNotice(response.data.message); + await elementIndex.settings.onReorderElements(selectedItems, offset); + elementIndex.updateElements(true, true); + }, + }); +})(); +JS, + [ + static::class, + [ + 'ownerElementType' => $this->owner::class, + 'ownerId' => $this->owner->id, + 'ownerSiteId' => $this->owner->siteId, + 'attribute' => $this->attribute, + ], + ]); + + return null; + } +} diff --git a/src/Element/Actions/Restore.php b/src/Element/Actions/Restore.php new file mode 100644 index 00000000000..4113a9f4e0b --- /dev/null +++ b/src/Element/Actions/Restore.php @@ -0,0 +1,111 @@ +successMessage)) { + $this->successMessage = t('{type} restored.', [ + 'type' => $elementType::pluralDisplayName(), + ]); + } + + if (! isset($this->partialSuccessMessage)) { + $this->partialSuccessMessage = t('Some {type} restored.', [ + 'type' => $elementType::pluralLowerDisplayName(), + ]); + } + + if (! isset($this->failMessage)) { + $this->failMessage = t('{type} not restored.', [ + 'type' => $elementType::pluralDisplayName(), + ]); + } + } + + #[\Override] + 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 + { + $anySuccess = false; + $anyFail = false; + + foreach ($query->all() as $element) { + if (! Gate::check('save', $element)) { + continue; + } + + if (Elements::restoreElement($element)) { + $anySuccess = true; + } else { + $anyFail = true; + } + } + + if (! $anySuccess && $anyFail) { + $this->setMessage($this->failMessage); + + return false; + } + + if ($anyFail) { + $this->setMessage($this->partialSuccessMessage); + } else { + $this->setMessage($this->successMessage); + } + + return true; + } +} diff --git a/src/Element/Actions/SetStatus.php b/src/Element/Actions/SetStatus.php new file mode 100644 index 00000000000..94d24184333 --- /dev/null +++ b/src/Element/Actions/SetStatus.php @@ -0,0 +1,140 @@ + ['required', Rule::in([self::ENABLED, self::DISABLED])], + ]; + } + + 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 + { + /** @var class-string $elementType */ + $elementType = $this->elementType; + $isLocalized = $elementType::isLocalized() && Sites::isMultiSite(); + + $elements = $query->all(); + $failCount = 0; + + foreach ($elements as $element) { + if (! Gate::check('save', $element)) { + continue; + } + + switch ($this->status) { + case self::ENABLED: + // Skip if there's nothing to change + if ($element->enabled && $element->getEnabledForSite()) { + continue 2; + } + + $element->enabled = true; + $element->setEnabledForSite(true); + $element->setScenario(Element::SCENARIO_LIVE); + break; + + case self::DISABLED: + // Is this a multi-site element? + if ($isLocalized && count($element->getSupportedSites()) !== 1) { + // Skip if there's nothing to change + if (! $element->getEnabledForSite()) { + continue 2; + } + $element->setEnabledForSite(false); + } else { + // Skip if there's nothing to change + if (! $element->enabled) { + continue 2; + } + $element->enabled = false; + } + break; + } + + if (Elements::saveElement($element) === false) { + // Validation error + $failCount++; + } + } + + // Did all of them fail? + if ($failCount === count($elements)) { + if (count($elements) === 1) { + $this->setMessage(t('Could not update status due to a validation error.')); + } else { + $this->setMessage(t('Could not update statuses due to validation errors.')); + } + + return false; + } + + if ($failCount !== 0) { + $this->setMessage(t('Status updated, with some failures due to validation errors.')); + } else { + if (count($elements) === 1) { + $this->setMessage(t('Status updated.')); + } else { + $this->setMessage(t('Statuses updated.')); + } + } + + return true; + } +} diff --git a/src/Element/Actions/View.php b/src/Element/Actions/View.php new file mode 100644 index 00000000000..7cdb2dc568b --- /dev/null +++ b/src/Element/Actions/View.php @@ -0,0 +1,51 @@ +label ??= t('View'); + } + + #[\Override] + public function getTriggerLabel(): string + { + return $this->label; + } + + public function getTriggerHtml(): ?string + { + HtmlStack::jsWithVars(fn ($type) => << { + new Craft.ElementActionTrigger({ + type: $type, + bulk: false, + validateSelection: (selectedItems, elementIndex) => { + const \$element = selectedItems.find('.element'); + return ( + \$element.data('url') && + (\$element.data('status') === 'enabled' || \$element.data('status') === 'live') + ); + }, + activate: (selectedItems, elementIndex) => { + window.open(selectedItems.find('.element').data('url')); + }, + }); +})(); +JS, [static::class]); + + return null; + } +} diff --git a/src/Element/Concerns/Exportable.php b/src/Element/Concerns/Exportable.php index 06070b3afa6..a1143c22c56 100644 --- a/src/Element/Concerns/Exportable.php +++ b/src/Element/Concerns/Exportable.php @@ -4,9 +4,9 @@ namespace CraftCms\Cms\Element\Concerns; -use craft\elements\exporters\Expanded; -use craft\elements\exporters\Raw; use CraftCms\Cms\Element\Events\RegisterExporters; +use CraftCms\Cms\Element\Exporters\Expanded; +use CraftCms\Cms\Element\Exporters\Raw; /** * Exportable provides element export functionality. diff --git a/src/Element/Concerns/HasActions.php b/src/Element/Concerns/HasActions.php index bee6aa090e0..e04652d8b5a 100644 --- a/src/Element/Concerns/HasActions.php +++ b/src/Element/Concerns/HasActions.php @@ -4,12 +4,12 @@ namespace CraftCms\Cms\Element\Concerns; -use craft\elements\actions\Delete; -use craft\elements\actions\DeleteActionInterface; -use craft\elements\actions\Duplicate; -use craft\elements\actions\Edit; -use craft\elements\actions\SetStatus; -use craft\elements\actions\View as ViewAction; +use CraftCms\Cms\Element\Actions\Delete; +use CraftCms\Cms\Element\Actions\Duplicate; +use CraftCms\Cms\Element\Actions\Edit; +use CraftCms\Cms\Element\Actions\SetStatus; +use CraftCms\Cms\Element\Actions\View as ViewAction; +use CraftCms\Cms\Element\Contracts\DeleteActionInterface; use CraftCms\Cms\Element\Events\RegisterActions; use Illuminate\Support\Collection; diff --git a/src/Element/Contracts/DeleteActionInterface.php b/src/Element/Contracts/DeleteActionInterface.php new file mode 100644 index 00000000000..e73e496b548 --- /dev/null +++ b/src/Element/Contracts/DeleteActionInterface.php @@ -0,0 +1,18 @@ + $elementType + */ + public function setElementType(string $elementType): void; + + public function getTriggerLabel(): string; + + public function getTriggerHtml(): ?string; + + public function getConfirmationMessage(): ?string; + + public function performAction(ElementQueryInterface $query): bool; + + public function getMessage(): ?string; + + public function getResponse(): ?Response; +} diff --git a/src/Element/Contracts/ElementExporterInterface.php b/src/Element/Contracts/ElementExporterInterface.php new file mode 100644 index 00000000000..4092c1962f9 --- /dev/null +++ b/src/Element/Contracts/ElementExporterInterface.php @@ -0,0 +1,23 @@ + $elementType + */ + public function setElementType(string $elementType): void; + + public function export(ElementQueryInterface $query): mixed; + + public function getFilename(): string; +} diff --git a/src/Element/ElementActions.php b/src/Element/ElementActions.php new file mode 100644 index 00000000000..1dd7e485527 --- /dev/null +++ b/src/Element/ElementActions.php @@ -0,0 +1,147 @@ + $elementType + * @return ElementActionInterface[] + */ + public function availableActions(string $elementType, string $sourceKey, ElementQueryInterface $elementQuery): array + { + $actions = $elementType::actions($sourceKey); + + foreach ($actions as $index => $action) { + $actions[$index] = $this->createAction($action, $elementType); + + if ($elementQuery->trashed) { + if ($actions[$index] instanceof DeleteActionInterface && $actions[$index]->canHardDelete()) { + $actions[$index]->setHardDelete(); + } elseif (! $actions[$index] instanceof Restore) { + unset($actions[$index]); + } + } elseif ($actions[$index] instanceof Restore) { + unset($actions[$index]); + } + } + + $actions = array_values($actions); + + if ($elementQuery->trashed) { + usort($actions, fn (ElementActionInterface $a, ElementActionInterface $b) => match (true) { + $a instanceof Restore => -1, + $b instanceof Restore => 1, + default => 0, + }); + } + + return $actions; + } + + /** + * @param ElementActionInterface|class-string|array{type:class-string} $action + * @param class-string $elementType + */ + public function createAction(mixed $action, string $elementType): ElementActionInterface + { + if ($action instanceof ElementActionInterface) { + $action->setElementType($elementType); + + return $action; + } + + if (is_string($action)) { + $action = ['type' => $action]; + } + + $action['elementType'] = $elementType; + + return ComponentHelper::createComponent($action, ElementActionInterface::class); + } + + /** + * @param iterable $actions + */ + public function serializeActions(iterable $actions): array + { + $data = []; + + foreach ($actions as $action) { + $data[] = ElementHelper::actionConfig($action); + } + + return $data; + } + + /** + * @param iterable $actions + */ + public function resolveAction(iterable $actions, string $actionClass): ?ElementActionInterface + { + foreach ($actions as $availableAction) { + if ($availableAction::class === $actionClass) { + return clone $availableAction; + } + } + + return null; + } + + /** + * @return array{valid:bool,success:bool,message:?string} + */ + public function invoke(ElementActionInterface $action, ElementQueryInterface $query): array + { + if (! $action->validate()) { + return [ + 'valid' => false, + 'success' => false, + 'message' => null, + ]; + } + + event($beforeEvent = new BeforePerformAction( + action: $action, + query: $query, + )); + + if (! $beforeEvent->isValid) { + return [ + 'valid' => true, + 'success' => false, + 'message' => $beforeEvent->message, + ]; + } + + $success = $action->performAction($query); + $message = $action->getMessage(); + + if ($success) { + event(new AfterPerformAction( + action: $action, + query: $query, + message: $message, + )); + } + + return [ + 'valid' => true, + 'success' => $success, + 'message' => $message, + ]; + } +} diff --git a/src/Element/ElementExporters.php b/src/Element/ElementExporters.php new file mode 100644 index 00000000000..719aa49d621 --- /dev/null +++ b/src/Element/ElementExporters.php @@ -0,0 +1,358 @@ + $elementType + * @return ElementExporterInterface[] + */ + public function availableExporters(string $elementType, string $sourceKey): array + { + $exporters = $elementType::exporters($sourceKey); + + foreach ($exporters as $index => $exporter) { + $exporters[$index] = $this->createExporter($exporter, $elementType); + } + + return array_values($exporters); + } + + /** + * @param ElementExporterInterface|class-string|array{type:class-string} $exporter + * @param class-string $elementType + */ + public function createExporter(mixed $exporter, string $elementType): ElementExporterInterface + { + if ($exporter instanceof ElementExporterInterface) { + $exporter->setElementType($elementType); + + return $exporter; + } + + if (is_string($exporter)) { + $exporter = ['type' => $exporter]; + } + + $exporter['elementType'] = $elementType; + + /** @var ElementExporterInterface */ + return ComponentHelper::createComponent($exporter, ElementExporterInterface::class); + } + + /** + * @param iterable $exporters + */ + public function serializeExporters(iterable $exporters): array + { + $data = []; + + foreach ($exporters as $exporter) { + $data[] = [ + 'type' => $exporter::class, + 'name' => $exporter::displayName(), + 'formattable' => $exporter::isFormattable(), + ]; + } + + return $data; + } + + /** + * @param iterable $exporters + */ + public function resolveExporter(iterable $exporters, string $exporterClass): ?ElementExporterInterface + { + class_exists($exporterClass); + + foreach ($exporters as $availableExporter) { + if ( + $availableExporter::class === $exporterClass || + is_a($availableExporter, $exporterClass) + ) { + return clone $availableExporter; + } + } + + return null; + } + + public function export( + ElementExporterInterface $exporter, + ElementQueryInterface $query, + string $format = 'csv', + ): Response { + $filename = $exporter->getFilename(); + $export = $exporter->export($query); + + if ($exporter::isFormattable()) { + $format = $this->normalizeFormat($format); + $filename .= ".$format"; + + if (is_callable($export)) { + $export = $export(); + } + + if (! is_iterable($export)) { + throw new UnexpectedValueException($exporter::class.'::export() must return an array or generator function since isFormattable() returns true.'); + } + + return $this->formattedResponse( + format: $format, + data: $this->normalizeRows($export), + filename: $filename, + rootTag: Str::camel($query->elementType::pluralLowerDisplayName()), + ); + } + + return $this->rawResponse($export, $filename); + } + + private function normalizeFormat(string $format): string + { + if (in_array($format, self::FORMATTABLE_FORMATS, true)) { + return $format; + } + + throw new InvalidArgumentException("Unsupported export format: $format"); + } + + /** + * @param iterable $data + */ + private function normalizeRows(iterable $data): array + { + $rows = []; + + foreach ($data as $row) { + $rows[] = is_array($row) ? $row : (array) $row; + } + + return $rows; + } + + private function formattedResponse(string $format, array $data, string $filename, string $rootTag): Response + { + return match ($format) { + 'csv' => $this->binaryResponse( + content: $this->spreadsheetContent($data, fn (Spreadsheet $spreadsheet): BaseWriter => new Csv($spreadsheet)), + filename: $filename, + contentType: 'text/csv; charset=UTF-8', + ), + 'xlsx' => $this->binaryResponse( + content: $this->spreadsheetContent($data, fn (Spreadsheet $spreadsheet): BaseWriter => new Xlsx($spreadsheet)), + filename: $filename, + contentType: 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet', + ), + 'json' => $this->binaryResponse( + content: Json::encode($data, JSON_PRETTY_PRINT | JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES), + filename: $filename, + contentType: 'application/json; charset=UTF-8', + ), + 'xml' => $this->binaryResponse( + content: (new XmlEncoder)->encode($data, 'xml', [ + 'xml_root_node_name' => $rootTag, + ]), + filename: $filename, + contentType: 'application/xml; charset=UTF-8', + ), + 'yaml' => $this->binaryResponse( + content: Yaml::dump($data, 20, 2), + filename: $filename, + contentType: 'application/x-yaml; charset=UTF-8', + ), + default => throw new InvalidArgumentException("Unsupported export format: $format"), + }; + } + + private function rawResponse(mixed $export, string $filename): Response + { + if ( + is_callable($export) || + is_resource($export) || + (is_array($export) && isset($export[0]) && is_resource($export[0])) + ) { + return $this->streamResponse($export, $filename); + } + + return $this->binaryResponse( + content: is_string($export) ? $export : (string) $export, + filename: $filename, + contentType: 'application/octet-stream', + ); + } + + private function binaryResponse(string|false $content, string $filename, string $contentType): Response + { + return new Response( + content: $content ?: '', + status: 200, + headers: [ + 'Content-Disposition' => HeaderUtils::makeDisposition(HeaderUtils::DISPOSITION_ATTACHMENT, $filename), + 'Content-Type' => $contentType, + ], + ); + } + + private function streamResponse(mixed $stream, string $filename): StreamedResponse + { + return new StreamedResponse( + function () use ($stream): void { + if (is_callable($stream)) { + foreach ($stream() as $chunk) { + echo $chunk; + } + + return; + } + + $chunkSize = 8 * 1024 * 1024; + + if (is_array($stream)) { + [$handle, $begin, $end] = $stream; + + if (stream_get_meta_data($handle)['seekable']) { + fseek($handle, $begin); + } + + while (! feof($handle) && ($position = ftell($handle)) <= $end) { + if ($position + $chunkSize > $end) { + $chunkSize = $end - $position + 1; + } + + echo fread($handle, $chunkSize); + } + + fclose($handle); + + return; + } + + while (! feof($stream)) { + echo fread($stream, $chunkSize); + } + + fclose($stream); + }, + 200, + [ + 'Content-Disposition' => HeaderUtils::makeDisposition(HeaderUtils::DISPOSITION_ATTACHMENT, $filename), + 'Content-Type' => 'application/octet-stream', + ], + ); + } + + /** + * @param callable(Spreadsheet):BaseWriter $writerFactory + */ + private function spreadsheetContent(array $data, callable $writerFactory): string|false + { + [$headers, $rows] = $this->spreadsheetRows($data); + + if ($headers === [] && $rows === []) { + return ''; + } + + $spreadsheet = new Spreadsheet; + $worksheet = $spreadsheet->getActiveSheet(); + + if ($headers !== []) { + $worksheet->fromArray($headers); + $worksheet->getStyle('1')->applyFromArray([ + 'font' => [ + 'bold' => true, + 'color' => ['rgb' => 'FFFFFF'], + ], + 'fill' => [ + 'fillType' => Fill::FILL_SOLID, + 'startColor' => ['rgb' => '000000'], + ], + ]); + } + + if ($rows !== []) { + $worksheet->fromArray($rows, startCell: $headers !== [] ? 'A2' : 'A1'); + } + + $path = tempnam(sys_get_temp_dir(), 'export'); + $handle = fopen($path, 'wb'); + $writerFactory($spreadsheet)->save($handle); + fclose($handle); + $content = file_get_contents($path); + unlink($path); + + return $content; + } + + /** + * @return array{0:array,1:array>} + */ + private function spreadsheetRows(array $data): array + { + if ($data === []) { + return [[], []]; + } + + $headers = []; + + foreach ($data as $row) { + foreach (array_keys($row) as $key) { + $headers[$key] = null; + } + } + + $headerRow = array_keys($headers); + $rows = []; + $suspectCharacters = ['=', '-', '+', '@']; + + foreach ($data as $row) { + $normalizedRow = []; + + foreach ($headerRow as $key) { + $field = $row[$key] ?? ''; + + if (is_scalar($field)) { + $field = (string) $field; + + if ($field !== '' && in_array($field[0], $suspectCharacters, true)) { + $field = "\t$field"; + } + } else { + $field = Json::encode($field); + } + + $normalizedRow[] = $field ?: ''; + } + + $rows[] = $normalizedRow; + } + + return [$headerRow, $rows]; + } +} diff --git a/src/Element/ElementHelper.php b/src/Element/ElementHelper.php index 5713df5e1ac..0d26c526f5c 100644 --- a/src/Element/ElementHelper.php +++ b/src/Element/ElementHelper.php @@ -5,11 +5,11 @@ namespace CraftCms\Cms\Element; use Craft; -use craft\base\ElementActionInterface; use craft\base\ElementInterface; use craft\base\NestedElementInterface; use CraftCms\Cms\Cms; use CraftCms\Cms\Database\Table; +use CraftCms\Cms\Element\Contracts\ElementActionInterface; use CraftCms\Cms\Field\Exceptions\FieldNotFoundException; use CraftCms\Cms\FieldLayout\LayoutElements\CustomField; use CraftCms\Cms\Shared\Exceptions\OperationAbortedException; diff --git a/src/Element/Elements.php b/src/Element/Elements.php index 869c5174030..e3bccc9d503 100644 --- a/src/Element/Elements.php +++ b/src/Element/Elements.php @@ -4,8 +4,6 @@ namespace CraftCms\Cms\Element; -use craft\base\ElementActionInterface; -use craft\base\ElementExporterInterface; use craft\base\ElementInterface; use CraftCms\Cms\Component\ComponentHelper; use CraftCms\Cms\Database\Table; @@ -609,35 +607,6 @@ public function restoreElements(array $elements): bool return app(ElementDeletions::class)->restoreElements($elements); } - // Element Actions & Exporters - // ------------------------------------------------------------------------- - - /** - * Creates an element action with a given config. - * - * @template T of ElementActionInterface - * - * @param class-string|array{type:class-string} $config The element action’s class name, or its config, with a `type` value and optionally a `settings` value - * @return T The element action - */ - public function createAction(string|array $config): ElementActionInterface - { - return ComponentHelper::createComponent($config, ElementActionInterface::class); - } - - /** - * Creates an element exporter with a given config. - * - * @template T of ElementExporterInterface - * - * @param class-string|array{type:class-string} $config The element exporter’s class name, or its config, with a `type` value and optionally a `settings` value - * @return T The element exporter - */ - public function createExporter(string|array $config): ElementExporterInterface - { - return ComponentHelper::createComponent($config, ElementExporterInterface::class); - } - // Misc // ------------------------------------------------------------------------- diff --git a/src/Element/Events/AfterPerformAction.php b/src/Element/Events/AfterPerformAction.php new file mode 100644 index 00000000000..bc3f97e96f5 --- /dev/null +++ b/src/Element/Events/AfterPerformAction.php @@ -0,0 +1,17 @@ + + */ + protected string $elementType; + + public static function isFormattable(): bool + { + return true; + } + + public function setElementType(string $elementType): void + { + $this->elementType = $elementType; + } + + public function getFilename(): string + { + $elementType = $this->elementType; + + return $elementType::pluralLowerDisplayName(); + } +} diff --git a/src/Element/Exporters/Expanded.php b/src/Element/Exporters/Expanded.php new file mode 100644 index 00000000000..55948dde3d0 --- /dev/null +++ b/src/Element/Exporters/Expanded.php @@ -0,0 +1,80 @@ +getAllFields() as $field) { + if (! $field instanceof EagerLoadingFieldInterface) { + continue; + } + + $eagerLoadableFields[] = [ + 'path' => $field->handle, + 'criteria' => [ + 'status' => null, + ], + ]; + } + + $data = []; + + /** @var ElementQuery $query */ + $query->with($eagerLoadableFields); + + $query->each(function (ElementInterface $element) use (&$data) { + $attributes = array_flip($element->attributes()); + + if (($fieldLayout = $element->getFieldLayout()) !== null) { + foreach ($fieldLayout->getCustomFields() as $field) { + unset($attributes[$field->handle]); + } + } + + $datetimeAttributes = ComponentHelper::datetimeAttributes($element); + $otherAttributes = array_diff(array_keys($attributes), $datetimeAttributes); + $elementArr = $element->toArray($otherAttributes); + + foreach ($datetimeAttributes as $attribute) { + $date = $element->$attribute; + $elementArr[$attribute] = $date ? DateTimeHelper::toIso8601($date) : $element->$attribute; + } + + uksort($elementArr, fn ($a, $b) => $attributes[$a] <=> $attributes[$b]); + + if ($fieldLayout !== null) { + foreach ($fieldLayout->getCustomFields() as $field) { + $value = $element->getFieldValue($field->handle); + $elementArr[$field->handle] = $field->serializeValue($value, $element); + } + } + + $data[] = $elementArr; + }, 100); + + return $data; + } +} diff --git a/src/Element/Exporters/Raw.php b/src/Element/Exporters/Raw.php new file mode 100644 index 00000000000..62c30ec6b21 --- /dev/null +++ b/src/Element/Exporters/Raw.php @@ -0,0 +1,23 @@ +asArray()->all(); + } +} diff --git a/src/Entry/Actions/MoveToSection.php b/src/Entry/Actions/MoveToSection.php new file mode 100644 index 00000000000..3283ff41b4c --- /dev/null +++ b/src/Entry/Actions/MoveToSection.php @@ -0,0 +1,56 @@ +elementType !== Entry::class) { + throw new RuntimeException('Move to section is only available for Entries.'); + } + + HtmlStack::jsWithVars(fn ($type) => << { + new Craft.ElementActionTrigger({ + type: $type, + bulk: true, + validateSelection: (selectedItems, elementIndex) => { + for (let i = 0; i < selectedItems.length; i++) { + if (!Garnish.hasAttr(selectedItems.eq(i).find('.element'), 'data-movable')) { + return false; + } + } + + return true; + }, + activate: (selectedItems, elementIndex) => { + let entryIds = []; + for (let i = 0; i < selectedItems.length; i++) { + entryIds.push(selectedItems.eq(i).find('.element').data('id')); + } + + new Craft.EntryMover(entryIds, elementIndex); + }, + }) +})(); +JS, [static::class]); + + return null; + } +} diff --git a/src/Entry/Actions/NewChild.php b/src/Entry/Actions/NewChild.php new file mode 100644 index 00000000000..9dad7e24a20 --- /dev/null +++ b/src/Entry/Actions/NewChild.php @@ -0,0 +1,75 @@ +label)) { + $this->label = t('Create a new child {type}', [ + 'type' => $elementType::lowerDisplayName(), + ]); + } + } + + #[\Override] + public function getTriggerLabel(): string + { + return $this->label; + } + + public function getTriggerHtml(): ?string + { + HtmlStack::jsWithVars(fn ($type, $maxLevels, $newChildUrl) => << { + let trigger = new Craft.ElementActionTrigger({ + type: $type, + bulk: false, + validateSelection: (selectedItems, elementIndex) => { + const element = selectedItems.find('.element'); + return ( + (!$maxLevels || $maxLevels > element.data('level')) && + !Garnish.hasAttr(element, 'data-disallow-new-children') + ); + }, + activate: (selectedItems, elementIndex) => { + const url = Craft.getUrl($newChildUrl, 'parentId=' + selectedItems.find('.element').data('id')); + Craft.redirectTo(url); + }, + }); + + if (Craft.currentElementIndex.view.tableSort) { + Craft.currentElementIndex.view.tableSort.on('positionChange', $.proxy(trigger, 'updateTrigger')); + } +})(); +JS, [static::class, $this->maxLevels, $this->newChildUrl]); + + return null; + } +} diff --git a/src/Entry/Actions/NewSiblingAfter.php b/src/Entry/Actions/NewSiblingAfter.php new file mode 100644 index 00000000000..90e9fbe0a06 --- /dev/null +++ b/src/Entry/Actions/NewSiblingAfter.php @@ -0,0 +1,58 @@ +label)) { + $this->label = t('Create a new {type} after', [ + 'type' => $elementType::lowerDisplayName(), + ]); + } + } + + #[\Override] + public function getTriggerLabel(): string + { + return $this->label; + } + + public function getTriggerHtml(): ?string + { + HtmlStack::jsWithVars(fn ($type, $newSiblingUrl) => << { + new Craft.ElementActionTrigger({ + type: $type, + bulk: false, + activate: (selectedItems, elementIndex) => { + Craft.redirectTo(Craft.getUrl($newSiblingUrl, 'after=' + selectedItems.find('.element').data('id'))); + }, + }); +})(); +JS, [static::class, $this->newSiblingUrl]); + + return null; + } +} diff --git a/src/Entry/Actions/NewSiblingBefore.php b/src/Entry/Actions/NewSiblingBefore.php new file mode 100644 index 00000000000..7f0e20929a6 --- /dev/null +++ b/src/Entry/Actions/NewSiblingBefore.php @@ -0,0 +1,58 @@ +label)) { + $this->label = t('Create a new {type} before', [ + 'type' => $elementType::lowerDisplayName(), + ]); + } + } + + #[\Override] + public function getTriggerLabel(): string + { + return $this->label; + } + + public function getTriggerHtml(): ?string + { + HtmlStack::jsWithVars(fn ($type, $newSiblingUrl) => << { + new Craft.ElementActionTrigger({ + type: $type, + bulk: false, + activate: (selectedItems, elementIndex) => { + Craft.redirectTo(Craft.getUrl($newSiblingUrl, 'before=' + selectedItems.find('.element').data('id'))); + }, + }); +})(); +JS, [static::class, $this->newSiblingUrl]); + + return null; + } +} diff --git a/src/Entry/Elements/Entry.php b/src/Entry/Elements/Entry.php index 16b84012ae4..0b6695ebe6f 100644 --- a/src/Entry/Elements/Entry.php +++ b/src/Entry/Elements/Entry.php @@ -10,15 +10,6 @@ use craft\base\NestedElementTrait; use craft\controllers\ElementIndexesController; use craft\controllers\ElementsController; -use craft\elements\actions\Copy; -use craft\elements\actions\Delete; -use craft\elements\actions\DeleteForSite; -use craft\elements\actions\Duplicate; -use craft\elements\actions\MoveToSection; -use craft\elements\actions\NewChild; -use craft\elements\actions\NewSiblingAfter; -use craft\elements\actions\NewSiblingBefore; -use craft\elements\actions\Restore; use craft\elements\conditions\entries\EntryCondition; use craft\elements\conditions\entries\SectionConditionRule; use craft\elements\conditions\entries\TypeConditionRule; @@ -31,6 +22,11 @@ use CraftCms\Cms\Database\Expressions\FixedOrderExpression; use CraftCms\Cms\Database\Table; use CraftCms\Cms\Edition; +use CraftCms\Cms\Element\Actions\Copy; +use CraftCms\Cms\Element\Actions\Delete; +use CraftCms\Cms\Element\Actions\DeleteForSite; +use CraftCms\Cms\Element\Actions\Duplicate; +use CraftCms\Cms\Element\Actions\Restore; use CraftCms\Cms\Element\Conditions\Contracts\ElementConditionInterface; use CraftCms\Cms\Element\Contracts\ExpirableElementInterface; use CraftCms\Cms\Element\Data\EagerLoadPlan; @@ -40,6 +36,10 @@ use CraftCms\Cms\Element\Queries\Contracts\ElementQueryInterface; use CraftCms\Cms\Element\Queries\EntryQuery; use CraftCms\Cms\Element\Revisions; +use CraftCms\Cms\Entry\Actions\MoveToSection; +use CraftCms\Cms\Entry\Actions\NewChild; +use CraftCms\Cms\Entry\Actions\NewSiblingAfter; +use CraftCms\Cms\Entry\Actions\NewSiblingBefore; use CraftCms\Cms\Entry\Data\EntryType; use CraftCms\Cms\Entry\Events\DefineEntryTypes; use CraftCms\Cms\Entry\Events\DefineMetaFields; @@ -64,6 +64,7 @@ use CraftCms\Cms\Support\Arr; use CraftCms\Cms\Support\DateTimeHelper; use CraftCms\Cms\Support\Facades\DeltaRegistry; +use CraftCms\Cms\Support\Facades\ElementActions; use CraftCms\Cms\Support\Facades\Elements; use CraftCms\Cms\Support\Facades\ElementSources; use CraftCms\Cms\Support\Facades\Entries; @@ -418,22 +419,22 @@ protected static function defineActions(string $source): array $newEntryUrl .= '?site='.$site->handle; } - $actions[] = Elements::createAction([ + $actions[] = ElementActions::createAction([ 'type' => NewSiblingBefore::class, 'newSiblingUrl' => $newEntryUrl, - ]); + ], static::class); - $actions[] = Elements::createAction([ + $actions[] = ElementActions::createAction([ 'type' => NewSiblingAfter::class, 'newSiblingUrl' => $newEntryUrl, - ]); + ], static::class); if ($section->maxLevels !== 1) { - $actions[] = Elements::createAction([ + $actions[] = ElementActions::createAction([ 'type' => NewChild::class, 'maxLevels' => $section->maxLevels, 'newChildUrl' => $newEntryUrl, - ]); + ], static::class); } } diff --git a/src/Http/Controllers/Elements/Concerns/InteractsWithElementIndexes.php b/src/Http/Controllers/Elements/Concerns/InteractsWithElementIndexes.php new file mode 100644 index 00000000000..88e83a4c2e0 --- /dev/null +++ b/src/Http/Controllers/Elements/Concerns/InteractsWithElementIndexes.php @@ -0,0 +1,214 @@ +}|null $conditionConfig */ + $conditionConfig = request()->input('condition'); + + if (! $conditionConfig) { + return null; + } + + /** @var ElementConditionInterface $condition */ + $condition = Conditions::createCondition($conditionConfig); + + if ($condition instanceof ElementCondition) { + $referenceElementId = request()->input('referenceElementId'); + + if ($referenceElementId) { + $criteria = []; + + if ($ownerId = request()->input('referenceElementOwnerId')) { + $criteria['ownerId'] = $ownerId; + } + + $condition->referenceElement = Elements::getElementById( + (int) $referenceElementId, + siteId: request()->input('referenceElementSiteId'), + criteria: $criteria, + ); + } + } + + return $condition; + } + + /** + * @param class-string $elementType + * @return array{0:?string,1:?array} + */ + protected function source(string $elementType, ?string $sourceKey, string $context): array + { + if (! isset($sourceKey)) { + return [$sourceKey, null]; + } + + if ($sourceKey === '__IMP__') { + return [$sourceKey, [ + 'type' => ElementSources::TYPE_NATIVE, + 'key' => '__IMP__', + 'label' => t('All elements'), + 'hasThumbs' => $elementType::hasThumbs(), + ]]; + } + + $source = ElementSourcesFacade::findSource($elementType, $sourceKey, $context); + + if ($source === null) { + $sourceKey = null; + } + + return [$sourceKey, $source]; + } + + protected function viewState(): array + { + $viewState = request()->input('viewState', []); + + if (empty($viewState['mode'])) { + $viewState['mode'] = 'table'; + } + + return $viewState; + } + + /** + * @param class-string $elementType + */ + protected function elementQuery( + string $elementType, + ?array $source, + ?ElementConditionInterface $condition, + ): ElementQueryInterface { + $query = $elementType::find(); + + if (! $source) { + $query->id(false); + + return $query; + } + + if ($source['type'] === ElementSources::TYPE_CUSTOM) { + /** @var ElementConditionInterface $sourceCondition */ + $sourceCondition = Conditions::createCondition($source['condition']); + $sourceCondition->modifyQuery($query); + } + + $applyCriteria = function (array $criteria) use ($query): void { + if (! $criteria) { + return; + } + + if (isset($criteria['trashed'])) { + $criteria['trashed'] = filter_var($criteria['trashed'], FILTER_VALIDATE_BOOLEAN, FILTER_NULL_ON_FAILURE) ?? false; + } + + if (isset($criteria['drafts'])) { + $criteria['drafts'] = filter_var($criteria['drafts'], FILTER_VALIDATE_BOOLEAN, FILTER_NULL_ON_FAILURE) ?? false; + } + + if (isset($criteria['draftOf'])) { + if (is_numeric($criteria['draftOf']) && $criteria['draftOf'] != 0) { + $criteria['draftOf'] = (int) $criteria['draftOf']; + } else { + $criteria['draftOf'] = filter_var($criteria['draftOf'], FILTER_VALIDATE_BOOLEAN, FILTER_NULL_ON_FAILURE); + } + } + + Typecast::configure($query, ElementHelper::cleanseQueryCriteria($criteria)); + }; + + $applyCriteria(request()->input('baseCriteria') ?? []); + + if ($condition) { + $condition->modifyQuery($query); + } + + $applyCriteria(request()->input('criteria') ?? []); + + $filterConditionConfig = request()->input('filterConfig'); + + if (! $filterConditionConfig && $filterConditionStr = request()->input('filters')) { + parse_str((string) $filterConditionStr, $filterConditionConfig); + $filterConditionConfig = $filterConditionConfig['condition'] ?? null; + } + + if ($filterConditionConfig) { + /** @var ElementConditionInterface $filterCondition */ + $filterCondition = Conditions::createCondition($filterConditionConfig); + $filterCondition->modifyQuery($query); + } + + $collapsedElementIds = request()->input('collapsedElementIds'); + + if (! $collapsedElementIds) { + return $query; + } + + $descendantQuery = (clone $query) + ->offset(null) + ->limit(null) + ->reorder() + ->positionedAfter(null) + ->positionedBefore(null) + ->status(null); + + $collapsedElements = (clone $descendantQuery) + ->id($collapsedElementIds) + ->orderBy('lft') + ->all(); + + if (empty($collapsedElements)) { + return $query; + } + + $descendantIds = []; + + foreach ($collapsedElements as $element) { + if (in_array($element->id, $descendantIds, false)) { + continue; + } + + $elementDescendantIds = (clone $descendantQuery) + ->descendantOf($element) + ->ids(); + + $descendantIds = array_merge($descendantIds, $elementDescendantIds); + } + + if (empty($descendantIds)) { + return $query; + } + + return $query instanceof ElementQuery + ? $query->whereNotIn('elements.id', $descendantIds) + : $query->andWhere(new ExcludeDescendantIdsExpression($descendantIds)); + } + + protected function isAdministrative(string $context): bool + { + return in_array($context, ['index', 'embedded-index'], true); + } +} diff --git a/src/Http/Controllers/Elements/ExportElementIndexController.php b/src/Http/Controllers/Elements/ExportElementIndexController.php new file mode 100644 index 00000000000..e7f272d041f --- /dev/null +++ b/src/Http/Controllers/Elements/ExportElementIndexController.php @@ -0,0 +1,78 @@ +request->validate([ + 'elementType' => [ + 'required', + 'string', + function (string $attribute, mixed $value, Closure $fail): void { + if (! is_string($value) || ! is_subclass_of($value, ElementInterface::class)) { + $fail(new InvalidTypeException((string) $value, ElementInterface::class)->getMessage()); + } + }, + ], + 'type' => ['sometimes', 'string'], + 'format' => ['sometimes', 'string'], + ]); + + /** @var class-string $elementType */ + $elementType = $validated['elementType']; + $context = $this->request->input('context', ElementSources::CONTEXT_INDEX); + + [$sourceKey, $source] = $this->source($elementType, $this->request->input('source'), $context); + abort_if(! isset($sourceKey), 400, 'Request missing required body param'); + abort_if(! $this->isAdministrative($context), 400, 'Request missing index context'); + + $exporters = $this->availableExporters($elementType, $sourceKey); + $exporter = $this->elementExporters->resolveExporter( + $exporters, + $this->request->input('type', Raw::class), + ); + + abort_if($exporter === null, 400, 'Element exporter is not supported by the element type'); + + return $this->elementExporters->export( + exporter: $exporter, + query: $this->elementQuery($elementType, $source, $this->condition()), + format: $this->request->input('format', 'csv'), + ); + } + + /** + * @param class-string $elementType + * @return ElementExporterInterface[] + */ + private function availableExporters(string $elementType, string $sourceKey): array + { + if ($this->request->isMobileBrowser()) { + return []; + } + + return $this->elementExporters->availableExporters($elementType, $sourceKey); + } +} diff --git a/src/Http/Controllers/Elements/PerformElementActionController.php b/src/Http/Controllers/Elements/PerformElementActionController.php new file mode 100644 index 00000000000..fd8ad0884cf --- /dev/null +++ b/src/Http/Controllers/Elements/PerformElementActionController.php @@ -0,0 +1,231 @@ +request->validate([ + 'elementType' => [ + 'required', + 'string', + function (string $attribute, mixed $value, Closure $fail): void { + if (! is_string($value) || ! is_subclass_of($value, ElementInterface::class)) { + $fail(new InvalidTypeException((string) $value, ElementInterface::class)->getMessage()); + } + }, + ], + 'elementAction' => ['required', 'string'], + 'elementIds' => ['required', 'array'], + ]); + + /** @var class-string $elementType */ + $elementType = $validated['elementType']; + $actionClass = $validated['elementAction']; + $elementIds = $validated['elementIds']; + $context = $this->request->input('context', ElementSources::CONTEXT_INDEX); + + [$sourceKey, $source] = $this->source($elementType, $this->request->input('source'), $context); + $condition = $this->condition(); + $viewState = $this->viewState(); + $elementQuery = $this->elementQuery($elementType, $source, $condition); + + $actions = null; + $exporters = null; + + if ($this->isAdministrative($context) && isset($sourceKey)) { + $actions = $this->availableActions($elementType, $sourceKey, $elementQuery); + $exporters = $this->availableExporters($elementType, $sourceKey); + } + + $action = $this->elementActions->resolveAction($actions ?? [], $actionClass); + abort_if($action === null, 400, 'Element action is not supported by the element type'); + + foreach ($action->settingsAttributes() as $paramName) { + $paramValue = $this->request->input($paramName); + + if ($paramValue !== null) { + $action->$paramName = $paramValue; + } + } + + $result = $this->elementActions->invoke( + action: $action, + query: (clone $elementQuery) + ->offset(0) + ->limit(null) + ->reorder() + ->positionedAfter(null) + ->positionedBefore(null) + ->id($elementIds) + ); + + abort_if(! $result['valid'], 400, 'Element action params did not validate'); + + if ($action->isDownload()) { + return $action->getResponse() ?? abort(500, 'Download element actions must provide a response'); + } + + if (! $result['success']) { + return $this->asFailure($result['message']); + } + + $responseData = $this->elementResponseData( + elementType: $elementType, + elementQuery: $elementQuery, + viewState: $viewState, + sourceKey: $sourceKey, + context: $context, + actions: $actions, + exporters: $exporters, + includeContainer: true, + includeActions: true, + ); + + $formatter = $this->i18N->getFormatter(); + + foreach ($this->elementSources->getSources($elementType, $context) as $source) { + if (! isset($source['key'])) { + continue; + } + + $responseData['badgeCounts'][$source['key']] = isset($source['badgeCount']) + ? $formatter->asDecimal($source['badgeCount'], 0) + : null; + } + + return $this->asSuccess($result['message'], $responseData); + } + + /** + * @param class-string $elementType + */ + private function availableActions( + string $elementType, + string $sourceKey, + ElementQueryInterface $elementQuery, + ): array { + return $this->elementActions->availableActions($elementType, $sourceKey, $elementQuery); + } + + /** + * @param class-string $elementType + * @return ElementExporterInterface[]|null + */ + private function availableExporters(string $elementType, string $sourceKey): ?array + { + if ($this->request->isMobileBrowser()) { + return null; + } + + return $this->elementExporters->availableExporters($elementType, $sourceKey); + } + + /** + * @param class-string $elementType + * @param ElementExporterInterface[]|null $exporters + */ + private function elementResponseData( + string $elementType, + ElementQueryInterface $elementQuery, + array $viewState, + ?string $sourceKey, + string $context, + ?array $actions, + ?array $exporters, + bool $includeContainer, + bool $includeActions, + ): array { + $responseData = []; + + if ($includeActions) { + $responseData['actions'] = $viewState['static'] === true ? [] : $this->actionData($actions); + $responseData['actionsHeadHtml'] = HtmlStack::headHtml(); + $responseData['actionsBodyHtml'] = HtmlStack::bodyHtml(); + $responseData['exporters'] = $this->exporterData($exporters); + } + + $disabledElementIds = $this->request->input('disabledElementIds', []); + $selectable = ( + ((! empty($actions)) || $this->request->boolean('selectable')) && + empty($viewState['inlineEditing']) + ); + $sortable = $this->isAdministrative($context) && $this->request->boolean('sortable'); + + if ($sourceKey) { + $responseData['html'] = $elementType::indexHtml( + $elementQuery, + $disabledElementIds, + $viewState, + $sourceKey, + $context, + $includeContainer, + $selectable, + $sortable, + ); + $responseData['headHtml'] = HtmlStack::headHtml(); + $responseData['bodyHtml'] = HtmlStack::bodyHtml(); + + return $responseData; + } + + $responseData['html'] = Html::tag('div', t('Nothing yet.'), [ + 'class' => ['zilch', 'small'], + ]); + + return $responseData; + } + + private function actionData(?array $actions): ?array + { + if (empty($actions)) { + return null; + } + + return $this->elementActions->serializeActions($actions); + } + + /** + * @param ElementExporterInterface[]|null $exporters + */ + private function exporterData(?array $exporters): ?array + { + if (empty($exporters)) { + return null; + } + + return $this->elementExporters->serializeExporters($exporters); + } +} diff --git a/src/Support/Facades/ElementActions.php b/src/Support/Facades/ElementActions.php new file mode 100644 index 00000000000..36e7b28317d --- /dev/null +++ b/src/Support/Facades/ElementActions.php @@ -0,0 +1,28 @@ +hard = true; + } + + #[Override] + public function getTriggerLabel(): string + { + if ($this->hard) { + return t('Delete permanently'); + } + + return t('Delete…'); + } + + #[Override] + public static function isDestructive(): bool + { + return true; + } + + public function getTriggerHtml(): ?string + { + if ($this->hard) { + return '
'.$this->getTriggerLabel().'
'; + } + + HtmlStack::jsWithVars( + fn ($type, $redirect) => << { + new Craft.ElementActionTrigger({ + type: $type, + bulk: true, + validateSelection: (selectedItems, elementIndex) => { + for (let i = 0; i < selectedItems.length; i++) { + if (!Garnish.hasAttr(selectedItems.eq(i).find('.element'), 'data-deletable')) { + return false; + } + } + return true; + }, + activate: (selectedItems, elementIndex) => { + elementIndex.setIndexBusy(); + const ids = elementIndex.getSelectedElementIds(); + const data = {userId: ids}; + Craft.sendActionRequest('POST', 'users/user-content-summary', {data}) + .then((response) => { + const modal = new Craft.DeleteUserModal(ids, { + contentSummary: response.data, + onSubmit: () => { + elementIndex.submitAction($type, Garnish.getPostData(modal.\$container)) + modal.hide(); + return false; + }, + redirect: $redirect + }) + }) + .finally(() => { + elementIndex.setIndexAvailable(); + }); + }, + }) +})(); +JS, + [ + static::class, + Crypt::encrypt(Edition::get() === Edition::Solo ? 'dashboard' : 'users'), + ]); + + return null; + } + + public function getConfirmationMessage(): ?string + { + if ($this->hard) { + return t('Are you sure you want to permanently delete the selected {type}?', [ + 'type' => User::pluralLowerDisplayName(), + ]); + } + + return null; + } + + #[Override] + public function performAction(ElementQueryInterface $query): bool + { + /** @var User[] $users */ + $users = $query->all(); + + // Are we transferring the user’s content to a different user? + if (is_array($this->transferContentTo)) { + $this->transferContentTo = reset($this->transferContentTo) ?: null; + } + + if ($this->transferContentTo) { + $transferContentTo = Users::getUserById((int) $this->transferContentTo); + + if (! $transferContentTo) { + throw new RuntimeException("No user exists with the ID “{$this->transferContentTo}”"); + } + } else { + $transferContentTo = null; + } + + // Delete the users + $deletedCount = 0; + + foreach ($users as $user) { + if (Gate::check('delete', $user)) { + $user->inheritorOnDelete = $transferContentTo; + if (Elements::deleteElement($user, $this->hard)) { + $deletedCount++; + } + } + } + + if ($deletedCount !== count($users)) { + if ($deletedCount === 0) { + $this->setMessage(t('Couldn’t delete {type}.', [ + 'type' => User::pluralLowerDisplayName(), + ])); + } else { + $this->setMessage(t('Couldn’t delete all {type}.', [ + 'type' => User::pluralLowerDisplayName(), + ])); + } + + return false; + } + + $this->setMessage(t('{type} deleted.', [ + 'type' => User::pluralDisplayName(), + ])); + + return true; + } +} diff --git a/src/User/Actions/SuspendUsers.php b/src/User/Actions/SuspendUsers.php new file mode 100644 index 00000000000..10dc3cdcb7d --- /dev/null +++ b/src/User/Actions/SuspendUsers.php @@ -0,0 +1,91 @@ + << { + 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, + Craft::$app->getUser()->getId(), + ]); + + return null; + } + + #[\Override] + public function performAction(ElementQueryInterface $query): bool + { + // Get the users that aren't already suspended + $query->status = UserQuery::STATUS_CREDENTIALED; + + /** @var User[] $users */ + $users = $query->all(); + $currentUser = Auth::user(); + + $successCount = count(array_filter($users, function (User $user) use ($currentUser) { + try { + if (! Users::canSuspend($currentUser, $user)) { + return false; + } + Users::suspendUser($user); + + return true; + } catch (Throwable) { + return false; + } + })); + + if ($successCount !== count($users)) { + $this->setMessage(t('Couldn’t suspend all users.')); + + return false; + } + + $this->setMessage(t('Users suspended.')); + + return true; + } +} diff --git a/src/User/Actions/UnsuspendUsers.php b/src/User/Actions/UnsuspendUsers.php new file mode 100644 index 00000000000..117dbda34ac --- /dev/null +++ b/src/User/Actions/UnsuspendUsers.php @@ -0,0 +1,86 @@ + << { + 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 + { + // Get the users that are suspended + $query->status(User::STATUS_SUSPENDED); + /** @var User[] $users */ + $users = $query->all(); + $currentUser = Auth::user(); + + $successCount = count(array_filter($users, function (User $user) use ($currentUser) { + if (! Users::canSuspend($currentUser, $user)) { + return false; + } + try { + Users::unsuspendUser($user); + + return true; + } catch (Throwable) { + return false; + } + })); + + if ($successCount !== count($users)) { + $this->setMessage(t('Couldn’t unsuspend all users.')); + + return false; + } + + $this->setMessage(t('Users unsuspended.')); + + return true; + } +} diff --git a/src/User/Elements/User.php b/src/User/Elements/User.php index a619e787c95..af4e55bb913 100644 --- a/src/User/Elements/User.php +++ b/src/User/Elements/User.php @@ -6,10 +6,6 @@ use Craft; use craft\base\ElementInterface; -use craft\elements\actions\DeleteUsers; -use craft\elements\actions\Restore; -use craft\elements\actions\SuspendUsers; -use craft\elements\actions\UnsuspendUsers; use craft\elements\conditions\users\UserCondition; use craft\elements\NestedElementManager; use CraftCms\Cms\Address\Elements\Address; @@ -21,6 +17,7 @@ use CraftCms\Cms\Cp\Html\StatusHtml; use CraftCms\Cms\Database\Table; use CraftCms\Cms\Edition; +use CraftCms\Cms\Element\Actions\Restore; use CraftCms\Cms\Element\Conditions\Contracts\ElementConditionInterface; use CraftCms\Cms\Element\Data\EagerLoadPlan; use CraftCms\Cms\Element\Element; @@ -54,6 +51,9 @@ use CraftCms\Cms\Support\Url; use CraftCms\Cms\Translation\Formatter; use CraftCms\Cms\Twig\Attributes\AllowedInSandbox; +use CraftCms\Cms\User\Actions\DeleteUsers; +use CraftCms\Cms\User\Actions\SuspendUsers; +use CraftCms\Cms\User\Actions\UnsuspendUsers; use CraftCms\Cms\User\Data\UserGroup; use CraftCms\Cms\User\Events\DefineFriendlyName; use CraftCms\Cms\User\Events\DefineName; diff --git a/tests/Feature/Element/Actions/DeleteActionTest.php b/tests/Feature/Element/Actions/DeleteActionTest.php new file mode 100644 index 00000000000..857065cb518 --- /dev/null +++ b/tests/Feature/Element/Actions/DeleteActionTest.php @@ -0,0 +1,37 @@ +createElement(); + + postJson(action(PerformElementActionController::class), [ + 'context' => 'index', + 'source' => '*', + 'viewState' => [ + 'mode' => 'table', + 'static' => false, + ], + 'elementType' => Entry::class, + 'elementAction' => Delete::class, + 'elementIds' => [$entry->id], + ])->assertOk(); + + expect(DB::table(Table::ELEMENTS)->where('id', $entry->id)->value('dateDeleted')) + ->not()->toBeNull(); +}); diff --git a/tests/Feature/Element/Actions/DuplicateActionTest.php b/tests/Feature/Element/Actions/DuplicateActionTest.php new file mode 100644 index 00000000000..d0258ad18af --- /dev/null +++ b/tests/Feature/Element/Actions/DuplicateActionTest.php @@ -0,0 +1,37 @@ +createElement(); + $beforeCount = DB::table(Table::ELEMENTS)->count(); + + postJson(action(PerformElementActionController::class), [ + 'context' => 'index', + 'source' => '*', + 'viewState' => [ + 'mode' => 'table', + 'static' => false, + ], + 'elementType' => Entry::class, + 'elementAction' => Duplicate::class, + 'elementIds' => [$entry->id], + ])->assertOk(); + + expect(DB::table(Table::ELEMENTS)->count())->toBeGreaterThan($beforeCount); +}); diff --git a/tests/Feature/Element/Actions/ElementActionsTest.php b/tests/Feature/Element/Actions/ElementActionsTest.php new file mode 100644 index 00000000000..0890ba575ca --- /dev/null +++ b/tests/Feature/Element/Actions/ElementActionsTest.php @@ -0,0 +1,166 @@ +elementActions = app(ElementActions::class); +}); + +it('creates actions from strings, arrays, and existing instances', function () { + $fromString = $this->elementActions->createAction(Delete::class, Entry::class); + $fromArray = $this->elementActions->createAction([ + 'type' => Delete::class, + 'successMessage' => 'Deleted from array.', + ], Entry::class); + $existing = new Delete; + $fromInstance = $this->elementActions->createAction($existing, Entry::class); + + expect($fromString)->toBeInstanceOf(Delete::class) + ->and($fromString->getConfirmationMessage())->toContain('entries') + ->and($fromArray)->toBeInstanceOf(Delete::class) + ->and($fromArray->successMessage)->toBe('Deleted from array.') + ->and($fromInstance)->toBe($existing) + ->and($fromInstance->getConfirmationMessage())->toContain('entries'); +}); + +it('resolves canonical entry actions for non-trashed queries', function () { + $actions = $this->elementActions->availableActions(Entry::class, '*', Entry::find()); + $types = array_map(fn ($action) => $action::class, $actions); + + expect($types)->toContain(View::class) + ->and($types)->toContain(Edit::class) + ->and($types)->toContain(Duplicate::class) + ->and($types)->toContain(SetStatus::class) + ->and($types)->toContain(Delete::class) + ->and($types)->not->toContain(Restore::class); +}); + +it('puts restore first for trashed queries', function () { + $actions = $this->elementActions->availableActions(Entry::class, '*', Entry::find()->trashed()); + + expect($actions[0]::class)->toBe(Restore::class); +}); + +it('resolves canonical user and address actions', function () { + $userActions = array_map( + fn ($action) => $action::class, + $this->elementActions->availableActions(User::class, '*', User::find()->status(null)), + ); + + $addressActions = array_map( + fn ($action) => $action::class, + $this->elementActions->availableActions(Address::class, '*', Address::find()), + ); + + expect($userActions)->toContain(SuspendUsers::class) + ->and($userActions)->toContain(UnsuspendUsers::class) + ->and($userActions)->toContain(DeleteUsers::class) + ->and($addressActions)->toContain(Copy::class); +}); + +it('serializes nested-manager action configs with canonical types', function () { + $owner = User::findOne(); + + $serialized = $this->elementActions->serializeActions([ + new ChangeSortOrder($owner, 'addresses'), + new MoveUp($owner, 'addresses'), + new MoveDown($owner, 'addresses'), + ]); + + expect(array_column($serialized, 'type'))->toBe([ + ChangeSortOrder::class, + MoveUp::class, + MoveDown::class, + ]); +}); + +it('resolves a cloned matching action and returns null when missing', function () { + $actions = [ + $this->elementActions->createAction(Delete::class, Entry::class), + $this->elementActions->createAction(Duplicate::class, Entry::class), + ]; + + $resolved = $this->elementActions->resolveAction($actions, Delete::class); + $missing = $this->elementActions->resolveAction($actions, Restore::class); + + expect($resolved)->toBeInstanceOf(Delete::class) + ->and($resolved)->not->toBe($actions[0]) + ->and($missing)->toBeNull(); +}); + +it('invokes actions and dispatches before and after events on success', function () { + Event::fake([ + BeforePerformAction::class, + AfterPerformAction::class, + ]); + + $action = new class extends ElementAction + { + public function performAction(ElementQueryInterface $query): bool + { + $this->setMessage('Action succeeded.'); + + return true; + } + }; + + $result = $this->elementActions->invoke($action, Entry::find()->id([])); + + expect($result)->toBe([ + 'valid' => true, + 'success' => true, + 'message' => 'Action succeeded.', + ]); + + Event::assertDispatched(BeforePerformAction::class); + Event::assertDispatched(AfterPerformAction::class); +}); + +it('returns an invalid result when action validation fails', function () { + $action = new class extends ElementAction + { + public ?string $status = null; + + public function getRules(): array + { + return [ + 'status' => ['required'], + ]; + } + }; + + $result = $this->elementActions->invoke($action, Entry::find()->id([])); + + expect($result)->toBe([ + 'valid' => false, + 'success' => false, + 'message' => null, + ]); +}); diff --git a/tests/Feature/Element/Actions/RestoreActionTest.php b/tests/Feature/Element/Actions/RestoreActionTest.php new file mode 100644 index 00000000000..e18a3595fdc --- /dev/null +++ b/tests/Feature/Element/Actions/RestoreActionTest.php @@ -0,0 +1,40 @@ +createElement(); + Elements::deleteElement($entry); + + postJson(action(PerformElementActionController::class), [ + 'context' => 'index', + 'source' => '*', + 'viewState' => [ + 'mode' => 'table', + 'static' => false, + ], + 'elementType' => Entry::class, + 'elementAction' => Restore::class, + 'elementIds' => [$entry->id], + 'criteria' => ['trashed' => true], + ])->assertOk(); + + expect(DB::table(Table::ELEMENTS)->where('id', $entry->id)->value('dateDeleted')) + ->toBeNull(); +}); diff --git a/tests/Feature/Element/Actions/SetStatusActionTest.php b/tests/Feature/Element/Actions/SetStatusActionTest.php new file mode 100644 index 00000000000..186d60c2706 --- /dev/null +++ b/tests/Feature/Element/Actions/SetStatusActionTest.php @@ -0,0 +1,52 @@ +createElement(); + + postJson(action(PerformElementActionController::class), [ + 'context' => 'index', + 'source' => '*', + 'viewState' => [ + 'mode' => 'table', + 'static' => false, + ], + 'elementType' => Entry::class, + 'elementAction' => SetStatus::class, + 'elementIds' => [$entry->id], + 'status' => SetStatus::DISABLED, + ])->assertOk(); + + expect(Entry::find()->id($entry->id)->status(Entry::STATUS_DISABLED)->one()) + ->not()->toBeNull(); +}); + +it('returns 400 when action params fail validation', function () { + $entry = EntryModel::factory()->createElement(); + + postJson(action(PerformElementActionController::class), [ + 'context' => 'index', + 'source' => '*', + 'viewState' => [ + 'mode' => 'table', + 'static' => false, + ], + 'elementType' => Entry::class, + 'elementAction' => SetStatus::class, + 'elementIds' => [$entry->id], + ])->assertStatus(400); +}); diff --git a/tests/Feature/Element/Concerns/HasActionsTest.php b/tests/Feature/Element/Concerns/HasActionsTest.php index a153563ae82..0bf756f6a73 100644 --- a/tests/Feature/Element/Concerns/HasActionsTest.php +++ b/tests/Feature/Element/Concerns/HasActionsTest.php @@ -2,11 +2,11 @@ declare(strict_types=1); -use craft\elements\actions\Delete; -use craft\elements\actions\Duplicate; -use craft\elements\actions\Edit; -use craft\elements\actions\SetStatus; -use craft\elements\actions\View; +use CraftCms\Cms\Element\Actions\Delete; +use CraftCms\Cms\Element\Actions\Duplicate; +use CraftCms\Cms\Element\Actions\Edit; +use CraftCms\Cms\Element\Actions\SetStatus; +use CraftCms\Cms\Element\Actions\View; use CraftCms\Cms\Element\Element; use CraftCms\Cms\Element\Events\RegisterActions; use CraftCms\Cms\Entry\Elements\Entry; diff --git a/tests/Feature/Element/Exporters/ElementExportersTest.php b/tests/Feature/Element/Exporters/ElementExportersTest.php new file mode 100644 index 00000000000..b162bd90165 --- /dev/null +++ b/tests/Feature/Element/Exporters/ElementExportersTest.php @@ -0,0 +1,104 @@ +elementExporters = app(ElementExporters::class); +}); + +it('creates exporters from strings, arrays, and existing instances', function () { + $fromString = $this->elementExporters->createExporter(Raw::class, Entry::class); + $fromArray = $this->elementExporters->createExporter([ + 'type' => Raw::class, + ], Entry::class); + $existing = new Raw; + $fromInstance = $this->elementExporters->createExporter($existing, Entry::class); + + expect($fromString)->toBeInstanceOf(Raw::class) + ->and($fromString->getFilename())->toBe('entries') + ->and($fromArray)->toBeInstanceOf(Raw::class) + ->and($fromArray->getFilename())->toBe('entries') + ->and($fromInstance)->toBe($existing) + ->and($fromInstance->getFilename())->toBe('entries'); +}); + +it('resolves canonical exporters for a source', function () { + $exporters = $this->elementExporters->availableExporters(Entry::class, '*'); + $types = array_map(fn (ElementExporterInterface $exporter) => $exporter::class, $exporters); + + expect($types)->toBe([ + Raw::class, + Expanded::class, + ]); +}); + +it('serializes exporter configs for the control panel', function () { + $serialized = $this->elementExporters->serializeExporters( + $this->elementExporters->availableExporters(Entry::class, '*'), + ); + + expect($serialized)->toBe([ + [ + 'type' => Raw::class, + 'name' => Raw::displayName(), + 'formattable' => true, + ], + [ + 'type' => Expanded::class, + 'name' => Expanded::displayName(), + 'formattable' => true, + ], + ]); +}); + +it('resolves a cloned matching exporter and returns null when missing', function () { + $exporters = $this->elementExporters->availableExporters(Entry::class, '*'); + + $resolved = $this->elementExporters->resolveExporter($exporters, Raw::class); + $missing = $this->elementExporters->resolveExporter($exporters, 'App\\MissingExporter'); + + expect($resolved)->toBeInstanceOf(Raw::class) + ->and($resolved)->not->toBe($exporters[0]) + ->and($missing)->toBeNull(); +}); + +it('accepts legacy alias class names when resolving exporters', function () { + $exporters = $this->elementExporters->availableExporters(Entry::class, '*'); + + expect($this->elementExporters->resolveExporter($exporters, craft\elements\exporters\Raw::class)) + ->toBeInstanceOf(Raw::class); +}); + +it('honors register exporters listeners when building available exporters', function () { + $customExporter = new class extends Raw + { + public static function displayName(): string + { + return 'Custom'; + } + }; + + Event::listen(function (RegisterExporters $event) use ($customExporter) { + if ($event->elementType === Entry::class) { + $event->exporters[] = clone $customExporter; + } + }); + + $exporters = $this->elementExporters->availableExporters(Entry::class, '*'); + + expect(array_map(fn (ElementExporterInterface $exporter) => $exporter::class, $exporters)) + ->toContain($customExporter::class); +}); diff --git a/tests/Feature/Http/Controllers/Elements/ExportElementIndexControllerTest.php b/tests/Feature/Http/Controllers/Elements/ExportElementIndexControllerTest.php new file mode 100644 index 00000000000..32209c0d7a9 --- /dev/null +++ b/tests/Feature/Http/Controllers/Elements/ExportElementIndexControllerTest.php @@ -0,0 +1,180 @@ +export = fn (array $payload = []) => post( + action(ExportElementIndexController::class), + array_merge([ + 'context' => 'index', + 'source' => '*', + 'elementType' => Entry::class, + ], $payload), + ); +}); + +it('returns 400 for unsupported exporters', function () { + ($this->export)([ + 'type' => 'App\\MissingExporter', + ])->assertStatus(400); +}); + +it('exports only the selected ids when criteria include an id filter', function () { + $included = EntryModel::factory()->createElement(['title' => 'Included']); + EntryModel::factory()->createElement(['title' => 'Excluded']); + + $response = ($this->export)([ + 'type' => Raw::class, + 'format' => 'json', + 'criteria' => [ + 'id' => [$included->id], + 'status' => null, + ], + ]); + + $response->assertOk(); + + $payload = json_decode((string) $response->getContent(), true, flags: JSON_THROW_ON_ERROR); + + expect($payload)->toHaveCount(1) + ->and($payload[0]['id'])->toBe($included->id); +}); + +it('exports the full query with an explicit limit when no ids are selected', function () { + EntryModel::factory()->createElement(['title' => 'First']); + EntryModel::factory()->createElement(['title' => 'Second']); + + $response = ($this->export)([ + 'type' => Raw::class, + 'format' => 'json', + 'criteria' => [ + 'limit' => 1, + 'status' => null, + ], + ]); + + $response->assertOk(); + + expect(json_decode((string) $response->getContent(), true, flags: JSON_THROW_ON_ERROR))->toHaveCount(1); +}); + +it('returns download responses for each supported formattable format', function (string $format, string $contentType, string $exporterClass) { + EntryModel::factory()->createElement(['title' => 'Export me']); + + $response = ($this->export)([ + 'type' => $exporterClass, + 'format' => $format, + ]); + + $response->assertOk(); + + expect($response->headers->get('content-disposition'))->toContain(".$format") + ->and($response->headers->get('content-type'))->toContain($contentType) + ->and($response->getContent())->not->toBe(''); +})->with([ + 'csv' => ['csv', 'text/csv', Raw::class], + 'xlsx' => ['xlsx', 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet', Raw::class], + 'json' => ['json', 'application/json', Raw::class], + 'xml' => ['xml', 'application/xml', Expanded::class], + 'yaml' => ['yaml', 'application/x-yaml', Raw::class], +]); + +it('uses the entry root tag for xml exports', function () { + EntryModel::factory()->createElement(['title' => 'Export me']); + + $response = ($this->export)([ + 'type' => Raw::class, + 'format' => 'xml', + ]); + + $response->assertOk(); + + expect($response->getContent())->toContain(''); +}); + +it('returns raw string responses for non formattable exporters', function () { + $exporter = new class extends ElementExporter + { + public static function isFormattable(): bool + { + return false; + } + + public static function displayName(): string + { + return 'String export'; + } + + public function export(ElementQueryInterface $query): mixed + { + return 'plain export'; + } + }; + + Event::listen(function (RegisterExporters $event) use ($exporter) { + if ($event->elementType === Entry::class) { + $event->exporters[] = clone $exporter; + } + }); + + $response = ($this->export)([ + 'type' => $exporter::class, + ]); + + $response->assertOk(); + + expect($response->headers->get('content-type'))->toContain('application/octet-stream') + ->and($response->getContent())->toBe('plain export'); +}); + +it('returns streamed responses for non formattable stream exporters', function () { + $exporter = new class extends ElementExporter + { + public static function isFormattable(): bool + { + return false; + } + + public static function displayName(): string + { + return 'Stream export'; + } + + public function export(ElementQueryInterface $query): mixed + { + return function (): iterable { + yield 'streamed'; + }; + } + }; + + Event::listen(function (RegisterExporters $event) use ($exporter) { + if ($event->elementType === Entry::class) { + $event->exporters[] = clone $exporter; + } + }); + + $response = ($this->export)([ + 'type' => $exporter::class, + ]); + + $response->assertOk(); + + expect($response->streamedContent())->toBe('streamed'); +}); diff --git a/tests/Feature/Http/Controllers/Elements/PerformElementActionControllerTest.php b/tests/Feature/Http/Controllers/Elements/PerformElementActionControllerTest.php new file mode 100644 index 00000000000..00235e898e4 --- /dev/null +++ b/tests/Feature/Http/Controllers/Elements/PerformElementActionControllerTest.php @@ -0,0 +1,118 @@ +performElementAction = fn (array $payload = []) => postJson( + action(PerformElementActionController::class), + array_merge([ + 'context' => 'index', + 'source' => '*', + 'viewState' => [ + 'mode' => 'table', + 'static' => false, + ], + ], $payload), + ); +}); + +it('requires authentication', function () { + auth()->logout(); + + postJson(action(PerformElementActionController::class), [ + 'elementType' => Entry::class, + ])->assertUnauthorized(); +}); + +it('validates required request params', function () { + ($this->performElementAction)([ + 'elementType' => Entry::class, + ])->assertUnprocessable(); +}); + +it('returns 400 for unsupported actions', function () { + ($this->performElementAction)([ + 'elementType' => Entry::class, + 'elementAction' => Delete::class, + 'elementIds' => [1], + 'source' => null, + ])->assertStatus(400); +}); + +it('returns native Laravel download responses for download actions', function () { + $entry = EntryModel::factory()->createElement(); + + $action = new class extends ElementAction + { + public static function isDownload(): bool + { + return true; + } + + public function performAction(ElementQueryInterface $query): bool + { + $this->setResponse(new Response( + content: 'downloaded', + status: 200, + headers: [ + 'Content-Disposition' => 'attachment; filename=entries.txt', + ], + )); + + return true; + } + }; + + Event::listen(function (RegisterActions $event) use ($action) { + if ($event->elementType === Entry::class) { + $event->actions[] = clone $action; + } + }); + + $response = post(action(PerformElementActionController::class), [ + 'context' => 'index', + 'source' => '*', + 'viewState' => [ + 'mode' => 'table', + 'static' => false, + ], + 'elementType' => Entry::class, + 'elementAction' => $action::class, + 'elementIds' => [$entry->id], + ]); + + $response->assertOk(); + + expect($response->headers->get('content-disposition'))->toContain('entries.txt') + ->and($response->getContent())->toBe('downloaded'); +}); + +it('includes exporter metadata in the refreshed element response', function () { + $entry = EntryModel::factory()->createElement(); + + ($this->performElementAction)([ + 'elementType' => Entry::class, + 'elementAction' => Delete::class, + 'elementIds' => [$entry->id], + ])->assertOk() + ->assertJsonPath('exporters.0.type', Raw::class) + ->assertJsonPath('exporters.0.formattable', true); +}); diff --git a/tests/Feature/User/Actions/DeleteUsersActionTest.php b/tests/Feature/User/Actions/DeleteUsersActionTest.php new file mode 100644 index 00000000000..75e4c4a7b26 --- /dev/null +++ b/tests/Feature/User/Actions/DeleteUsersActionTest.php @@ -0,0 +1,37 @@ +createElement(['admin' => false]); + + postJson(action(PerformElementActionController::class), [ + 'context' => 'index', + 'source' => '*', + 'viewState' => [ + 'mode' => 'table', + 'static' => false, + ], + 'elementType' => User::class, + 'elementAction' => DeleteUsers::class, + 'elementIds' => [$user->id], + 'criteria' => ['status' => null], + ])->assertOk(); + + expect(DB::table(Table::ELEMENTS)->where('id', $user->id)->value('dateDeleted')) + ->not()->toBeNull(); +}); diff --git a/tests/Feature/User/Actions/SuspendUsersActionTest.php b/tests/Feature/User/Actions/SuspendUsersActionTest.php new file mode 100644 index 00000000000..01b6ae5fdb1 --- /dev/null +++ b/tests/Feature/User/Actions/SuspendUsersActionTest.php @@ -0,0 +1,35 @@ +createElement(['admin' => false]); + + postJson(action(PerformElementActionController::class), [ + 'context' => 'index', + 'source' => '*', + 'viewState' => [ + 'mode' => 'table', + 'static' => false, + ], + 'elementType' => User::class, + 'elementAction' => SuspendUsers::class, + 'elementIds' => [$user->id], + 'criteria' => ['status' => null], + ])->assertOk(); + + expect(User::find()->id($user->id)->status(User::STATUS_SUSPENDED)->one()) + ->not()->toBeNull(); +}); diff --git a/tests/Feature/User/Actions/UnsuspendUsersActionTest.php b/tests/Feature/User/Actions/UnsuspendUsersActionTest.php new file mode 100644 index 00000000000..3c9cf06e618 --- /dev/null +++ b/tests/Feature/User/Actions/UnsuspendUsersActionTest.php @@ -0,0 +1,49 @@ +createElement(['admin' => false]); + + postJson(action(PerformElementActionController::class), [ + 'context' => 'index', + 'source' => '*', + 'viewState' => [ + 'mode' => 'table', + 'static' => false, + ], + 'elementType' => User::class, + 'elementAction' => SuspendUsers::class, + 'elementIds' => [$user->id], + 'criteria' => ['status' => null], + ])->assertOk(); + + postJson(action(PerformElementActionController::class), [ + 'context' => 'index', + 'source' => '*', + 'viewState' => [ + 'mode' => 'table', + 'static' => false, + ], + 'elementType' => User::class, + 'elementAction' => UnsuspendUsers::class, + 'elementIds' => [$user->id], + 'criteria' => ['status' => User::STATUS_SUSPENDED], + ])->assertOk(); + + expect(User::find()->id($user->id)->status(null)->one()) + ->not()->toBeNull(); +}); diff --git a/tests/Unit/Component/ComponentHelperTest.php b/tests/Unit/Component/ComponentHelperTest.php index 2d3d5e7d194..0a5974156b6 100644 --- a/tests/Unit/Component/ComponentHelperTest.php +++ b/tests/Unit/Component/ComponentHelperTest.php @@ -41,6 +41,34 @@ public static function displayName(): string } } +class DatetimeStubComponent extends Component +{ + public DateTime $startsAt; + + public ?DateTime $endsAt = null; + + public string $title = ''; + + public DateTimeImmutable $immutableAt; + + public DateTime|int $unionAt; + + public static ?DateTime $staticAt = null; + + protected ?DateTime $hiddenAt = null; + + #[Override] + public static function displayName(): string + { + return 'Datetime Stub'; + } +} + +class DatetimeChildStubComponent extends DatetimeStubComponent +{ + public DateTime $publishedAt; +} + class NonComponent {} abstract class AbstractStubComponent extends Component @@ -433,3 +461,19 @@ public static function displayName(): string expect(ComponentHelper::mergeSettings($config))->toBe(['settings']); }); }); + +describe('datetimeAttributes', function () { + test('returns all public datetime attributes including inherited ones', function () { + expect(ComponentHelper::datetimeAttributes(new DatetimeChildStubComponent)) + ->toEqualCanonicalizing(['startsAt', 'endsAt', 'publishedAt']); + }); + + test('excludes non-datetime, union, static, and non-public properties', function () { + expect(ComponentHelper::datetimeAttributes(new DatetimeStubComponent)) + ->not->toContain('title') + ->not->toContain('immutableAt') + ->not->toContain('unionAt') + ->not->toContain('staticAt') + ->not->toContain('hiddenAt'); + }); +}); diff --git a/tests/Unit/Element/Concerns/ExportableTest.php b/tests/Unit/Element/Concerns/ExportableTest.php index 63d6fb48a9d..1b0e15a52a6 100644 --- a/tests/Unit/Element/Concerns/ExportableTest.php +++ b/tests/Unit/Element/Concerns/ExportableTest.php @@ -2,9 +2,9 @@ declare(strict_types=1); -use craft\elements\exporters\Expanded; -use craft\elements\exporters\Raw; use CraftCms\Cms\Element\Events\RegisterExporters; +use CraftCms\Cms\Element\Exporters\Expanded; +use CraftCms\Cms\Element\Exporters\Raw; use CraftCms\Cms\Entry\Elements\Entry; use Illuminate\Support\Facades\Event; diff --git a/yii2-adapter/legacy/base/ElementAction.php b/yii2-adapter/legacy/base/ElementAction.php index f8d84e7f6db..2a5562d3c89 100644 --- a/yii2-adapter/legacy/base/ElementAction.php +++ b/yii2-adapter/legacy/base/ElementAction.php @@ -7,98 +7,105 @@ namespace craft\base; -use CraftCms\Cms\Element\Queries\Contracts\ElementQueryInterface; +use Craft; +use CraftCms\Yii2Adapter\ModelWrapper; +use CraftCms\Yii2Adapter\Validation\LegacyYiiRules; +use Symfony\Component\HttpFoundation\Response; +use Symfony\Component\HttpFoundation\StreamedResponse; /** * ElementAction is the base class for classes representing element actions in terms of objects. * * @author Pixel & Tonic, Inc. * @since 3.0.0 + * @deprecated in 6.0.0. Use {@see \CraftCms\Cms\Element\Actions\ElementAction} instead. */ -abstract class ElementAction extends ConfigurableComponent implements ElementActionInterface +abstract class ElementAction extends \CraftCms\Cms\Element\Actions\ElementAction implements ElementActionInterface { - /** - * @inheritdoc - */ - public static function isDestructive(): bool + public function getRules(): array { - return false; + return LegacyYiiRules::mergeAttributeRules( + rules: parent::getRules(), + target: $this, + yiiRules: $this->defineRules(), + validatorTarget: fn() => new ModelWrapper($this), + allowMethodValidators: true, + ); } - /** - * @inheritdoc - */ - public static function isDownload(): bool + public function getResponse(): ?Response { - return false; - } + $response = parent::getResponse(); - /** - * @var class-string - * @since 3.0.30 - */ - protected string $elementType; - - /** - * @var string|null - */ - private ?string $_message = null; + if ($response !== null || !static::isDownload()) { + return $response; + } - /** - * @inheritdoc - */ - public function setElementType(string $elementType): void - { - $this->elementType = $elementType; + return $this->downloadResponse(Craft::$app->getResponse()); } /** - * @inheritdoc + * @return array */ - public function getTriggerLabel(): string + protected function defineRules(): array { - return static::displayName(); + return []; } - /** - * @inheritdoc - */ - public function getTriggerHtml(): ?string + private function downloadResponse(\craft\web\Response $response): Response { - return null; - } + $headers = $response->getHeaders()->toArray(); - /** - * @inheritdoc - */ - public function getConfirmationMessage(): ?string - { - return null; - } + if ($response->stream === null) { + return new Response( + content: $response->content ?? '', + status: $response->getStatusCode(), + headers: $headers, + ); + } - /** - * @inheritdoc - */ - public function performAction(ElementQueryInterface $query): bool - { - return true; - } + return new StreamedResponse( + function() use ($response): void { + $stream = $response->stream; - /** - * @inheritdoc - */ - public function getMessage(): ?string - { - return $this->_message; - } + if (is_callable($stream)) { + foreach ($stream() as $chunk) { + echo $chunk; + } - /** - * Sets the message that should be displayed to the user after the action is performed. - * - * @param string $message The message that should be displayed to the user after the action is performed. - */ - protected function setMessage(string $message): void - { - $this->_message = $message; + return; + } + + $chunkSize = 8 * 1024 * 1024; + + if (is_array($stream)) { + [$handle, $begin, $end] = $stream; + + if (stream_get_meta_data($handle)['seekable']) { + fseek($handle, $begin); + } + + while (!feof($handle) && ($position = ftell($handle)) <= $end) { + if ($position + $chunkSize > $end) { + $chunkSize = $end - $position + 1; + } + + echo fread($handle, $chunkSize); + } + + fclose($handle); + + return; + } + + while (!feof($stream)) { + echo fread($stream, $chunkSize); + } + + fclose($stream); + }, + $response->getStatusCode(), + $headers, + ); } } diff --git a/yii2-adapter/legacy/base/ElementActionInterface.php b/yii2-adapter/legacy/base/ElementActionInterface.php index 8f573205104..2d90d358de1 100644 --- a/yii2-adapter/legacy/base/ElementActionInterface.php +++ b/yii2-adapter/legacy/base/ElementActionInterface.php @@ -7,79 +7,13 @@ namespace craft\base; -use CraftCms\Cms\Component\Contracts\ComponentInterface; -use CraftCms\Cms\Component\Contracts\ConfigurableComponentInterface; -use CraftCms\Cms\Element\Queries\Contracts\ElementQueryInterface; - /** * ElementActionInterface defines the common interface to be implemented by element action classes. * * @author Pixel & Tonic, Inc. * @since 3.0.0 + * @deprecated in 6.0.0. Use {@see \CraftCms\Cms\Element\Contracts\ElementActionInterface} instead. */ -interface ElementActionInterface extends ConfigurableComponentInterface, ComponentInterface, ModelInterface +interface ElementActionInterface extends \CraftCms\Cms\Element\Contracts\ElementActionInterface { - /** - * Returns whether this action is destructive in nature. - * - * @return bool Whether this action is destructive in nature. - */ - public static function isDestructive(): bool; - - /** - * Returns whether this is a download action. - * - * Download actions’ [[performAction()]] method should call one of these methods before returning `true`: - * - * - [[\yii\web\Response::sendFile()]] - * - [[\yii\web\Response::sendContentAsFile()]] - * - [[\yii\web\Response::sendStreamAsFile()]] - * - * @return bool Whether this is a download action - * @since 3.5.0 - */ - public static function isDownload(): bool; - - /** - * Sets the element type on the action. - * - * @param class-string $elementType - */ - public function setElementType(string $elementType): void; - - /** - * Returns the action’s trigger label. - * - * @return string The action’s trigger label - */ - public function getTriggerLabel(): string; - - /** - * Returns the action’s trigger HTML. - * - * @return string|null The action’s trigger HTML. - */ - public function getTriggerHtml(): ?string; - - /** - * Returns a confirmation message that should be displayed before the action is performed. - * - * @return string|null The confirmation message, if any. - */ - public function getConfirmationMessage(): ?string; - - /** - * Performs the action on any elements that match the given criteria. - * - * @param ElementQueryInterface $query The element query defining which elements the action should affect. - * @return bool Whether the action was performed successfully. - */ - public function performAction(ElementQueryInterface $query): bool; - - /** - * Returns the message that should be displayed to the user after the action is performed. - * - * @return string|null The message that should be displayed to the user. - */ - public function getMessage(): ?string; } diff --git a/yii2-adapter/legacy/base/ElementExporterInterface.php b/yii2-adapter/legacy/base/ElementExporterInterface.php index 2335010b6f5..ee42fd983fa 100644 --- a/yii2-adapter/legacy/base/ElementExporterInterface.php +++ b/yii2-adapter/legacy/base/ElementExporterInterface.php @@ -7,54 +7,12 @@ namespace craft\base; -use CraftCms\Cms\Component\Contracts\ComponentInterface; -use CraftCms\Cms\Element\Queries\Contracts\ElementQueryInterface; - /** * ElementExporterInterface defines the common interface to be implemented by element exporter classes. * * @author Pixel & Tonic, Inc. * @since 3.4.0 */ -interface ElementExporterInterface extends ComponentInterface, ModelInterface +interface ElementExporterInterface extends \CraftCms\Cms\Element\Contracts\ElementExporterInterface, ModelInterface { - /** - * Returns whether the response data can be formatted as CSV, JSON, or XML. - * - * @return bool - * @since 3.6.0 - */ - public static function isFormattable(): bool; - - /** - * Sets the element type on the exporter. - * - * @param class-string $elementType - */ - public function setElementType(string $elementType): void; - - /** - * Creates the export data for elements fetched with the given element query. - * - * If [[isFormattable()]] returns `true`, then this must return one of the followings: - * - * - An array of arrays - * - A callable that returns an array of arrays - * - A [generator function](https://www.php.net/manual/en/language.generators.overview.php) that yields arrays. - * - * Otherwise, a string or resource could also be returned. - * - * @param ElementQueryInterface $query The element query - * @return array|string|callable|resource - */ - public function export(ElementQueryInterface $query): mixed; - - /** - * Returns the filename that the export file should have. - * - * If the data is [[isFormattable()|formattable]], then a file extension will be added based on the selected format. - * - * @return string - */ - public function getFilename(): string; } diff --git a/yii2-adapter/legacy/base/Fs.php b/yii2-adapter/legacy/base/Fs.php index 48669d90bd1..bc5a9c0958e 100644 --- a/yii2-adapter/legacy/base/Fs.php +++ b/yii2-adapter/legacy/base/Fs.php @@ -7,15 +7,11 @@ namespace craft\base; -use Closure; use craft\fs\bridge\LegacyFsFlysystemAdapter; use CraftCms\Cms\Filesystem\Filesystems\Filesystem; -use CraftCms\Cms\Support\Arr; use CraftCms\Yii2Adapter\ModelWrapper; -use ReflectionFunction; -use ReflectionMethod; +use CraftCms\Yii2Adapter\Validation\LegacyYiiRules; use yii\base\InvalidConfigException; -use yii\validators\Validator; /** * Field is the base class for classes representing filesystems in terms of objects. @@ -47,73 +43,13 @@ public function getDiskConfig(): array public function getRules(): array { - $yiiRules = $this->defineRules(); - - $rules = parent::getRules(); - $legacyAttributes = []; - - foreach ($yiiRules as $rule) { - if (!is_array($rule) || !isset($rule[0], $rule[1])) { - continue; - } - - foreach ((array)$rule[0] as $attribute) { - if (is_string($attribute) && $attribute !== '') { - $legacyAttributes[$attribute] = true; - } - } - } - - foreach (array_keys($legacyAttributes) as $legacyAttribute) { - $rules[$legacyAttribute] ??= []; - $rules[$legacyAttribute] = Arr::wrap($rules[$legacyAttribute]); - - array_unshift($rules[$legacyAttribute], function($attribute, $value, $fail) use ($yiiRules) { - foreach ($yiiRules as $rule) { - if (!is_array($rule) || !isset($rule[0], $rule[1])) { - continue; - } - - $attributes = (array)$rule[0]; - $type = $rule[1]; - $options = array_slice($rule, 2, null, true); - - if (!in_array($attribute, $attributes, true)) { - continue; - } - - if (is_string($type) && method_exists($this, $type)) { - $method = $type; - $filesystem = $this; - $type = function(string $attribute, ?array $params, Validator $validator, mixed $current) use ($method, $filesystem): void { - $parameterCount = (new ReflectionMethod($filesystem, $method))->getNumberOfParameters(); - - match (true) { - $parameterCount === 0 => $filesystem->$method(), - $parameterCount === 1 => $filesystem->$method($attribute), - $parameterCount === 2 => $filesystem->$method($attribute, $params), - $parameterCount === 3 => $filesystem->$method($attribute, $params, $validator), - default => $filesystem->$method($attribute, $params, $validator, $current), - }; - }; - } - - if (isset($options['when']) && is_callable($options['when'])) { - $options['when'] = $this->normalizeWhenCallback($options['when']); - } - - $wrappedModel = new ModelWrapper($this); - $validator = Validator::createValidator($type, $wrappedModel, $attributes, $options); - $validator->validateAttribute($wrappedModel, $attribute); - - foreach ($wrappedModel->getErrors($attribute) as $error) { - $fail((string)$error); - } - } - }); - } - - return $rules; + return LegacyYiiRules::mergeAttributeRules( + rules: parent::getRules(), + target: $this, + yiiRules: $this->defineRules(), + validatorTarget: fn() => new ModelWrapper($this), + allowMethodValidators: true, + ); } /** @@ -123,18 +59,4 @@ protected function defineRules(): array { return []; } - - private function normalizeWhenCallback(callable $callback): Closure - { - return function($model, string $attribute) use ($callback): bool { - $callback = Closure::fromCallable($callback); - $parameterCount = (new ReflectionFunction($callback))->getNumberOfParameters(); - - return match (true) { - $parameterCount === 0 => (bool)$callback(), - $parameterCount === 1 => (bool)$callback($this), - default => (bool)$callback($this, $attribute), - }; - }; - } } diff --git a/yii2-adapter/legacy/base/conditions/BaseCondition.php b/yii2-adapter/legacy/base/conditions/BaseCondition.php index 3ec3266dfc7..f3fbffe6d61 100644 --- a/yii2-adapter/legacy/base/conditions/BaseCondition.php +++ b/yii2-adapter/legacy/base/conditions/BaseCondition.php @@ -5,10 +5,9 @@ use craft\events\RegisterConditionRulesEvent; use craft\helpers\Html; use CraftCms\Cms\Condition\Events\RegisterConditionRules; -use CraftCms\Cms\Support\Arr; use CraftCms\Yii2Adapter\ModelWrapper; +use CraftCms\Yii2Adapter\Validation\LegacyYiiRules; use Illuminate\Support\Facades\Event; -use yii\validators\Validator; /** * BaseCondition provides a base implementation for conditions. @@ -34,29 +33,12 @@ abstract class BaseCondition extends \CraftCms\Cms\Condition\BaseCondition public function getRules(): array { - $yiiRules = $this->defineRules(); - - // Ensure it's set and an array - $rules = parent::getRules(); - $rules['*'] ??= []; - $rules['*'] = Arr::wrap($rules['*']); - - array_unshift($rules['*'], function($attribute, $value, $fail) use ($yiiRules) { - foreach ($yiiRules as $rule) { - $attributes = (array) $rule[0]; - $type = $rule[1]; - $options = array_slice($rule, 2); - - if (!in_array($attribute, $attributes, true)) { - continue; - } - - $validator = Validator::createValidator($type, new ModelWrapper($this), $attributes, $options); - $validator->validateAttribute(new ModelWrapper($this), $attribute); - } - }); - - return $rules; + return LegacyYiiRules::mergeWildcardRules( + rules: parent::getRules(), + target: $this, + yiiRules: $this->defineRules(), + validatorTarget: fn() => new ModelWrapper($this), + ); } public function defineRules(): array diff --git a/yii2-adapter/legacy/controllers/ElementIndexesController.php b/yii2-adapter/legacy/controllers/ElementIndexesController.php index e91f5a22879..94f6dd703a3 100644 --- a/yii2-adapter/legacy/controllers/ElementIndexesController.php +++ b/yii2-adapter/legacy/controllers/ElementIndexesController.php @@ -10,17 +10,12 @@ namespace craft\controllers; use Craft; -use craft\base\ElementAction; use craft\base\ElementActionInterface; -use craft\base\ElementExporterInterface; use craft\base\ElementInterface; use craft\db\ExcludeDescendantIdsExpression; -use craft\elements\actions\DeleteActionInterface; -use craft\elements\actions\Restore; -use craft\elements\exporters\Raw; -use craft\events\ElementActionEvent; use CraftCms\Cms\Element\Conditions\Contracts\ElementConditionInterface; use CraftCms\Cms\Element\Conditions\Contracts\ElementConditionRuleInterface; +use CraftCms\Cms\Element\Contracts\ElementExporterInterface; use CraftCms\Cms\Element\Element; use CraftCms\Cms\Element\ElementHelper; use CraftCms\Cms\Element\ElementSources; @@ -29,9 +24,10 @@ use CraftCms\Cms\FieldLayout\FieldLayout; use CraftCms\Cms\Support\Arr; use CraftCms\Cms\Support\Facades\Conditions; +use CraftCms\Cms\Support\Facades\ElementActions; +use CraftCms\Cms\Support\Facades\ElementExporters; use CraftCms\Cms\Support\Facades\Elements; use CraftCms\Cms\Support\Facades\HtmlStack; -use CraftCms\Cms\Support\Facades\I18N; use CraftCms\Cms\Support\Html; use CraftCms\Cms\Support\Str; use CraftCms\Cms\Support\Typecast; @@ -39,7 +35,6 @@ use Illuminate\Support\Facades\Gate; use Illuminate\Support\Facades\Log; use Throwable; -use yii\base\InvalidValueException; use yii\web\BadRequestHttpException; use yii\web\ForbiddenHttpException; use yii\web\Response; @@ -101,9 +96,7 @@ public function beforeAction($action): bool return false; } - if (!in_array($action->id, ['export', 'perform-action'], true)) { - $this->requireAcceptsJson(); - } + $this->requireAcceptsJson(); $this->elementType = $this->elementType(); $this->context = $this->context(); @@ -116,7 +109,7 @@ public function beforeAction($action): bool $this->elementQuery = $this->elementQuery(); if ( - in_array($action->id, ['get-elements', 'get-more-elements', 'perform-action', 'export']) && + in_array($action->id, ['get-elements', 'get-more-elements'], true) && $this->isAdministrative() && isset($this->sourceKey) ) { @@ -244,112 +237,6 @@ public function actionCountElements(): Response ]); } - /** - * Performs an action on one or more selected elements. - * - * @throws BadRequestHttpException if the requested element action is not supported by the element type, or its parameters didn’t validate - */ - public function actionPerformAction(): ?Response - { - $this->requirePostRequest(); - - $elementsService = Craft::$app->getElements(); - - $actionClass = $this->request->getRequiredBodyParam('elementAction'); - $elementIds = $this->request->getRequiredBodyParam('elementIds'); - - // Find that action from the list of available actions for the source - if (!empty($this->actions)) { - /** @var ElementAction $availableAction */ - foreach ($this->actions as $availableAction) { - if ($actionClass === get_class($availableAction)) { - $action = clone $availableAction; - break; - } - } - } - - /** @noinspection UnSafeIsSetOverArrayInspection - FP */ - if (!isset($action)) { - throw new BadRequestHttpException('Element action is not supported by the element type'); - } - - // Check for any params in the post data - foreach ($action->settingsAttributes() as $paramName) { - $paramValue = $this->request->getBodyParam($paramName); - - if ($paramValue !== null) { - $action->$paramName = $paramValue; - } - } - - // Make sure the action validates - if (!$action->validate()) { - throw new BadRequestHttpException('Element action params did not validate'); - } - - // Perform the action - $actionCriteria = (clone $this->elementQuery) - ->offset(0) - ->limit(null) - ->orderBy([]) - ->positionedAfter(null) - ->positionedBefore(null) - ->id($elementIds); - - // Fire a 'beforePerformAction' event - $event = new ElementActionEvent([ - 'action' => $action, - 'criteria' => $actionCriteria, - ]); - - $elementsService->trigger($elementsService::EVENT_BEFORE_PERFORM_ACTION, $event); - - if ($event->isValid) { - $success = $action->performAction($actionCriteria); - $message = $action->getMessage(); - - if ($success) { - // Fire an 'afterPerformAction' event - $elementsService->trigger($elementsService::EVENT_AFTER_PERFORM_ACTION, new ElementActionEvent([ - 'action' => $action, - 'criteria' => $actionCriteria, - ])); - } - } else { - $success = false; - $message = $event->message; - } - - // Respond - if ($action->isDownload()) { - return $this->response; - } - - if (!$success) { - return $this->asFailure($message); - } - - // Send a new set of elements - $responseData = $this->elementResponseData(true, true); - - // Send updated badge counts - $formatter = I18N::getFormatter(); - foreach (app(ElementSources::class)->getSources($this->elementType, $this->context) as $source) { - if (!isset($source['key'])) { - continue; - } - - if (isset($source['badgeCount'])) { - $responseData['badgeCounts'][$source['key']] = $formatter->asDecimal($source['badgeCount'], 0); - } else { - $responseData['badgeCounts'][$source['key']] = null; - } - } - - return $this->asSuccess($message, data: $responseData); - } - /** * Returns the source tree HTML for an element index. */ @@ -367,90 +254,6 @@ public function actionGetSourceTreeHtml(): Response ]); } - /** - * Exports element data. - * - * @throws BadRequestHttpException - * - * @since 3.4.4 - */ - public function actionExport(): Response - { - $exporter = $this->_exporter(); - $exporter->setElementType($this->elementType); - - // Set the filename header before calling export() in case export() starts outputting the data - $filename = $exporter->getFilename(); - if ($exporter::isFormattable()) { - $this->response->format = $this->request->getBodyParam('format', 'csv'); - $filename .= '.' . $this->response->format; - } - $this->response->setDownloadHeaders($filename); - - $export = $exporter->export($this->elementQuery); - - if ($exporter::isFormattable()) { - // Handle being passed in a generator function or other callable - if (is_callable($export)) { - $export = $export(); - } - if (!is_iterable($export)) { - throw new InvalidValueException(get_class($exporter) . '::export() must return an array or generator function since isFormattable() returns true.'); - } - - $this->response->data = $export; - - switch ($this->response->format) { - case Response::FORMAT_JSON: - $this->response->formatters[Response::FORMAT_JSON]['prettyPrint'] = true; - break; - case Response::FORMAT_XML: - app()->setLocale('en-US'); - $this->response->formatters[Response::FORMAT_XML]['rootTag'] = Str::camel($this->elementType::pluralLowerDisplayName()); - break; - } - } elseif ( - is_callable($export) || - is_resource($export) || - (is_array($export) && isset($export[0]) && is_resource($export[0])) - ) { - $this->response->stream = $export; - } else { - $this->response->data = $export; - $this->response->format = Response::FORMAT_RAW; - } - - return $this->response; - } - - /** - * Returns the exporter for the request. - * - * @throws BadRequestHttpException - */ - private function _exporter(): ElementExporterInterface - { - if (!$this->sourceKey) { - throw new BadRequestHttpException('Request missing required body param'); - } - - if (!$this->isAdministrative()) { - throw new BadRequestHttpException('Request missing index context'); - } - - // Find that exporter from the list of available exporters for the source - $exporterClass = $this->request->getBodyParam('type', Raw::class); - if (!empty($this->exporters)) { - foreach ($this->exporters as $exporter) { - if ($exporterClass === get_class($exporter)) { - return $exporter; - } - } - } - - throw new BadRequestHttpException('Element exporter is not supported by the element type'); - } - /** * Creates a filter HUD’s contents. * @@ -862,48 +665,11 @@ protected function elementResponseData(bool $includeContainer, bool $includeActi */ protected function availableActions(): ?array { - $actions = $this->elementType::actions($this->sourceKey); - - foreach ($actions as $i => $action) { - // $action could be a string or config array - if ($action instanceof ElementActionInterface) { - $action->setElementType($this->elementType); - } else { - if (is_string($action)) { - $action = ['type' => $action]; - } - /** @var array $action */ - /** @phpstan-var array{type:class-string} $action */ - $action['elementType'] = $this->elementType; - $actions[$i] = $action = Elements::createAction($action); - } - - if ($this->elementQuery->trashed) { - if ($action instanceof DeleteActionInterface && $action->canHardDelete()) { - $action->setHardDelete(); - } elseif (!$action instanceof Restore) { - unset($actions[$i]); - } - } elseif ($action instanceof Restore) { - unset($actions[$i]); - } - } - - if ($this->elementQuery->trashed) { - // Make sure Restore goes first - usort($actions, function($a, $b): int { - if ($a instanceof Restore) { - return -1; - } - if ($b instanceof Restore) { - return 1; - } - - return 0; - }); - } - - return array_values($actions); + return ElementActions::availableActions( + elementType: $this->elementType, + sourceKey: $this->sourceKey, + elementQuery: $this->elementQuery, + ); } /** @@ -919,22 +685,7 @@ protected function availableExporters(): ?array return null; } - $exporters = $this->elementType::exporters($this->sourceKey); - - foreach ($exporters as $i => $exporter) { - // $action could be a string or config array - if ($exporter instanceof ElementExporterInterface) { - $exporter->setElementType($this->elementType); - } else { - if (is_string($exporter)) { - $exporter = ['type' => $exporter]; - } - $exporter['elementType'] = $this->elementType; - $exporters[$i] = Elements::createExporter($exporter); - } - } - - return array_values($exporters); + return ElementExporters::availableExporters($this->elementType, $this->sourceKey); } /** @@ -946,14 +697,7 @@ protected function actionData(): ?array return null; } - $actionData = []; - - /** @var ElementAction $action */ - foreach ($this->actions as $action) { - $actionData[] = ElementHelper::actionConfig($action); - } - - return $actionData; + return ElementActions::serializeActions($this->actions); } /** @@ -967,17 +711,7 @@ protected function exporterData(): ?array return null; } - $exporterData = []; - - foreach ($this->exporters as $exporter) { - $exporterData[] = [ - 'type' => get_class($exporter), - 'name' => $exporter::displayName(), - 'formattable' => $exporter::isFormattable(), - ]; - } - - return $exporterData; + return ElementExporters::serializeExporters($this->exporters); } /** diff --git a/yii2-adapter/legacy/elements/Category.php b/yii2-adapter/legacy/elements/Category.php index 79f32e71877..eee3c9ae08c 100644 --- a/yii2-adapter/legacy/elements/Category.php +++ b/yii2-adapter/legacy/elements/Category.php @@ -27,6 +27,7 @@ use CraftCms\Cms\Element\Queries\Contracts\ElementQueryInterface; use CraftCms\Cms\FieldLayout\FieldLayout; use CraftCms\Cms\Structure\Enums\Mode; +use CraftCms\Cms\Support\Facades\ElementActions; use CraftCms\Cms\Support\Facades\Elements; use CraftCms\Cms\Support\Facades\Sites; use CraftCms\Cms\Support\Facades\Structures; @@ -267,11 +268,11 @@ protected static function defineActions(string $source): array $newChildUrl .= '?site=' . $site->handle; } - $actions[] = Elements::createAction([ + $actions[] = ElementActions::createAction([ 'type' => NewChild::class, 'maxLevels' => $group->maxLevels, 'newChildUrl' => $newChildUrl, - ]); + ], static::class); } // Duplicate diff --git a/yii2-adapter/legacy/elements/NestedElementManager.php b/yii2-adapter/legacy/elements/NestedElementManager.php index 62fb8f57436..af39c209d0a 100644 --- a/yii2-adapter/legacy/elements/NestedElementManager.php +++ b/yii2-adapter/legacy/elements/NestedElementManager.php @@ -10,9 +10,6 @@ use Closure; use craft\base\ElementInterface; use craft\base\NestedElementInterface; -use craft\elements\actions\ChangeSortOrder; -use craft\elements\actions\MoveDown; -use craft\elements\actions\MoveUp; use craft\events\BulkElementsEvent; use craft\events\DuplicateNestedElementsEvent; use CraftCms\Cms\Auth\SessionAuth; @@ -20,6 +17,9 @@ use CraftCms\Cms\Cp\Html\ElementIndexHtml; use CraftCms\Cms\Cp\Icons; use CraftCms\Cms\Database\Table; +use CraftCms\Cms\Element\Actions\ChangeSortOrder; +use CraftCms\Cms\Element\Actions\MoveDown; +use CraftCms\Cms\Element\Actions\MoveUp; use CraftCms\Cms\Element\Drafts; use CraftCms\Cms\Element\Element; use CraftCms\Cms\Element\ElementCollection; diff --git a/yii2-adapter/legacy/elements/actions/ChangeSortOrder.php b/yii2-adapter/legacy/elements/actions/ChangeSortOrder.php index a9c61a7c47f..0075ccdd1de 100644 --- a/yii2-adapter/legacy/elements/actions/ChangeSortOrder.php +++ b/yii2-adapter/legacy/elements/actions/ChangeSortOrder.php @@ -1,133 +1,15 @@ - * @since 5.0.0 - */ -class ChangeSortOrder extends ElementAction -{ - /** - * Constructor - * - * @param ElementInterface $owner The owner element - * @param string $attribute The attribute name that nested elements are accessible by, from the owner element. - */ - public function __construct( - private readonly ElementInterface $owner, - private readonly string $attribute, - $config = [], - ) { - parent::__construct($config); - } - - /** - * @inheritdoc - */ - public function getTriggerLabel(): string - { - return t('Move to page…'); - } - +/** @phpstan-ignore-next-line */ +if (false) { /** - * @inheritdoc + * @deprecated 6.0.0 use {@see \CraftCms\Cms\Element\Actions\ChangeSortOrder} instead. */ - public function getTriggerHtml(): ?string + class ChangeSortOrder extends \CraftCms\Cms\Element\Actions\ChangeSortOrder { - HtmlStack::jsWithVars( - fn($type, $params) => << { - new Craft.ElementActionTrigger({ - type: $type, - bulk: true, - validateSelection: (selectedItems, elementIndex) => { - return ( - elementIndex.sortable && - elementIndex.totalResults && - elementIndex.totalResults > elementIndex.settings.batchSize - ); - }, - activate: (selectedItems, elementIndex) => { - const totalPages = Math.ceil(elementIndex.totalResults / elementIndex.settings.batchSize); - const container = $('
'); - const flex = $('
', {class: 'flex flex-nowrap'}); - const select = Craft.ui.createSelect({ - options: [...Array(totalPages).keys()].map(num => ({label: num + 1, value: num + 1})), - value: elementIndex.page === totalPages ? elementIndex.page - 1 : elementIndex.page + 1, - }).appendTo(flex); - select.find('option[value=' + elementIndex.page + ']').attr('disabled', 'disabled'); - const button = Craft.ui.createSubmitButton({ - label: Craft.t('app', 'Move'), - spinner: true, - }).appendTo(flex); - Craft.ui.createField(flex, { - label: Craft.t('app', 'Choose a page'), - }).appendTo(container); - const hud = new Garnish.HUD(elementIndex.\$actionMenuBtn, container); - - button.one('activate', async () => { - const page = parseInt(select.find('select').val()); - moveToPage(selectedItems, elementIndex, page, button, hud); - }); - }, - }) - - async function moveToPage(selectedItems, elementIndex, page, button, hud) { - button.addClass('loading'); - await elementIndex.settings.onBeforeMoveElementsToPage(selectedItems, page); - - const data = Object.assign($params, { - elementIds: elementIndex.getSelectedElementIds(), - offset: (page - 1) * elementIndex.settings.batchSize, - }) - - // swap out the ownerId with the new draft ownerId - const elementEditor = elementIndex.\$container.closest('form').data('elementEditor'); - if (elementEditor) { - data.ownerId = elementEditor.getDraftElementId(data.ownerId); - } - - let response; - try { - response = await Craft.sendActionRequest('POST', 'nested-elements/reorder', {data}); - } catch (e) { - Craft.cp.displayError(e?.response?.data?.error); - return; - } finally { - button.removeClass('loading'); - } - - hud.hide(); - Craft.cp.displayNotice(response.data.message); - await elementIndex.settings.onMoveElementsToPage(selectedItems, page); - elementIndex.setPage(page); - elementIndex.updateElements(true, true) - } -})(); -JS, - [ - static::class, - [ - 'ownerElementType' => get_class($this->owner), - 'ownerId' => $this->owner->id, - 'ownerSiteId' => $this->owner->siteId, - 'attribute' => $this->attribute, - ], - ]); - - return null; } } + +class_alias(\CraftCms\Cms\Element\Actions\ChangeSortOrder::class, ChangeSortOrder::class); diff --git a/yii2-adapter/legacy/elements/actions/Copy.php b/yii2-adapter/legacy/elements/actions/Copy.php index 7bb974f04e9..afafc65b0f6 100644 --- a/yii2-adapter/legacy/elements/actions/Copy.php +++ b/yii2-adapter/legacy/elements/actions/Copy.php @@ -1,64 +1,15 @@ - * @since 5.7.0 - */ -class Copy extends ElementAction -{ - /** - * @inheritdoc - */ - public function getTriggerLabel(): string - { - return t('Copy'); - } - +/** @phpstan-ignore-next-line */ +if (false) { /** - * @inheritdoc - * @since 3.5.0 + * @deprecated 6.0.0 use {@see \CraftCms\Cms\Element\Actions\Copy} instead. */ - public function getTriggerHtml(): ?string + class Copy extends \CraftCms\Cms\Element\Actions\Copy { - // Only enable for copyable elements, per canCopy() - 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-copyable')) { - return false; - } - } - - return true; - }, - activate: (selectedItems, elementIndex) => { - let elements = $(); - selectedItems.each((i, item) => { - elements = elements.add($(item).find('.element:first')); - }); - Craft.cp.copyElements(elements); - }, - }) -})(); -JS, [static::class]); - - return null; } } + +class_alias(\CraftCms\Cms\Element\Actions\Copy::class, Copy::class); diff --git a/yii2-adapter/legacy/elements/actions/CopyReferenceTag.php b/yii2-adapter/legacy/elements/actions/CopyReferenceTag.php index 84d3877c610..c7c5b0e6116 100644 --- a/yii2-adapter/legacy/elements/actions/CopyReferenceTag.php +++ b/yii2-adapter/legacy/elements/actions/CopyReferenceTag.php @@ -1,58 +1,15 @@ - * @since 3.0.0 - */ -class CopyReferenceTag extends ElementAction -{ +/** @phpstan-ignore-next-line */ +if (false) { /** - * @inheritdoc + * @deprecated 6.0.0 use {@see \CraftCms\Cms\Asset\Actions\CopyReferenceTag} instead. */ - public function getTriggerLabel(): string + class CopyReferenceTag extends \CraftCms\Cms\Asset\Actions\CopyReferenceTag { - return t('Copy reference tag'); - } - - /** - * @inheritdoc - */ - public function getTriggerHtml(): ?string - { - $refHandle = $this->elementType::refHandle(); - if ($refHandle === null) { - throw new Exception("Element type \"$this->elementType\" doesn't have a reference handle."); - } - - HtmlStack::jsWithVars(fn($type, $refHandle) => << { - new Craft.ElementActionTrigger({ - type: $type, - bulk: false, - activate: (selectedItems, elementIndex) => { - Craft.ui.createCopyTextPrompt({ - label: Craft.t('app', 'Copy the reference tag'), - value: '{' + $refHandle + ':' + selectedItems.find('.element').data('id') + '}', - }); - }, - }) -})(); -JS, [static::class, $refHandle]); - - return null; } } + +class_alias(\CraftCms\Cms\Asset\Actions\CopyReferenceTag::class, CopyReferenceTag::class); diff --git a/yii2-adapter/legacy/elements/actions/CopyUrl.php b/yii2-adapter/legacy/elements/actions/CopyUrl.php index c6d164fc716..7f89196a70e 100644 --- a/yii2-adapter/legacy/elements/actions/CopyUrl.php +++ b/yii2-adapter/legacy/elements/actions/CopyUrl.php @@ -1,56 +1,15 @@ - * @since 3.5.0 - */ -class CopyUrl extends ElementAction -{ - // Public Methods - // ========================================================================= - +/** @phpstan-ignore-next-line */ +if (false) { /** - * @inheritdoc + * @deprecated 6.0.0 use {@see \CraftCms\Cms\Asset\Actions\CopyUrl} instead. */ - public function getTriggerLabel(): string + class CopyUrl extends \CraftCms\Cms\Asset\Actions\CopyUrl { - return t('Copy URL'); - } - - /** - * @inheritdoc - */ - public function getTriggerHtml(): ?string - { - HtmlStack::jsWithVars(fn($type) => << { - new Craft.ElementActionTrigger({ - type: $type, - bulk: false, - validateSelection: (selectedItems, elementIndex) => !!selectedItems.find('.element').data('url'), - activate: (selectedItems, elementIndex) => { - Craft.ui.createCopyTextPrompt({ - label: Craft.t('app', 'Copy the URL'), - value: selectedItems.find('.element').data('url'), - }); - }, - }) -})(); -JS, [static::class]); - - return null; } } + +class_alias(\CraftCms\Cms\Asset\Actions\CopyUrl::class, CopyUrl::class); diff --git a/yii2-adapter/legacy/elements/actions/Delete.php b/yii2-adapter/legacy/elements/actions/Delete.php index 42a3f13d149..a9914f2d121 100644 --- a/yii2-adapter/legacy/elements/actions/Delete.php +++ b/yii2-adapter/legacy/elements/actions/Delete.php @@ -1,240 +1,15 @@ - * @since 3.0.0 - */ -class Delete extends ElementAction implements DeleteActionInterface -{ - /** - * @var bool Whether to delete the element’s descendants as well. - * @since 3.5.0 - */ - public bool $withDescendants = false; - - /** - * @var bool Whether to permanently delete the elements. - * @since 3.5.0 - */ - public bool $hard = false; - - /** - * @var string|null The confirmation message that should be shown before the elements get deleted - */ - public ?string $confirmationMessage = null; - - /** - * @var string|null The message that should be shown after the elements get deleted - */ - public ?string $successMessage = null; - - /** - * @inheritdoc - */ - public function canHardDelete(): bool - { - return !$this->withDescendants; - } - - /** - * @inheritdoc - */ - public function setHardDelete(): void - { - $this->hard = true; - } - - /** - * @inheritdoc - * @since 3.5.0 - */ - 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')) { - return false; - } - } - - return elementIndex.settings.canDeleteElements(selectedItems); - }, - beforeActivate: async (selectedItems, elementIndex) => { - await elementIndex.settings.onBeforeDeleteElements(selectedItems); - }, - afterActivate: async (selectedItems, elementIndex) => { - await elementIndex.settings.onDeleteElements(selectedItems); - }, - }) -})(); -JS, [static::class]); - - if ($this->hard) { - return Html::tag('div', $this->getTriggerLabel(), [ - 'class' => ['btn', 'formsubmit'], - ]); - } - - return null; - } - +/** @phpstan-ignore-next-line */ +if (false) { /** - * @inheritdoc + * @deprecated 6.0.0 use {@see \CraftCms\Cms\Element\Actions\Delete} instead. */ - public function getTriggerLabel(): string + class Delete extends \CraftCms\Cms\Element\Actions\Delete { - if ($this->hard) { - return t('Delete permanently'); - } - - if ($this->withDescendants) { - return t('Delete (with descendants)'); - } - - return t('Delete'); - } - - /** - * @inheritdoc - */ - public static function isDestructive(): bool - { - return true; - } - - /** - * @inheritdoc - */ - public function getConfirmationMessage(): ?string - { - if (isset($this->confirmationMessage)) { - return $this->confirmationMessage; - } - - if ($this->hard) { - return t('Are you sure you want to permanently delete the selected {type}?', [ - 'type' => $this->elementType::pluralLowerDisplayName(), - ]); - } - - if ($this->withDescendants) { - return t('Are you sure you want to delete the selected {type} along with their descendants?', [ - 'type' => $this->elementType::pluralLowerDisplayName(), - ]); - } - - return t('Are you sure you want to delete the selected {type}?', [ - 'type' => $this->elementType::pluralLowerDisplayName(), - ]); - } - - /** - * @inheritdoc - */ - public function performAction(ElementQueryInterface $query): bool - { - $withDescendants = $this->withDescendants && !$this->hard; - - if ($withDescendants) { - $query - ->with([ - [ - 'descendants', - [ - 'orderBy' => ['structureelements.lft' => SORT_DESC], - 'status' => null, - ], - ], - ]) - ->orderBy(['structureelements.lft' => SORT_DESC]); - } - - $deletedElementIds = []; - $deleteOwnership = []; - - foreach ($query->all() as $element) { - if (!Gate::check('view', $element) || !Gate::check('delete', $element)) { - continue; - } - - if (!isset($deletedElementIds[$element->id])) { - if ($withDescendants) { - foreach ($element->getDescendants()->all() as $descendant) { - if ( - !isset($deletedElementIds[$descendant->id]) && - Gate::check('view', $descendant) && - Gate::check('delete', $descendant) - ) { - $this->deleteElement($descendant, $deleteOwnership); - $deletedElementIds[$descendant->id] = true; - } - } - } - $this->deleteElement($element, $deleteOwnership); - $deletedElementIds[$element->id] = true; - } - } - - foreach ($deleteOwnership as $ownerId => $elementIds) { - DB::table(Table::ELEMENTS_OWNERS) - ->whereIn('elementId', $elementIds) - ->where('ownerId', $ownerId) - ->delete(); - } - - if (isset($this->successMessage)) { - $this->setMessage($this->successMessage); - } else { - $this->setMessage(t('{type} deleted.', [ - 'type' => $this->elementType::pluralDisplayName(), - ])); - } - - return true; - } - - private function deleteElement( - ElementInterface $element, - array &$deleteOwnership, - ): void { - // If the element primarily belongs to a different element, (and we're not hard deleting) just delete the ownership - if (!$this->hard && $element instanceof NestedElementInterface) { - $ownerId = $element->getOwnerId(); - if ($ownerId && $element->getPrimaryOwnerId() !== $ownerId) { - $deleteOwnership[$ownerId][] = $element->id; - return; - } - } - - \CraftCms\Cms\Support\Facades\Elements::deleteElement($element, $this->hard); } } + +class_alias(\CraftCms\Cms\Element\Actions\Delete::class, Delete::class); diff --git a/yii2-adapter/legacy/elements/actions/DeleteActionInterface.php b/yii2-adapter/legacy/elements/actions/DeleteActionInterface.php index d608289b91e..867cc717d3c 100644 --- a/yii2-adapter/legacy/elements/actions/DeleteActionInterface.php +++ b/yii2-adapter/legacy/elements/actions/DeleteActionInterface.php @@ -16,18 +16,8 @@ * @author Pixel & Tonic, Inc. * @since 3.6.5 * @mixin Delete + * @deprecated 6.0.0 use {@see \CraftCms\Cms\Element\Contracts\DeleteActionInterface} instead. */ -interface DeleteActionInterface +interface DeleteActionInterface extends \CraftCms\Cms\Element\Contracts\DeleteActionInterface { - /** - * Returns whether the action is capable of hard-deleting elements. - * - * @return bool - */ - public function canHardDelete(): bool; - - /** - * Instructs the action that the elements should be hard-deleted. - */ - public function setHardDelete(): void; } diff --git a/yii2-adapter/legacy/elements/actions/DeleteAssets.php b/yii2-adapter/legacy/elements/actions/DeleteAssets.php index 9c15043a7ba..27d33393901 100644 --- a/yii2-adapter/legacy/elements/actions/DeleteAssets.php +++ b/yii2-adapter/legacy/elements/actions/DeleteAssets.php @@ -1,72 +1,15 @@ - * @since 3.0.0 - */ -class DeleteAssets extends Delete -{ +/** @phpstan-ignore-next-line */ +if (false) { /** - * @inheritdoc - * @since 3.5.15 + * @deprecated 6.0.0 use {@see \CraftCms\Cms\Asset\Actions\DeleteAssets} instead. */ - public function getTriggerHtml(): ?string + class DeleteAssets extends \CraftCms\Cms\Asset\Actions\DeleteAssets { - // Only enable for deletable elements, per canDelete() - HtmlStack::jsWithVars(fn($type) => << { - const trigger = new Craft.ElementActionTrigger({ - type: $type, - 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 true; - }, - - activate: (selectedItems, elementIndex) => { - const element = selectedItems.find('.element:first'); - if (Garnish.hasAttr(element, 'data-is-folder')) { - const sourcePath = element.data('source-path'); - elementIndex.deleteFolder(sourcePath[sourcePath.length - 1]) - .then(() => { - elementIndex.updateElements(); - }); - } else { - elementIndex.submitAction(trigger.\$trigger.data('action'), Garnish.getPostData(trigger.\$trigger)); - } - }, - }); -})(); -JS, [static::class]); - - return null; } } + +class_alias(\CraftCms\Cms\Asset\Actions\DeleteAssets::class, DeleteAssets::class); diff --git a/yii2-adapter/legacy/elements/actions/DeleteForSite.php b/yii2-adapter/legacy/elements/actions/DeleteForSite.php index f6ccca48231..1a031f37d34 100644 --- a/yii2-adapter/legacy/elements/actions/DeleteForSite.php +++ b/yii2-adapter/legacy/elements/actions/DeleteForSite.php @@ -1,117 +1,15 @@ - * @since 3.7.0 - */ -class DeleteForSite extends ElementAction -{ +/** @phpstan-ignore-next-line */ +if (false) { /** - * @var string|null The confirmation message that should be shown before the elements get deleted + * @deprecated 6.0.0 use {@see \CraftCms\Cms\Element\Actions\DeleteForSite} instead. */ - public ?string $confirmationMessage = null; - - /** - * @var string|null The message that should be shown after the elements get deleted - */ - public ?string $successMessage = null; - - /** - * @inheritdoc - */ - public function getTriggerHtml(): ?string + class DeleteForSite extends \CraftCms\Cms\Element\Actions\DeleteForSite { - // 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; - } - - /** - * @inheritdoc - */ - public function getTriggerLabel(): string - { - return t('Delete for site'); - } - - /** - * @inheritdoc - */ - public static function isDestructive(): bool - { - return true; - } - - /** - * @inheritdoc - */ - public function getConfirmationMessage(): ?string - { - return $this->confirmationMessage ?? t('Are you sure you want to delete the selected {type} for this site?', [ - 'type' => $this->elementType::pluralLowerDisplayName(), - ]); - } - - /** - * @inheritdoc - */ - public function performAction(ElementQueryInterface $query): bool - { - // Ignore any elements the user doesn’t have permission to delete - $elements = array_filter( - $query->all(), - fn(ElementInterface $element) => ( - Gate::check('view', $element) && - Gate::check('deleteForSite', $element) - ), - ); - - Elements::deleteElementsForSite($elements); - - if (isset($this->successMessage)) { - $this->setMessage($this->successMessage); - } else { - $this->setMessage(t('{type} deleted for site.', [ - 'type' => $this->elementType::pluralDisplayName(), - ])); - } - - return true; } } + +class_alias(\CraftCms\Cms\Element\Actions\DeleteForSite::class, DeleteForSite::class); diff --git a/yii2-adapter/legacy/elements/actions/DeleteUsers.php b/yii2-adapter/legacy/elements/actions/DeleteUsers.php index 1bfdc753b03..d3f49cc35b4 100644 --- a/yii2-adapter/legacy/elements/actions/DeleteUsers.php +++ b/yii2-adapter/legacy/elements/actions/DeleteUsers.php @@ -1,200 +1,15 @@ - * @since 3.0.0 - */ -class DeleteUsers extends ElementAction implements DeleteActionInterface -{ - /** - * @var int|int[]|null The user ID that the deleted user’s content should be transferred to - */ - public int|array|null $transferContentTo = null; - - /** - * @var bool Whether to permanently delete the elements. - * @since 3.6.5 - */ - public bool $hard = false; - - /** - * @inheritdoc - */ - public function canHardDelete(): bool - { - return true; - } - - /** - * @inheritdoc - */ - public function setHardDelete(): void - { - $this->hard = true; - } - +/** @phpstan-ignore-next-line */ +if (false) { /** - * @inheritdoc + * @deprecated 6.0.0 use {@see \CraftCms\Cms\User\Actions\DeleteUsers} instead. */ - public function getTriggerLabel(): string + class DeleteUsers extends \CraftCms\Cms\User\Actions\DeleteUsers { - if ($this->hard) { - return t('Delete permanently'); - } - - return t('Delete…'); - } - - /** - * @inheritdoc - */ - public static function isDestructive(): bool - { - return true; - } - - /** - * @inheritdoc - */ - public function getTriggerHtml(): ?string - { - if ($this->hard) { - return '
' . $this->getTriggerLabel() . '
'; - } - - HtmlStack::jsWithVars( - fn($type, $redirect) => << { - new Craft.ElementActionTrigger({ - type: $type, - bulk: true, - validateSelection: (selectedItems, elementIndex) => { - for (let i = 0; i < selectedItems.length; i++) { - if (!Garnish.hasAttr(selectedItems.eq(i).find('.element'), 'data-deletable')) { - return false; - } - } - return true; - }, - activate: (selectedItems, elementIndex) => { - elementIndex.setIndexBusy(); - const ids = elementIndex.getSelectedElementIds(); - const data = {userId: ids}; - Craft.sendActionRequest('POST', 'users/user-content-summary', {data}) - .then((response) => { - const modal = new Craft.DeleteUserModal(ids, { - contentSummary: response.data, - onSubmit: () => { - elementIndex.submitAction($type, Garnish.getPostData(modal.\$container)) - modal.hide(); - return false; - }, - redirect: $redirect - }) - }) - .finally(() => { - elementIndex.setIndexAvailable(); - }); - }, - }) -})(); -JS, - [ - static::class, - Crypt::encrypt(Edition::get() === Edition::Solo ? 'dashboard' : 'users'), - ]); - - return null; - } - - /** - * @inheritdoc - */ - public function getConfirmationMessage(): ?string - { - if ($this->hard) { - return t('Are you sure you want to permanently delete the selected {type}?', [ - 'type' => User::pluralLowerDisplayName(), - ]); - } - - return null; - } - - /** - * @inheritdoc - */ - public function performAction(ElementQueryInterface $query): bool - { - /** @var User[] $users */ - $users = $query->all(); - - // Are we transferring the user’s content to a different user? - if (is_array($this->transferContentTo)) { - $this->transferContentTo = reset($this->transferContentTo) ?: null; - } - - if ($this->transferContentTo) { - $transferContentTo = Users::getUserById($this->transferContentTo); - - if (!$transferContentTo) { - throw new Exception("No user exists with the ID “{$this->transferContentTo}”"); - } - } else { - $transferContentTo = null; - } - - // Delete the users - $deletedCount = 0; - - foreach ($users as $user) { - if (Gate::check('delete', $user)) { - $user->inheritorOnDelete = $transferContentTo; - if (Elements::deleteElement($user, $this->hard)) { - $deletedCount++; - } - } - } - - if ($deletedCount !== count($users)) { - if ($deletedCount === 0) { - $this->setMessage(t('Couldn’t delete {type}.', [ - 'type' => User::pluralLowerDisplayName(), - ])); - } else { - $this->setMessage(t('Couldn’t delete all {type}.', [ - 'type' => User::pluralLowerDisplayName(), - ])); - } - - return false; - } - - $this->setMessage(t('{type} deleted.', [ - 'type' => User::pluralDisplayName(), - ])); - - return true; } } + +class_alias(\CraftCms\Cms\User\Actions\DeleteUsers::class, DeleteUsers::class); diff --git a/yii2-adapter/legacy/elements/actions/DownloadAssetFile.php b/yii2-adapter/legacy/elements/actions/DownloadAssetFile.php index be28fcd960e..8925180e4c6 100644 --- a/yii2-adapter/legacy/elements/actions/DownloadAssetFile.php +++ b/yii2-adapter/legacy/elements/actions/DownloadAssetFile.php @@ -1,67 +1,15 @@ - * @since 3.0.0 - */ -class DownloadAssetFile extends ElementAction -{ - /** - * @inheritdoc - */ - public function getTriggerLabel(): string - { - return t('Download'); - } - +/** @phpstan-ignore-next-line */ +if (false) { /** - * @inheritdoc + * @deprecated 6.0.0 use {@see \CraftCms\Cms\Asset\Actions\DownloadAssetFile} instead. */ - public function getTriggerHtml(): ?string + class DownloadAssetFile extends \CraftCms\Cms\Asset\Actions\DownloadAssetFile { - HtmlStack::jsWithVars(fn($type) => << { - new Craft.ElementActionTrigger({ - type: $type, - activate: (selectedItems, elementIndex) => { - var \$form = Craft.createForm().appendTo(Garnish.\$bod); - $(Craft.getCsrfInput()).appendTo(\$form); - $('', { - type: 'hidden', - name: 'action', - value: 'assets/download-asset' - }).appendTo(\$form); - selectedItems.each(function() { - $('', { - type: 'hidden', - name: 'assetId[]', - value: $(this).data('id') - }).appendTo(\$form); - }); - $('', { - type: 'submit', - value: 'Submit', - }).appendTo(\$form); - \$form.submit(); - \$form.remove(); - }, - }); -})(); -JS, [static::class]); - - return null; } } + +class_alias(\CraftCms\Cms\Asset\Actions\DownloadAssetFile::class, DownloadAssetFile::class); diff --git a/yii2-adapter/legacy/elements/actions/Duplicate.php b/yii2-adapter/legacy/elements/actions/Duplicate.php index c363eef9489..2c6501ceb30 100644 --- a/yii2-adapter/legacy/elements/actions/Duplicate.php +++ b/yii2-adapter/legacy/elements/actions/Duplicate.php @@ -1,194 +1,15 @@ - * @since 3.0.30 - */ -class Duplicate extends ElementAction -{ - /** - * @var bool Whether to also duplicate the selected elements’ descendants - */ - public bool $deep = false; - - /** - * @var bool Whether to duplicate the selected elements as drafts - * @since 5.9.0 - */ - public bool $asDrafts = false; - - /** - * @var string|null The message that should be shown after the elements get deleted - */ - public ?string $successMessage = null; - - /** - * @inheritdoc - */ - public function getTriggerLabel(): string - { - return $this->deep - ? t('Duplicate (with descendants)') - : t('Duplicate'); - } - - /** - * @inheritdoc - * @since 3.5.0 - */ - 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.settings.onBeforeDuplicateElements(selectedItems); - }, - afterActivate: async (selectedItems, elementIndex) => { - await elementIndex.settings.onDuplicateElements(selectedItems); - }, - }) -})(); -JS, [ - static::class, - $this->asDrafts ? 'data-duplicatable-as-draft' : 'data-duplicatable', - ]); - - return null; - } - +/** @phpstan-ignore-next-line */ +if (false) { /** - * @inheritdoc + * @deprecated 6.0.0 use {@see \CraftCms\Cms\Element\Actions\Duplicate} instead. */ - public function performAction(ElementQueryInterface $query): bool + class Duplicate extends \CraftCms\Cms\Element\Actions\Duplicate { - if ($this->deep) { - $query->orderBy(['structureelements.lft' => SORT_ASC]); - } - - $elements = $query->all(); - $successCount = 0; - $failCount = 0; - - $this->_duplicateElements($query, $elements, $successCount, $failCount); - - // Did all of them fail? - if ($successCount === 0) { - $this->setMessage(t('Could not duplicate elements due to validation errors.')); - return false; - } - - if ($failCount !== 0) { - $this->setMessage(t('Could not duplicate all elements due to validation errors.')); - } else { - $this->setMessage(t('Elements duplicated.')); - } - - return true; - } - - /** - * @param ElementQueryInterface $query - * @param ElementInterface[] $elements - * @param array $duplicatedElementIds - * @param int $successCount - * @param int $failCount - * @param ElementInterface|null $newParent - */ - private function _duplicateElements(ElementQueryInterface $query, array $elements, int &$successCount, int &$failCount, array &$duplicatedElementIds = [], ?ElementInterface $newParent = null): void - { - foreach ($elements as $element) { - $allowed = $this->asDrafts - ? Gate::check('duplicateAsDraft', $element) - : Gate::check('duplicate', $element); - - if (!$allowed) { - continue; - } - - // Make sure this element wasn't already duplicated, which could - // happen if it's the descendant of a previously duplicated element - // and $this->deep == true. - if (isset($duplicatedElementIds[$element->id])) { - continue; - } - - $attributes = [ - 'isProvisionalDraft' => false, - 'draftId' => null, - ]; - - // If the element was loaded for a non-primary owner, set its primary owner to it - if ($element instanceof NestedElementInterface) { - $attributes['primaryOwner'] = $element->getOwner(); - $attributes['sortOrder'] = null; // clear our sort order too - } - - try { - $duplicate = Elements::duplicateElement( - $element, - $attributes, - asUnpublishedDraft: $this->asDrafts, - ); - } catch (Throwable) { - // Validation error - $failCount++; - continue; - } - - $successCount++; - $duplicatedElementIds[$element->id] = true; - - if ($newParent) { - // Append it to the duplicate of $element’s parent - Structures::append($element->structureId, $duplicate, $newParent); - } elseif ($element->structureId) { - // Place it right next to the original element - Structures::moveAfter($element->structureId, $duplicate, $element); - } - - if ($this->deep) { - // Don't use $element->children() here in case its lft/rgt values have changed - $children = $element::find() - ->siteId($element->siteId) - ->descendantOf($element->id) - ->descendantDist(1) - ->status(null) - ->all(); - - $this->_duplicateElements($query, $children, $successCount, $failCount, $duplicatedElementIds, $duplicate); - } - } } } + +class_alias(\CraftCms\Cms\Element\Actions\Duplicate::class, Duplicate::class); diff --git a/yii2-adapter/legacy/elements/actions/Edit.php b/yii2-adapter/legacy/elements/actions/Edit.php index e923cfa7220..d392d3408d6 100644 --- a/yii2-adapter/legacy/elements/actions/Edit.php +++ b/yii2-adapter/legacy/elements/actions/Edit.php @@ -1,66 +1,15 @@ - * @since 3.0.0 - */ -class Edit extends ElementAction -{ - /** - * @var string|null The trigger label - */ - public ?string $label = null; - - /** - * @inheritdoc - */ - public function init(): void - { - if (!isset($this->label)) { - $this->label = t('Edit'); - } - } - - /** - * @inheritdoc - */ - public function getTriggerLabel(): string - { - return $this->label; - } - +/** @phpstan-ignore-next-line */ +if (false) { /** - * @inheritdoc + * @deprecated 6.0.0 use {@see \CraftCms\Cms\Element\Actions\Edit} instead. */ - public function getTriggerHtml(): ?string + class Edit extends \CraftCms\Cms\Element\Actions\Edit { - HtmlStack::jsWithVars(fn($type) => << { - new Craft.ElementActionTrigger({ - type: $type, - bulk: false, - validateSelection: (selectedItems, elementIndex) => Garnish.hasAttr(selectedItems.find('.element'), 'data-savable'), - activate: (selectedItems, elementIndex) => { - const \$element = selectedItems.find('.element:first'); - Craft.createElementEditor(\$element.data('type'), \$element); - }, - }); -})(); -JS, [static::class]); - - return null; } } + +class_alias(\CraftCms\Cms\Element\Actions\Edit::class, Edit::class); diff --git a/yii2-adapter/legacy/elements/actions/EditImage.php b/yii2-adapter/legacy/elements/actions/EditImage.php index cd3e2aac1bc..8c432184776 100644 --- a/yii2-adapter/legacy/elements/actions/EditImage.php +++ b/yii2-adapter/legacy/elements/actions/EditImage.php @@ -1,66 +1,15 @@ - * @since 3.0.0 - */ -class EditImage extends ElementAction -{ - /** - * @var string The trigger label - */ - public string $label; - - /** - * @inheritdoc - */ - public function init(): void - { - if (!isset($this->label)) { - $this->label = t('Edit Image'); - } - } - - /** - * @inheritdoc - */ - public function getTriggerLabel(): string - { - return $this->label; - } - +/** @phpstan-ignore-next-line */ +if (false) { /** - * @inheritdoc + * @deprecated 6.0.0 use {@see \CraftCms\Cms\Asset\Actions\EditImage} instead. */ - public function getTriggerHtml(): ?string + class EditImage extends \CraftCms\Cms\Asset\Actions\EditImage { - HtmlStack::jsWithVars(fn($type) => << { - new Craft.ElementActionTrigger({ - type: $type, - bulk: false, - validateSelection: (selectedItems, elementIndex) => Garnish.hasAttr(selectedItems.find('.element'), 'data-editable-image'), - activate: (selectedItems, elementIndex) => { - const \$element = selectedItems.find('.element:first'); - new Craft.AssetImageEditor(\$element.data('id')); - }, - }); -})(); -JS, [static::class]); - - return null; } } + +class_alias(\CraftCms\Cms\Asset\Actions\EditImage::class, EditImage::class); diff --git a/yii2-adapter/legacy/elements/actions/MoveAssets.php b/yii2-adapter/legacy/elements/actions/MoveAssets.php index 156e75d6d03..4ecdc422513 100644 --- a/yii2-adapter/legacy/elements/actions/MoveAssets.php +++ b/yii2-adapter/legacy/elements/actions/MoveAssets.php @@ -1,115 +1,15 @@ - * @since 4.4.0 - */ -class MoveAssets extends ElementAction -{ +/** @phpstan-ignore-next-line */ +if (false) { /** - * @inheritdoc + * @deprecated 6.0.0 use {@see \CraftCms\Cms\Asset\Actions\MoveAssets} instead. */ - public function getTriggerLabel(): string + class MoveAssets extends \CraftCms\Cms\Asset\Actions\MoveAssets { - return t('Move…'); - } - - /** - * @inheritdoc - */ - public function getTriggerHtml(): ?string - { - HtmlStack::jsWithVars(fn($actionClass) => << { - const groupItems = function(\$items) { - const \$folders = \$items.has('.element[data-is-folder]'); - const \$assets = \$items.not(\$folders); - return [\$folders, \$assets]; - }; - - const peerFiles = function(\$folders, \$assets) { - return !!(\$folders.length || \$assets.has('.element[data-peer-file]').length) - }; - - new Craft.ElementActionTrigger({ - type: $actionClass, - bulk: true, - requireId: false, - validateSelection: (selectedItems, elementIndex) => { - for (let i = 0; i < selectedItems.length; i++) { - if (!Garnish.hasAttr(selectedItems.eq(i).find('.element'), 'data-movable')) { - return false; - } - } - return elementIndex.getMoveTargetSourceKeys(peerFiles(...groupItems(selectedItems))).length; - }, - activate: (selectedItems, elementIndex) => { - const [\$folders, \$assets] = groupItems(selectedItems); - const selectedFolderIds = \$folders.toArray().map((item) => { - return parseInt($(item).find('.element:first').data('folder-id')); - }); - const disabledFolderIds = selectedFolderIds.slice(); - if (elementIndex.sourcePath.length) { - const currentFolder = elementIndex.sourcePath[elementIndex.sourcePath.length - 1]; - if (currentFolder.folderId) { - disabledFolderIds.push(currentFolder.folderId); - } - } - const selectedAssetIds = \$assets.toArray().map((item) => { - return parseInt($(item).data('id')); - }); - - new Craft.VolumeFolderSelectorModal({ - sources: elementIndex.getMoveTargetSourceKeys(peerFiles(\$folders, \$assets)), - showTitle: true, - modalTitle: Craft.t('app', 'Move to'), - selectBtnLabel: Craft.t('app', 'Move'), - disabledFolderIds: disabledFolderIds, - indexSettings: { - defaultSource: elementIndex.sourceKey, - defaultSourcePath: elementIndex.sourcePath, - }, - onSelect: async ([targetFolder]) => { - const mover = new Craft.AssetMover(); - const moveParams = await mover.getMoveParams(selectedFolderIds, selectedAssetIds); - if (!moveParams.proceed) { - return; - } - const totalFoldersMoved = await mover.moveFolders(selectedFolderIds, targetFolder.folderId, elementIndex.currentFolderId); - const totalAssetsMoved = await mover.moveAssets(selectedAssetIds, targetFolder.folderId, elementIndex.currentFolderId); - const totalItemsMoved = totalFoldersMoved + totalAssetsMoved; - if (totalItemsMoved) { - mover.successNotice( - moveParams, - Craft.t('app', '{totalItems, plural, =1{Item} other{Items}} moved.', { - totalItems: totalItemsMoved, - }) - ); - elementIndex.updateElements(true); - } - }, - }); - }, - }) -})(); -JS, [ - static::class, - ]); - - return null; } } + +class_alias(\CraftCms\Cms\Asset\Actions\MoveAssets::class, MoveAssets::class); diff --git a/yii2-adapter/legacy/elements/actions/MoveDown.php b/yii2-adapter/legacy/elements/actions/MoveDown.php index 770c69fb340..304b3f5134f 100644 --- a/yii2-adapter/legacy/elements/actions/MoveDown.php +++ b/yii2-adapter/legacy/elements/actions/MoveDown.php @@ -1,105 +1,15 @@ - * @since 5.7.0 - */ -class MoveDown extends ElementAction -{ - /** - * Constructor - * - * @param ElementInterface $owner The owner element - * @param string $attribute The attribute name that nested elements are accessible by, from the owner element. - */ - public function __construct( - private readonly ElementInterface $owner, - private readonly string $attribute, - $config = [], - ) { - parent::__construct($config); - } - - /** - * @inheritdoc - */ - public function getTriggerLabel(): string - { - return t('Move backward'); //down - } - +/** @phpstan-ignore-next-line */ +if (false) { /** - * @inheritdoc + * @deprecated 6.0.0 use {@see \CraftCms\Cms\Element\Actions\MoveDown} instead. */ - public function getTriggerHtml(): ?string + class MoveDown extends \CraftCms\Cms\Element\Actions\MoveDown { - HtmlStack::jsWithVars( - fn($type, $params) => << { - new Craft.ElementActionTrigger({ - type: $type, - bulk: false, - validateSelection: (selectedItems, elementIndex) => { - return ( - elementIndex.sortable && - selectedItems.parent().children().last().data('id') !== selectedItems.data('id') - ); - }, - activate: async (selectedItems, elementIndex) => { - const selectedItemIndex = Object.values(elementIndex.view.getAllElements()).indexOf(selectedItems[0]); - const offset = selectedItemIndex + 1; - await elementIndex.settings.onBeforeReorderElements(selectedItems, offset); - - const data = Object.assign($params, { - elementIds: elementIndex.getSelectedElementIds(), - offset: offset, - }); - - // swap out the ownerId with the new draft ownerId - const elementEditor = elementIndex.\$container.closest('form').data('elementEditor'); - if (elementEditor) { - data.ownerId = elementEditor.getDraftElementId(data.ownerId); - } - - let response; - try { - response = await Craft.sendActionRequest('POST', 'nested-elements/reorder', {data}); - } catch (e) { - Craft.cp.displayError(response.data && response.data.error); - return; - } - - Craft.cp.displayNotice(response.data.message); - await elementIndex.settings.onReorderElements(selectedItems, offset); - elementIndex.updateElements(true, true); - }, - }); -})(); -JS, - [ - static::class, - [ - 'ownerElementType' => get_class($this->owner), - 'ownerId' => $this->owner->id, - 'ownerSiteId' => $this->owner->siteId, - 'attribute' => $this->attribute, - ], - ]); - - return null; } } + +class_alias(\CraftCms\Cms\Element\Actions\MoveDown::class, MoveDown::class); diff --git a/yii2-adapter/legacy/elements/actions/MoveToSection.php b/yii2-adapter/legacy/elements/actions/MoveToSection.php index 676163f9e5b..ff368f30fae 100644 --- a/yii2-adapter/legacy/elements/actions/MoveToSection.php +++ b/yii2-adapter/legacy/elements/actions/MoveToSection.php @@ -1,69 +1,15 @@ - * @since 5.3.0 - */ -class MoveToSection extends ElementAction -{ +/** @phpstan-ignore-next-line */ +if (false) { /** - * @inheritdoc + * @deprecated 6.0.0 use {@see \CraftCms\Cms\Entry\Actions\MoveToSection} instead. */ - public function getTriggerLabel(): string + class MoveToSection extends \CraftCms\Cms\Entry\Actions\MoveToSection { - return t('Move to…'); - } - - /** - * @inheritdoc - */ - public function getTriggerHtml(): ?string - { - if ($this->elementType !== Entry::class) { - throw new Exception("Move to section is only available for Entries."); - } - - HtmlStack::jsWithVars(fn($type) => << { - new Craft.ElementActionTrigger({ - type: $type, - bulk: true, - validateSelection: (selectedItems, elementIndex) => { - for (let i = 0; i < selectedItems.length; i++) { - if (!Garnish.hasAttr(selectedItems.eq(i).find('.element'), 'data-movable')) { - return false; - } - } - - return true; - }, - activate: (selectedItems, elementIndex) => { - let entryIds = []; - for (let i = 0; i < selectedItems.length; i++) { - entryIds.push(selectedItems.eq(i).find('.element').data('id')); - } - - new Craft.EntryMover(entryIds, elementIndex); - }, - }) -})(); -JS, [static::class]); - - return null; } } + +class_alias(\CraftCms\Cms\Entry\Actions\MoveToSection::class, MoveToSection::class); diff --git a/yii2-adapter/legacy/elements/actions/MoveUp.php b/yii2-adapter/legacy/elements/actions/MoveUp.php index ebc52d8ddb8..936ceaa6861 100644 --- a/yii2-adapter/legacy/elements/actions/MoveUp.php +++ b/yii2-adapter/legacy/elements/actions/MoveUp.php @@ -1,105 +1,15 @@ - * @since 5.7.0 - */ -class MoveUp extends ElementAction -{ - /** - * Constructor - * - * @param ElementInterface $owner The owner element - * @param string $attribute The attribute name that nested elements are accessible by, from the owner element. - */ - public function __construct( - private readonly ElementInterface $owner, - private readonly string $attribute, - $config = [], - ) { - parent::__construct($config); - } - - /** - * @inheritdoc - */ - public function getTriggerLabel(): string - { - return t('Move forward'); //up - } - +/** @phpstan-ignore-next-line */ +if (false) { /** - * @inheritdoc + * @deprecated 6.0.0 use {@see \CraftCms\Cms\Element\Actions\MoveUp} instead. */ - public function getTriggerHtml(): ?string + class MoveUp extends \CraftCms\Cms\Element\Actions\MoveUp { - HtmlStack::jsWithVars( - fn($type, $params) => << { - new Craft.ElementActionTrigger({ - type: $type, - bulk: false, - validateSelection: (selectedItems, elementIndex) => { - return ( - elementIndex.sortable && - selectedItems.parent().children().first().data('id') !== selectedItems.data('id') - ); - }, - activate: async (selectedItems, elementIndex) => { - const selectedItemIndex = Object.values(elementIndex.view.getAllElements()).indexOf(selectedItems[0]); - const offset = selectedItemIndex - 1; - await elementIndex.settings.onBeforeReorderElements(selectedItems, offset); - - const data = Object.assign($params, { - elementIds: elementIndex.getSelectedElementIds(), - offset: offset, - }); - - // swap out the ownerId with the new draft ownerId - const elementEditor = elementIndex.\$container.closest('form').data('elementEditor'); - if (elementEditor) { - data.ownerId = elementEditor.getDraftElementId(data.ownerId); - } - - let response; - try { - response = await Craft.sendActionRequest('POST', 'nested-elements/reorder', {data}); - } catch (e) { - Craft.cp.displayError(response.data && response.data.error); - return; - } - - Craft.cp.displayNotice(response.data.message); - await elementIndex.settings.onReorderElements(selectedItems, offset); - elementIndex.updateElements(true, true); - }, - }); -})(); -JS, - [ - static::class, - [ - 'ownerElementType' => get_class($this->owner), - 'ownerId' => $this->owner->id, - 'ownerSiteId' => $this->owner->siteId, - 'attribute' => $this->attribute, - ], - ]); - - return null; } } + +class_alias(\CraftCms\Cms\Element\Actions\MoveUp::class, MoveUp::class); diff --git a/yii2-adapter/legacy/elements/actions/NewChild.php b/yii2-adapter/legacy/elements/actions/NewChild.php index 113477c41d5..e890dc64557 100644 --- a/yii2-adapter/legacy/elements/actions/NewChild.php +++ b/yii2-adapter/legacy/elements/actions/NewChild.php @@ -1,90 +1,15 @@ - * @since 3.0.0 - */ -class NewChild extends ElementAction -{ - /** - * @var string|null The trigger label - */ - public ?string $label = null; - - /** - * @var int|null The maximum number of levels that the structure is allowed to have - */ - public ?int $maxLevels = null; - +/** @phpstan-ignore-next-line */ +if (false) { /** - * @var string|null The URL that the user should be taken to after clicking on this element action + * @deprecated 6.0.0 use {@see \CraftCms\Cms\Entry\Actions\NewChild} instead. */ - public ?string $newChildUrl = null; - - /** - * @inheritdoc - */ - public function setElementType(string $elementType): void + class NewChild extends \CraftCms\Cms\Entry\Actions\NewChild { - parent::setElementType($elementType); - - if (!isset($this->label)) { - $this->label = t('Create a new child {type}', [ - 'type' => $elementType::lowerDisplayName(), - ]); - } - } - - /** - * @inheritdoc - */ - public function getTriggerLabel(): string - { - return $this->label; - } - - /** - * @inheritdoc - */ - public function getTriggerHtml(): ?string - { - HtmlStack::jsWithVars(fn($type, $maxLevels, $newChildUrl) => << { - let trigger = new Craft.ElementActionTrigger({ - type: $type, - bulk: false, - validateSelection: (selectedItems, elementIndex) => { - const element = selectedItems.find('.element'); - return ( - (!$maxLevels || $maxLevels > element.data('level')) && - !Garnish.hasAttr(element, 'data-disallow-new-children') - ); - }, - activate: (selectedItems, elementIndex) => { - const url = Craft.getUrl($newChildUrl, 'parentId=' + selectedItems.find('.element').data('id')); - Craft.redirectTo(url); - }, - }); - - if (Craft.currentElementIndex.view.tableSort) { - Craft.currentElementIndex.view.tableSort.on('positionChange', $.proxy(trigger, 'updateTrigger')); - } -})(); -JS, [static::class, $this->maxLevels, $this->newChildUrl]); - - return null; } } + +class_alias(\CraftCms\Cms\Entry\Actions\NewChild::class, NewChild::class); diff --git a/yii2-adapter/legacy/elements/actions/NewSiblingAfter.php b/yii2-adapter/legacy/elements/actions/NewSiblingAfter.php index c75717faed0..58ad3b18b4d 100644 --- a/yii2-adapter/legacy/elements/actions/NewSiblingAfter.php +++ b/yii2-adapter/legacy/elements/actions/NewSiblingAfter.php @@ -1,73 +1,15 @@ - * @since 3.7.0 - */ -class NewSiblingAfter extends ElementAction -{ - /** - * @var string|null The trigger label - */ - public ?string $label = null; - - /** - * @var string|null The URL that the user should be taken to after clicking on this element action - */ - public ?string $newSiblingUrl = null; - - /** - * @inheritdoc - */ - public function setElementType(string $elementType): void - { - parent::setElementType($elementType); - - if (!isset($this->label)) { - $this->label = t('Create a new {type} after', [ - 'type' => $elementType::lowerDisplayName(), - ]); - } - } - - /** - * @inheritdoc - */ - public function getTriggerLabel(): string - { - return $this->label; - } - +/** @phpstan-ignore-next-line */ +if (false) { /** - * @inheritdoc + * @deprecated 6.0.0 use {@see \CraftCms\Cms\Entry\Actions\NewSiblingAfter} instead. */ - public function getTriggerHtml(): ?string + class NewSiblingAfter extends \CraftCms\Cms\Entry\Actions\NewSiblingAfter { - HtmlStack::jsWithVars(fn($type, $newSiblingUrl) => << { - new Craft.ElementActionTrigger({ - type: $type, - bulk: false, - activate: (selectedItems, elementIndex) => { - Craft.redirectTo(Craft.getUrl($newSiblingUrl, 'after=' + selectedItems.find('.element').data('id'))); - }, - }); -})(); -JS, [static::class, $this->newSiblingUrl]); - - return null; } } + +class_alias(\CraftCms\Cms\Entry\Actions\NewSiblingAfter::class, NewSiblingAfter::class); diff --git a/yii2-adapter/legacy/elements/actions/NewSiblingBefore.php b/yii2-adapter/legacy/elements/actions/NewSiblingBefore.php index 0a677d3c1f0..3d5f9d5717f 100644 --- a/yii2-adapter/legacy/elements/actions/NewSiblingBefore.php +++ b/yii2-adapter/legacy/elements/actions/NewSiblingBefore.php @@ -1,73 +1,15 @@ - * @since 3.7.0 - */ -class NewSiblingBefore extends ElementAction -{ - /** - * @var string|null The trigger label - */ - public ?string $label = null; - - /** - * @var string|null The URL that the user should be taken to after clicking on this element action - */ - public ?string $newSiblingUrl = null; - - /** - * @inheritdoc - */ - public function setElementType(string $elementType): void - { - parent::setElementType($elementType); - - if (!isset($this->label)) { - $this->label = t('Create a new {type} before', [ - 'type' => $elementType::lowerDisplayName(), - ]); - } - } - - /** - * @inheritdoc - */ - public function getTriggerLabel(): string - { - return $this->label; - } - +/** @phpstan-ignore-next-line */ +if (false) { /** - * @inheritdoc + * @deprecated 6.0.0 use {@see \CraftCms\Cms\Entry\Actions\NewSiblingBefore} instead. */ - public function getTriggerHtml(): ?string + class NewSiblingBefore extends \CraftCms\Cms\Entry\Actions\NewSiblingBefore { - HtmlStack::jsWithVars(fn($type, $newSiblingUrl) => << { - new Craft.ElementActionTrigger({ - type: $type, - bulk: false, - activate: (selectedItems, elementIndex) => { - Craft.redirectTo(Craft.getUrl($newSiblingUrl, 'before=' + selectedItems.find('.element').data('id'))); - }, - }); -})(); -JS, [static::class, $this->newSiblingUrl]); - - return null; } } + +class_alias(\CraftCms\Cms\Entry\Actions\NewSiblingBefore::class, NewSiblingBefore::class); diff --git a/yii2-adapter/legacy/elements/actions/PreviewAsset.php b/yii2-adapter/legacy/elements/actions/PreviewAsset.php index 722eb404f28..dc65f34a44b 100644 --- a/yii2-adapter/legacy/elements/actions/PreviewAsset.php +++ b/yii2-adapter/legacy/elements/actions/PreviewAsset.php @@ -1,71 +1,15 @@ - * @since 3.0.0 - */ -class PreviewAsset extends ElementAction -{ - /** - * @var string|null The trigger label - */ - public ?string $label = null; - - /** - * @inheritdoc - */ - public function init(): void - { - if (!isset($this->label)) { - $this->label = t('Preview file'); - } - } - - /** - * @inheritdoc - */ - public function getTriggerLabel(): string - { - return $this->label; - } - +/** @phpstan-ignore-next-line */ +if (false) { /** - * @inheritdoc + * @deprecated 6.0.0 use {@see \CraftCms\Cms\Asset\Actions\PreviewAsset} instead. */ - public function getTriggerHtml(): ?string + class PreviewAsset extends \CraftCms\Cms\Asset\Actions\PreviewAsset { - HtmlStack::jsWithVars(fn($type) => << { - new Craft.ElementActionTrigger({ - type: $type, - bulk: false, - validateSelection: (selectedItems, elementIndex) => selectedItems.length === 1, - activate: (selectedItems, elementIndex) => { - const \$element = selectedItems.find('.element'); - const settings = {}; - if (\$element.data('image-width')) { - settings.startingWidth = \$element.data('image-width'); - settings.startingHeight = \$element.data('image-height'); - } - new Craft.PreviewFileModal(\$element.data('id'), elementIndex.view.elementSelect, settings); - }, - }); -})(); -JS, [static::class]); - - return null; } } + +class_alias(\CraftCms\Cms\Asset\Actions\PreviewAsset::class, PreviewAsset::class); diff --git a/yii2-adapter/legacy/elements/actions/RenameFile.php b/yii2-adapter/legacy/elements/actions/RenameFile.php index 4673619f059..79eff89ebc5 100644 --- a/yii2-adapter/legacy/elements/actions/RenameFile.php +++ b/yii2-adapter/legacy/elements/actions/RenameFile.php @@ -1,100 +1,15 @@ - * @since 3.0.0 - */ -class RenameFile extends ElementAction -{ - /** - * @inheritdoc - */ - public function getTriggerLabel(): string - { - return t('Rename file'); - } - +/** @phpstan-ignore-next-line */ +if (false) { /** - * @inheritdoc + * @deprecated 6.0.0 use {@see \CraftCms\Cms\Asset\Actions\RenameFile} instead. */ - public function getTriggerHtml(): ?string + class RenameFile extends \CraftCms\Cms\Asset\Actions\RenameFile { - HtmlStack::jsWithVars( - fn($type, $prompt) => << { - new Craft.ElementActionTrigger({ - type: $type, - bulk: false, - validateSelection: (selectedItems, elementIndex) => Garnish.hasAttr(selectedItems.find('.element'), 'data-movable'), - activate: (selectedItems, elementIndex) => { - const \$element = selectedItems.find('.element') - const assetId = \$element.data('id'); - let oldName = \$element.data('filename'); - - const newName = prompt($prompt, oldName); - - if (!newName || newName == oldName) - { - return; - } - - elementIndex.setIndexBusy(); - - let currentFolderId = elementIndex.\$source.data('folder-id'); - const currentFolder = elementIndex.sourcePath[elementIndex.sourcePath.length - 1]; - if (currentFolder && currentFolder.folderId) { - currentFolderId = currentFolder.folderId; - } - - const data = { - assetId: assetId, - folderId: currentFolderId, - filename: newName - }; - - Craft.sendActionRequest('POST', 'assets/move-asset', {data}) - .then(response => { - if (response.data.conflict) { - alert(response.data.conflict); - this.activate(selectedItems); - return; - } - - if (response.data.success) { - elementIndex.updateElements(); - - // If assets were just merged we should get the reference tags updated right away - Craft.cp.runQueue(); - } - }) - .catch(({response}) => { - alert(response.data.message) - }) - .finally(() => { - elementIndex.setIndexAvailable(); - }); - }, - }); -})(); -JS, - [ - static::class, - t('Enter the new filename'), - ]); - - return null; } } + +class_alias(\CraftCms\Cms\Asset\Actions\RenameFile::class, RenameFile::class); diff --git a/yii2-adapter/legacy/elements/actions/ReplaceFile.php b/yii2-adapter/legacy/elements/actions/ReplaceFile.php index 458da89c16e..df8657787d7 100644 --- a/yii2-adapter/legacy/elements/actions/ReplaceFile.php +++ b/yii2-adapter/legacy/elements/actions/ReplaceFile.php @@ -1,87 +1,15 @@ - * @since 3.0.0 - */ -class ReplaceFile extends ElementAction -{ +/** @phpstan-ignore-next-line */ +if (false) { /** - * @inheritdoc + * @deprecated 6.0.0 use {@see \CraftCms\Cms\Asset\Actions\ReplaceFile} instead. */ - public function getTriggerLabel(): string + class ReplaceFile extends \CraftCms\Cms\Asset\Actions\ReplaceFile { - return t('Replace file'); - } - - /** - * @inheritdoc - */ - public function getTriggerHtml(): ?string - { - HtmlStack::jsWithVars(fn($type) => << { - new Craft.ElementActionTrigger({ - type: $type, - bulk: false, - validateSelection: (selectedItems, elementIndex) => Garnish.hasAttr(selectedItems.find('.element'), 'data-replaceable'), - activate: (selectedItems, elementIndex) => { - $('.replaceFile').remove(); - - const \$element = selectedItems.find('.element'); - const \$fileInput = $('').appendTo(Garnish.\$bod); - const settings = elementIndex._currentUploaderSettings; - - settings.dropZone = null; - settings.fileInput = \$fileInput; - settings.paramName = 'replaceFile'; - settings.replace = true; - settings.events = {}; - - const fileuploaddone = settings.events?.fileuploaddone; - settings.events = Object.assign({}, settings.events || {}, { - fileuploaddone: (event, data = null) => { - const result = event instanceof CustomEvent ? event.detail : data.result; - if (!result.error) { - Craft.cp.displayNotice(Craft.t('app', 'New file uploaded.')); - // update the element row - if (Craft.broadcaster) { - Craft.broadcaster.postMessage({ - event: 'saveElement', - id: result.assetId, - }); - } - } - if (fileuploaddone) { - fileuploaddone(event, data); - } - } - }); - - const tempUploader = Craft.createUploader(elementIndex.uploader.fsType, \$fileInput, settings); - tempUploader.setParams({ - assetId: \$element.data('id') - }); - - \$fileInput.click(); - }, - }); -})(); -JS, [static::class]); - - return null; } } + +class_alias(\CraftCms\Cms\Asset\Actions\ReplaceFile::class, ReplaceFile::class); diff --git a/yii2-adapter/legacy/elements/actions/Restore.php b/yii2-adapter/legacy/elements/actions/Restore.php index 652ce3798ae..d0384f68bbf 100644 --- a/yii2-adapter/legacy/elements/actions/Restore.php +++ b/yii2-adapter/legacy/elements/actions/Restore.php @@ -1,141 +1,15 @@ - * @since 3.1.0 - */ -class Restore extends ElementAction -{ - /** - * @var string|null The message that should be shown after the elements get restored - */ - public ?string $successMessage = null; - - /** - * @var string|null The message that should be shown after some elements get restored - */ - public ?string $partialSuccessMessage = null; - - /** - * @var string|null The message that should be shown if no elements get restored - */ - public ?string $failMessage = null; - - /** - * @var bool Whether the action should only be available for elements with a `data-restorable` attribute - * @since 4.3.0 - */ - public bool $restorableElementsOnly = false; - - /** - * @inheritdoc - */ - public function setElementType(string $elementType): void - { - parent::setElementType($elementType); - - if (!isset($this->successMessage)) { - $this->successMessage = t('{type} restored.', [ - 'type' => $elementType::pluralDisplayName(), - ]); - } - - if (!isset($this->partialSuccessMessage)) { - $this->partialSuccessMessage = t('Some {type} restored.', [ - 'type' => $elementType::pluralLowerDisplayName(), - ]); - } - - if (!isset($this->failMessage)) { - $this->failMessage = t('{type} not restored.', [ - 'type' => $elementType::pluralDisplayName(), - ]); - } - } - - /** - * @inheritdoc - */ - public function getTriggerLabel(): string - { - return t('Restore'); - } - +/** @phpstan-ignore-next-line */ +if (false) { /** - * @inheritdoc + * @deprecated 6.0.0 use {@see \CraftCms\Cms\Element\Actions\Restore} instead. */ - public function getTriggerHtml(): ?string + class Restore extends \CraftCms\Cms\Element\Actions\Restore { - // 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() . '
'; - } - - /** - * @inheritdoc - */ - public function performAction(ElementQueryInterface $query): bool - { - $anySuccess = false; - $anyFail = false; - - foreach ($query->all() as $element) { - if (!Gate::check('save', $element)) { - continue; - } - - if (Elements::restoreElement($element)) { - $anySuccess = true; - } else { - $anyFail = true; - } - } - - if (!$anySuccess && $anyFail) { - $this->setMessage($this->failMessage); - return false; - } - - if ($anyFail) { - $this->setMessage($this->partialSuccessMessage); - } else { - $this->setMessage($this->successMessage); - } - - return true; } } + +class_alias(\CraftCms\Cms\Element\Actions\Restore::class, Restore::class); diff --git a/yii2-adapter/legacy/elements/actions/SetStatus.php b/yii2-adapter/legacy/elements/actions/SetStatus.php index 2442015ab99..9c78833b905 100644 --- a/yii2-adapter/legacy/elements/actions/SetStatus.php +++ b/yii2-adapter/legacy/elements/actions/SetStatus.php @@ -1,160 +1,15 @@ - * @since 3.0.0 - */ -class SetStatus extends ElementAction -{ - public const ENABLED = 'enabled'; - /** - * @since 3.4.0 - */ - public const DISABLED = 'disabled'; - - /** - * @var string|null The status elements should be set to - */ - public ?string $status = null; - - /** - * @inheritdoc - */ - public function getTriggerLabel(): string - { - return t('Set Status'); - } - - /** - * @inheritdoc - */ - protected function defineRules(): array - { - $rules = parent::defineRules(); - $rules[] = [['status'], 'required']; - $rules[] = [['status'], 'in', 'range' => [self::ENABLED, self::DISABLED]]; - return $rules; - } - +/** @phpstan-ignore-next-line */ +if (false) { /** - * @inheritdoc + * @deprecated 6.0.0 use {@see \CraftCms\Cms\Element\Actions\SetStatus} instead. */ - public function getTriggerHtml(): ?string + class SetStatus extends \CraftCms\Cms\Element\Actions\SetStatus { - 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'); - } - - /** - * @inheritdoc - */ - public function performAction(ElementQueryInterface $query): bool - { - /** @var class-string $elementType */ - $elementType = $this->elementType; - $isLocalized = $elementType::isLocalized() && Sites::isMultiSite(); - - $elements = $query->all(); - $failCount = 0; - - foreach ($elements as $element) { - if (!Gate::check('save', $element)) { - continue; - } - - switch ($this->status) { - case self::ENABLED: - // Skip if there's nothing to change - if ($element->enabled && $element->getEnabledForSite()) { - continue 2; - } - - $element->enabled = true; - $element->setEnabledForSite(true); - $element->setScenario(Element::SCENARIO_LIVE); - break; - - case self::DISABLED: - // Is this a multi-site element? - if ($isLocalized && count($element->getSupportedSites()) !== 1) { - // Skip if there's nothing to change - if (!$element->getEnabledForSite()) { - continue 2; - } - $element->setEnabledForSite(false); - } else { - // Skip if there's nothing to change - if (!$element->enabled) { - continue 2; - } - $element->enabled = false; - } - break; - } - - if (Elements::saveElement($element) === false) { - // Validation error - $failCount++; - } - } - - // Did all of them fail? - if ($failCount === count($elements)) { - if (count($elements) === 1) { - $this->setMessage(t('Could not update status due to a validation error.')); - } else { - $this->setMessage(t('Could not update statuses due to validation errors.')); - } - - return false; - } - - if ($failCount !== 0) { - $this->setMessage(t('Status updated, with some failures due to validation errors.')); - } else { - if (count($elements) === 1) { - $this->setMessage(t('Status updated.')); - } else { - $this->setMessage(t('Statuses updated.')); - } - } - - return true; } } + +class_alias(\CraftCms\Cms\Element\Actions\SetStatus::class, SetStatus::class); diff --git a/yii2-adapter/legacy/elements/actions/ShowInFolder.php b/yii2-adapter/legacy/elements/actions/ShowInFolder.php index 073901cb385..ed0724c42ab 100644 --- a/yii2-adapter/legacy/elements/actions/ShowInFolder.php +++ b/yii2-adapter/legacy/elements/actions/ShowInFolder.php @@ -1,73 +1,15 @@ - * @since 5.0.0 - */ -class ShowInFolder extends ElementAction -{ - /** - * @inheritdoc - */ - public function getTriggerLabel(): string - { - return t('Show in folder'); - } - +/** @phpstan-ignore-next-line */ +if (false) { /** - * @inheritdoc + * @deprecated 6.0.0 use {@see \CraftCms\Cms\Asset\Actions\ShowInFolder} instead. */ - public function getTriggerHtml(): ?string + class ShowInFolder extends \CraftCms\Cms\Asset\Actions\ShowInFolder { - if ($this->elementType !== Asset::class) { - throw new Exception("Show in folder is only available for Assets."); - } - - HtmlStack::jsWithVars(fn($type) => << { - new Craft.ElementActionTrigger({ - type: $type, - bulk: false, - activate: (selectedItem, elementIndex) => { - const data = { - 'assetId': selectedItem.find('.element:first').data('id') - } - - Craft.sendActionRequest('POST', 'assets/show-in-folder', {data}) - .then(({data}) => { - elementIndex.sourcePath = data.sourcePath; - elementIndex.stopSearching(); - - // prevent searching in subfolders - we want the exact folder the asset belongs to - elementIndex.setSelecetedSourceState('includeSubfolders', false); - - // search for the selected asset's filename - elementIndex.\$search.val(data.filename); - elementIndex.\$search.trigger('input'); - }) - .catch((e) => { - Craft.cp.displayError(e?.response?.data?.message); - }); - }, - }) -})(); -JS, [static::class]); - - return null; } } + +class_alias(\CraftCms\Cms\Asset\Actions\ShowInFolder::class, ShowInFolder::class); diff --git a/yii2-adapter/legacy/elements/actions/SuspendUsers.php b/yii2-adapter/legacy/elements/actions/SuspendUsers.php index 092926ff37d..8a6c857c17d 100644 --- a/yii2-adapter/legacy/elements/actions/SuspendUsers.php +++ b/yii2-adapter/legacy/elements/actions/SuspendUsers.php @@ -1,105 +1,15 @@ - * @since 3.0.0 - */ -class SuspendUsers extends ElementAction -{ - /** - * @inheritdoc - */ - public function getTriggerLabel(): string - { - return t('Suspend'); - } - - /** - * @inheritdoc - */ - 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, - Craft::$app->getUser()->getId(), - ]); - - return null; - } - +/** @phpstan-ignore-next-line */ +if (false) { /** - * @inheritdoc + * @deprecated 6.0.0 use {@see \CraftCms\Cms\User\Actions\SuspendUsers} instead. */ - public function performAction(ElementQueryInterface $query): bool + class SuspendUsers extends \CraftCms\Cms\User\Actions\SuspendUsers { - /** @var ElementQuery $query */ - // Get the users that aren't already suspended - $query->status = UserQuery::STATUS_CREDENTIALED; - - /** @var User[] $users */ - $users = $query->all(); - $currentUser = Auth::user(); - - $successCount = count(array_filter($users, function(User $user) use ($currentUser) { - try { - if (!Users::canSuspend($currentUser, $user)) { - return false; - } - Users::suspendUser($user); - return true; - } catch (Throwable) { - return false; - } - })); - - if ($successCount !== count($users)) { - $this->setMessage(t('Couldn’t suspend all users.')); - return false; - } - - $this->setMessage(t('Users suspended.')); - return true; } } + +class_alias(\CraftCms\Cms\User\Actions\SuspendUsers::class, SuspendUsers::class); diff --git a/yii2-adapter/legacy/elements/actions/UnsuspendUsers.php b/yii2-adapter/legacy/elements/actions/UnsuspendUsers.php index acd4f559f8b..e83e0715d8a 100644 --- a/yii2-adapter/legacy/elements/actions/UnsuspendUsers.php +++ b/yii2-adapter/legacy/elements/actions/UnsuspendUsers.php @@ -1,98 +1,15 @@ - * @since 3.0.0 - */ -class UnsuspendUsers extends ElementAction -{ +/** @phpstan-ignore-next-line */ +if (false) { /** - * @inheritdoc + * @deprecated 6.0.0 use {@see \CraftCms\Cms\User\Actions\UnsuspendUsers} instead. */ - public function getTriggerLabel(): string + class UnsuspendUsers extends \CraftCms\Cms\User\Actions\UnsuspendUsers { - return t('Unsuspend'); - } - - /** - * @inheritdoc - */ - 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; - } - - /** - * @inheritdoc - */ - public function performAction(ElementQueryInterface $query): bool - { - // Get the users that are suspended - $query->status(User::STATUS_SUSPENDED); - /** @var User[] $users */ - $users = $query->all(); - $currentUser = Auth::user(); - - $successCount = count(array_filter($users, function(User $user) use ($currentUser) { - if (!Users::canSuspend($currentUser, $user)) { - return false; - } - try { - Users::unsuspendUser($user); - return true; - } catch (Throwable) { - return false; - } - })); - - if ($successCount !== count($users)) { - $this->setMessage(t('Couldn’t unsuspend all users.')); - return false; - } - - $this->setMessage(t('Users unsuspended.')); - return true; } } + +class_alias(\CraftCms\Cms\User\Actions\UnsuspendUsers::class, UnsuspendUsers::class); diff --git a/yii2-adapter/legacy/elements/actions/View.php b/yii2-adapter/legacy/elements/actions/View.php index 8b0f1f3b909..17070489af8 100644 --- a/yii2-adapter/legacy/elements/actions/View.php +++ b/yii2-adapter/legacy/elements/actions/View.php @@ -1,71 +1,15 @@ - * @since 3.0.0 - */ -class View extends ElementAction -{ - /** - * @var string|null The trigger label - */ - public ?string $label = null; - - /** - * @inheritdoc - */ - public function init(): void - { - if (!isset($this->label)) { - $this->label = t('View'); - } - } - - /** - * @inheritdoc - */ - public function getTriggerLabel(): string - { - return $this->label; - } - +/** @phpstan-ignore-next-line */ +if (false) { /** - * @inheritdoc + * @deprecated 6.0.0 use {@see \CraftCms\Cms\Element\Actions\View} instead. */ - public function getTriggerHtml(): ?string + class View extends \CraftCms\Cms\Element\Actions\View { - HtmlStack::jsWithVars(fn($type) => << { - new Craft.ElementActionTrigger({ - type: $type, - bulk: false, - validateSelection: (selectedItems, elementIndex) => { - const \$element = selectedItems.find('.element'); - return ( - \$element.data('url') && - (\$element.data('status') === 'enabled' || \$element.data('status') === 'live') - ); - }, - activate: (selectedItems, elementIndex) => { - window.open(selectedItems.find('.element').data('url')); - }, - }); -})(); -JS, [static::class]); - - return null; } } + +class_alias(\CraftCms\Cms\Element\Actions\View::class, View::class); diff --git a/yii2-adapter/legacy/elements/exporters/Expanded.php b/yii2-adapter/legacy/elements/exporters/Expanded.php index ca8c6bf4501..1062c6da506 100644 --- a/yii2-adapter/legacy/elements/exporters/Expanded.php +++ b/yii2-adapter/legacy/elements/exporters/Expanded.php @@ -7,95 +7,14 @@ namespace craft\elements\exporters; -use craft\base\ElementExporter; -use craft\base\ElementInterface; -use craft\elements\db\ElementQuery; -use craft\helpers\Component; -use craft\helpers\DateTimeHelper; -use craft\helpers\Db; -use CraftCms\Cms\Element\Queries\Contracts\ElementQueryInterface; -use CraftCms\Cms\Field\Contracts\EagerLoadingFieldInterface; -use CraftCms\Cms\Field\Fields; -use function CraftCms\Cms\t; - -/** - * Expanded represents an "Expanded" element exporter. - * - * @author Pixel & Tonic, Inc. - * @since 3.4.0 - */ -class Expanded extends ElementExporter -{ +/** @phpstan-ignore-next-line */ +if (false) { /** - * @inheritdoc + * @deprecated 6.0.0 use {@see \CraftCms\Cms\Element\Exporters\Expanded} instead. */ - public static function displayName(): string + class Expanded extends \CraftCms\Cms\Element\Exporters\Expanded { - return t('Expanded'); - } - - /** - * @inheritdoc - */ - public function export(ElementQueryInterface $query): mixed - { - // Eager-load as much as we can - $eagerLoadableFields = []; - foreach (app(Fields::class)->getAllFields() as $field) { - if ($field instanceof EagerLoadingFieldInterface) { - $eagerLoadableFields[] = [ - 'path' => $field->handle, - 'criteria' => [ - 'status' => null, - ], - ]; - } - } - - $data = []; - - /** @var ElementQuery $query */ - $query->with($eagerLoadableFields); - - foreach (Db::each($query) as $element) { - /** @var ElementInterface $element */ - // Get the basic array representation excluding custom fields - $attributes = array_flip($element->attributes()); - if (($fieldLayout = $element->getFieldLayout()) !== null) { - foreach ($fieldLayout->getCustomFields() as $field) { - unset($attributes[$field->handle]); - } - } - // because of changes in https://github.com/craftcms/cms/commit/e662ee32d7a5c15dfaa911ae462155615ce7a320 - // we need to split attributes to the date ones and all other; - // pass all other to toArray() - // and then return DateTimeHelper::toIso8601($date, false); for all the date ones - $datetimeAttributes = Component::datetimeAttributes($element); - $otherAttributes = array_diff(array_keys($attributes), $datetimeAttributes); - - $elementArr = $element->toArray($otherAttributes); - - foreach ($datetimeAttributes as $attribute) { - $date = $element->$attribute; - if ($date) { - $elementArr[$attribute] = DateTimeHelper::toIso8601($date); - } else { - $elementArr[$attribute] = $element->$attribute; - } - } - - // sort the $elementArr so the keys are in the same order as the values in the $attributes table - uksort($elementArr, fn($a, $b) => $attributes[$a] <=> $attributes[$b]); - - if ($fieldLayout !== null) { - foreach ($fieldLayout->getCustomFields() as $field) { - $value = $element->getFieldValue($field->handle); - $elementArr[$field->handle] = $field->serializeValue($value, $element); - } - } - $data[] = $elementArr; - } - - return $data; } } + +class_alias(\CraftCms\Cms\Element\Exporters\Expanded::class, Expanded::class); diff --git a/yii2-adapter/legacy/elements/exporters/Raw.php b/yii2-adapter/legacy/elements/exporters/Raw.php index 87913985e12..6b7247e5897 100644 --- a/yii2-adapter/legacy/elements/exporters/Raw.php +++ b/yii2-adapter/legacy/elements/exporters/Raw.php @@ -7,31 +7,14 @@ namespace craft\elements\exporters; -use craft\base\ElementExporter; -use CraftCms\Cms\Element\Queries\Contracts\ElementQueryInterface; -use function CraftCms\Cms\t; - -/** - * Raw represents a "Raw data" element exporter. - * - * @author Pixel & Tonic, Inc. - * @since 3.4.0 - */ -class Raw extends ElementExporter -{ +/** @phpstan-ignore-next-line */ +if (false) { /** - * @inheritdoc + * @deprecated 6.0.0 use {@see \CraftCms\Cms\Element\Exporters\Raw} instead. */ - public static function displayName(): string + class Raw extends \CraftCms\Cms\Element\Exporters\Raw { - return t('Raw data (fastest)'); - } - - /** - * @inheritdoc - */ - public function export(ElementQueryInterface $query): mixed - { - return $query->asArray()->all(); } } + +class_alias(\CraftCms\Cms\Element\Exporters\Raw::class, Raw::class); diff --git a/yii2-adapter/legacy/events/ElementActionEvent.php b/yii2-adapter/legacy/events/ElementActionEvent.php index 847d587e4b0..56df64e58af 100644 --- a/yii2-adapter/legacy/events/ElementActionEvent.php +++ b/yii2-adapter/legacy/events/ElementActionEvent.php @@ -7,7 +7,7 @@ namespace craft\events; -use craft\base\ElementActionInterface; +use CraftCms\Cms\Element\Contracts\ElementActionInterface; use CraftCms\Cms\Element\Queries\Contracts\ElementQueryInterface; /** diff --git a/yii2-adapter/legacy/helpers/Component.php b/yii2-adapter/legacy/helpers/Component.php index cd8fd1941e2..83ee777407b 100644 --- a/yii2-adapter/legacy/helpers/Component.php +++ b/yii2-adapter/legacy/helpers/Component.php @@ -14,9 +14,6 @@ use CraftCms\Cms\Component\Exceptions\MissingComponentException; use CraftCms\Cms\Cp\Icons; use DateTime; -use ReflectionClass; -use ReflectionNamedType; -use ReflectionProperty; use RuntimeException; use yii\base\InvalidConfigException; @@ -101,19 +98,8 @@ public static function iconSvg(?string $icon, string $label): string * @param Model|ElementInterface $model * @return array */ - public static function datetimeAttributes(Model|ElementInterface $model): array + public static function datetimeAttributes(object $model): array { - $datetimeAttributes = []; - foreach ((new ReflectionClass($model))->getProperties(ReflectionProperty::IS_PUBLIC) as $property) { - if (!$property->isStatic()) { - $type = $property->getType(); - if ($type instanceof ReflectionNamedType && $type->getName() === DateTime::class) { - $datetimeAttributes[] = $property->getName(); - } - } - } - - // Include datetimeAttributes() for now - return array_unique(array_merge($datetimeAttributes, $model->datetimeAttributes())); + return array_unique(array_merge(parent::datetimeAttributes($model), $model->datetimeAttributes())); } } diff --git a/yii2-adapter/legacy/helpers/ElementHelper.php b/yii2-adapter/legacy/helpers/ElementHelper.php index 87df7a57331..d7524b537e4 100644 --- a/yii2-adapter/legacy/helpers/ElementHelper.php +++ b/yii2-adapter/legacy/helpers/ElementHelper.php @@ -7,8 +7,8 @@ namespace craft\helpers; -use craft\base\ElementActionInterface; use craft\base\ElementInterface; +use CraftCms\Cms\Element\Contracts\ElementActionInterface; use CraftCms\Cms\Element\Drafts; use CraftCms\Cms\Element\ElementAttributeRenderer; use CraftCms\Cms\Element\ElementHelper as LaravelElementHelper; diff --git a/yii2-adapter/legacy/services/Elements.php b/yii2-adapter/legacy/services/Elements.php index c1cf69acc63..5720e4f7b67 100644 --- a/yii2-adapter/legacy/services/Elements.php +++ b/yii2-adapter/legacy/services/Elements.php @@ -1,6 +1,8 @@ getElements()`]]. * * @phpstan-import-type EagerLoadingMap from ElementInterface + * * @author Pixel & Tonic, Inc. + * * @since 3.0.0 */ class Elements extends Component @@ -125,6 +137,7 @@ class Elements extends Component /** * @event EagerLoadElementsEvent The event that is triggered before elements are eager-loaded. + * * @since 3.5.0 */ public const EVENT_BEFORE_EAGER_LOAD_ELEMENTS = 'beforeEagerLoadElements'; @@ -164,12 +177,14 @@ class Elements extends Component /** * @event ElementEvent The event that is triggered before an element is restored. + * * @since 3.1.0 */ public const EVENT_BEFORE_RESTORE_ELEMENT = 'beforeRestoreElement'; /** * @event ElementEvent The event that is triggered after an element is restored. + * * @since 3.1.0 */ public const EVENT_AFTER_RESTORE_ELEMENT = 'afterRestoreElement'; @@ -223,7 +238,7 @@ class Elements extends Component * * Event handlers must set `$event->handled` to `true` for their change to take effect. * - * @see setElementUri() + * @see SetElementUri() * @since 4.6.0 */ public const EVENT_SET_ELEMENT_URI = 'setElementUri'; @@ -302,18 +317,21 @@ class Elements extends Component /** * @event ElementEvent The event that is triggered before canonical element changes are merged into a derivative. + * * @since 3.7.0 */ public const EVENT_BEFORE_MERGE_CANONICAL_CHANGES = 'beforeMergeCanonical'; /** * @event ElementEvent The event that is triggered after canonical element changes are merged into a derivative. + * * @since 3.7.0 */ public const EVENT_AFTER_MERGE_CANONICAL_CHANGES = 'afterMergeCanonical'; /** * @event InvalidateElementCachesEvent The event that is triggered when element caches are invalidated. + * * @since 4.2.0 */ public const EVENT_INVALIDATE_CACHES = 'invalidateCaches'; @@ -513,6 +531,7 @@ class Elements extends Component /** * @event ElementEvent The event that is triggered before deleting an element for a single site. + * * @see deleteElementForSite() * @see deleteElementsForSite() * @since 4.4.0 @@ -521,6 +540,7 @@ class Elements extends Component /** * @event ElementEvent The event that is triggered after deleting an element for a single site. + * * @see deleteElementForSite() * @see deleteElementsForSite() * @since 4.4.0 @@ -531,10 +551,13 @@ class Elements extends Component * Creates an element with a given config. * * @template T of ElementInterface - * @param class-string|array $config The element’s class name, or its config, with a `type` value + * + * @param class-string|array $config The element’s class name, or its config, with a `type` value * * @phpstan-param class-string|array{type:class-string} $config + * * @return T The element + * * @deprecated 6.0.0 use {@see \CraftCms\Cms\Element\Elements::createElement()} instead. */ public function createElement(mixed $config): ElementInterface @@ -545,10 +568,11 @@ public function createElement(mixed $config): ElementInterface /** * Creates an element query for a given element type. * - * @param class-string $elementType The element class - * + * @param class-string $elementType The element class * @return ElementQueryInterface The element query + * * @throws InvalidArgumentException if $elementType is not a valid element + * * @since 3.5.0 * @deprecated 6.0.0 use {@see \CraftCms\Cms\Element\Elements::createElementQuery()} instead. */ @@ -559,6 +583,7 @@ public function createElementQuery(string $elementType): ElementQueryInterface|E /** * @var string the DB connection name that should be used to store element bulk op records. + * * @since 5.3.0 */ public string $bulkOpDb = 'db2'; @@ -569,11 +594,10 @@ public function createElementQuery(string $elementType): ElementQueryInterface|E /** * Returns whether we are currently collecting element cache invalidation info. * - * @return bool * @see startCollectingCacheInfo() * @see stopCollectingCacheInfo() * @since 4.3.0 - * @deprecated 6.0.0 use {@see \CraftCms\Cms\Element\ElementCaches::isCollectingCacheInfo()} instead. + * @deprecated 6.0.0 use {@see ElementCaches::isCollectingCacheInfo()} instead. */ public function getIsCollectingCacheInfo(): bool { @@ -583,7 +607,6 @@ public function getIsCollectingCacheInfo(): bool /** * Returns whether we are currently collecting element cache invalidation tags. * - * @return bool * @since 3.5.0 * @deprecated in 4.3.0. [[getIsCollectingCacheInfo()]] should be used instead. */ @@ -596,7 +619,7 @@ public function getIsCollectingCacheTags(): bool * Starts collecting element cache invalidation info. * * @since 4.3.0 - * @deprecated 6.0.0 use {@see \CraftCms\Cms\Element\ElementCaches::startCollectingCacheInfo()} instead. + * @deprecated 6.0.0 use {@see ElementCaches::startCollectingCacheInfo()} instead. */ public function startCollectingCacheInfo(): void { @@ -617,10 +640,10 @@ public function startCollectingCacheTags(): void /** * Adds element cache invalidation tags to the current collection. * - * @param string[] $tags + * @param string[] $tags * * @since 3.5.0 - * @deprecated 6.0.0 use {@see \CraftCms\Cms\Element\ElementCaches::collectCacheTags()} instead. + * @deprecated 6.0.0 use {@see ElementCaches::collectCacheTags()} instead. */ public function collectCacheTags(array $tags): void { @@ -632,10 +655,9 @@ public function collectCacheTags(array $tags): void * * The value will only be used if it is less than the currently stored expiration date. * - * @param DateTime $expiryDate * * @since 4.3.0 - * @deprecated 6.0.0 use {@see \CraftCms\Cms\Element\ElementCaches::setCacheExpiryDate()} instead. + * @deprecated 6.0.0 use {@see ElementCaches::setCacheExpiryDate()} instead. */ public function setCacheExpiryDate(DateTime $expiryDate): void { @@ -645,10 +667,9 @@ public function setCacheExpiryDate(DateTime $expiryDate): void /** ** Stores cache invalidation info for a given element. * - * @param ElementInterface $element * * @since 4.5.0 - * @deprecated 6.0.0 use {@see \CraftCms\Cms\Element\ElementCaches::collectCacheInfoForElement()} instead. + * @deprecated 6.0.0 use {@see ElementCaches::collectCacheInfoForElement()} instead. */ public function collectCacheInfoForElement(ElementInterface $element): void { @@ -661,10 +682,10 @@ public function collectCacheInfoForElement(ElementInterface $element): void * * If no cache tags were registered, `[null, null]` will be returned. * - * @return array * @phpstan-return array{TagDependency|null,int|null} + * * @since 4.3.0 - * @deprecated 6.0.0 use {@see \CraftCms\Cms\Element\ElementCaches::stopCollectingCacheInfo()} instead. + * @deprecated 6.0.0 use {@see ElementCaches::stopCollectingCacheInfo()} instead. */ public function stopCollectingCacheInfo(): array { @@ -678,13 +699,13 @@ public function stopCollectingCacheInfo(): array /** * Stops collecting element cache invalidation tags, and returns a cache dependency object. * - * @return TagDependency * @since 3.5.0 * @deprecated in 4.3.0. [[stopCollectingCacheInfo()]] should be used instead. */ public function stopCollectingCacheTags(): TagDependency { [$dep] = $this->stopCollectingCacheInfo(); + return $dep ?? new TagDependency(); } @@ -692,7 +713,7 @@ public function stopCollectingCacheTags(): TagDependency * Invalidates all element caches. * * @since 3.5.0 - * @deprecated 6.0.0 use {@see \CraftCms\Cms\Element\ElementCaches::invalidateAll()} instead. + * @deprecated 6.0.0 use {@see ElementCaches::invalidateAll()} instead. */ public function invalidateAllCaches(): void { @@ -702,10 +723,10 @@ public function invalidateAllCaches(): void /** * Invalidates caches for the given element type. * - * @param class-string $elementType + * @param class-string $elementType * * @since 3.5.0 - * @deprecated 6.0.0 use {@see \CraftCms\Cms\Element\ElementCaches::invalidateForElementType()} instead. + * @deprecated 6.0.0 use {@see ElementCaches::invalidateForElementType()} instead. */ public function invalidateCachesForElementType(string $elementType): void { @@ -715,10 +736,9 @@ public function invalidateCachesForElementType(string $elementType): void /** * Invalidates caches for the given element. * - * @param ElementInterface $element * * @since 3.5.0 - * @deprecated 6.0.0 use {@see \CraftCms\Cms\Element\ElementCaches::invalidateForElement()} instead. + * @deprecated 6.0.0 use {@see ElementCaches::invalidateForElement()} instead. */ public function invalidateCachesForElement(ElementInterface $element): void { @@ -741,13 +761,13 @@ private function elementCaches(): ElementCachesService * The element’s status will not be a factor when using this method. * * @template T of ElementInterface - * @param int $elementId The element’s ID. - * @param class-string|null $elementType The element class. - * @param int|string|int[]|null $siteId The site(s) to fetch the element in. - * Defaults to the current site. - * @param array $criteria * + * @param int $elementId The element’s ID. + * @param class-string|null $elementType The element class. + * @param int|string|int[]|null $siteId The site(s) to fetch the element in. + * Defaults to the current site. * @return T|null The matching element, or `null`. + * * @deprecated 6.0.0 use {@see \CraftCms\Cms\Element\Elements::getElementById()} instead. */ public function getElementById( @@ -767,20 +787,20 @@ public function getElementById( * The element’s status will not be a factor when using this method. * * @template T of ElementInterface - * @param string $uid The element’s UID. - * @param class-string|null $elementType The element class. - * @param int|string|int[]|null $siteId The site(s) to fetch the element in. - * Defaults to the current site. - * @param array $criteria * + * @param string $uid The element’s UID. + * @param class-string|null $elementType The element class. + * @param int|string|int[]|null $siteId The site(s) to fetch the element in. + * Defaults to the current site. * @return T|null The matching element, or `null`. + * * @since 3.5.13 * @deprecated 6.0.0 use {@see \CraftCms\Cms\Element\Elements::getElementByUid()} instead. */ public function getElementByUid( string $uid, ?string $elementType = null, - array|int|string $siteId = null, + array|int|string|null $siteId = null, array $criteria = [], ): ?ElementInterface { return ElementsFacade::getElementByUId($uid, $elementType, $siteId, $criteria); @@ -789,12 +809,12 @@ public function getElementByUid( /** * Returns an element by its URI. * - * @param string $uri The element’s URI. - * @param int|null $siteId The site to look for the URI in, and to return the element in. - * Defaults to the current site. - * @param bool $enabledOnly Whether to only look for an enabled element. Defaults to `false`. - * + * @param string $uri The element’s URI. + * @param int|null $siteId The site to look for the URI in, and to return the element in. + * Defaults to the current site. + * @param bool $enabledOnly Whether to only look for an enabled element. Defaults to `false`. * @return ElementInterface|null The matching element, or `null`. + * * @deprecated 6.0.0 use {@see \CraftCms\Cms\Element\Elements::getElementByUri()} instead. */ public function getElementByUri(string $uri, ?int $siteId = null, bool $enabledOnly = false): ?ElementInterface @@ -805,9 +825,9 @@ public function getElementByUri(string $uri, ?int $siteId = null, bool $enabledO /** * Returns the class of an element with a given ID. * - * @param int $elementId The element’s ID - * + * @param int $elementId The element’s ID * @return class-string|null The element’s class, or null if it could not be found + * * @deprecated 6.0.0 use {@see \CraftCms\Cms\Element\ElementTypes::getElementTypeById()} instead. */ public function getElementTypeById(int $elementId): ?string @@ -818,9 +838,9 @@ public function getElementTypeById(int $elementId): ?string /** * Returns the class of an element with a given UID. * - * @param string $uid The element’s UID - * + * @param string $uid The element’s UID * @return string|null The element’s class, or null if it could not be found + * * @since 3.5.13 * @deprecated 6.0.0 use {@see \CraftCms\Cms\Element\ElementTypes::getElementTypeByUid()} instead. */ @@ -832,9 +852,9 @@ public function getElementTypeByUid(string $uid): ?string /** * Returns the classes of elements with the given IDs. * - * @param int[] $elementIds The elements’ IDs - * + * @param int[] $elementIds The elements’ IDs * @return string[] + * * @deprecated 6.0.0 use {@see \CraftCms\Cms\Element\ElementTypes::getElementTypesByIds()} instead. */ public function getElementTypesByIds(array $elementIds): array @@ -845,10 +865,10 @@ public function getElementTypesByIds(array $elementIds): array /** * Returns an element’s URI for a given site. * - * @param int $elementId The element’s ID. - * @param int $siteId The site to search for the element’s URI in. - * + * @param int $elementId The element’s ID. + * @param int $siteId The site to search for the element’s URI in. * @return string|null The element’s URI or `null` if the element doesn’t exist. + * * @deprecated 6.0.0 use {@see \CraftCms\Cms\Element\Elements::getElementUriForSite()} instead. */ public function getElementUriForSite(int $elementId, int $siteId): ?string @@ -859,10 +879,10 @@ public function getElementUriForSite(int $elementId, int $siteId): ?string /** * Returns the site IDs that a given element is enabled in. * - * @param int $elementId The element’s ID. - * + * @param int $elementId The element’s ID. * @return int[] The site IDs that the element is enabled in. If the element could not be found, an empty array - * will be returned. + * will be returned. + * * @deprecated 6.0.0 use {@see \CraftCms\Cms\Element\Elements::getEnabledSiteIdsForElement()} instead. */ public function getEnabledSiteIdsForElement(int $elementId): array @@ -877,6 +897,7 @@ public function getEnabledSiteIdsForElement(int $elementId): array * Returns the active bulk op keys. * * @return string[] + * * @since 5.7.0 * @deprecated 6.0.0 use {@see \CraftCms\Cms\Element\BulkOp\BulkOps::activeKeys()} instead. */ @@ -889,6 +910,7 @@ public function getBulkOpKeys(): array * Begins tracking element saves and deletes as part of a bulk operation, identified by a unique key. * * @return string The bulk operation key + * * @since 5.0.0 * @deprecated 6.0.0 use {@see \CraftCms\Cms\Element\BulkOp\BulkOps::start()} instead. */ @@ -900,7 +922,7 @@ public function beginBulkOp(): string /** * Resumes tracking element saves and deletes as part of a bulk operation. * - * @param string $key The bulk operation key returned by [[beginBulkOp()]]. + * @param string $key The bulk operation key returned by [[beginBulkOp()]]. * * @since 5.0.0 * @deprecated 6.0.0 use {@see \CraftCms\Cms\Element\BulkOp\BulkOps::resume()} instead. @@ -913,7 +935,7 @@ public function resumeBulkOp(string $key): void /** * Finishes tracking element saves and deletes as part of a bulk operation. * - * @param string $key The bulk operation key returned by [[beginBulkOp()]]. + * @param string $key The bulk operation key returned by [[beginBulkOp()]]. * * @since 5.0.0 * @deprecated 6.0.0 use {@see \CraftCms\Cms\Element\BulkOp\BulkOps::end()} instead. @@ -926,7 +948,6 @@ public function endBulkOp(string $key): void /** * Tracks an element as being affected by any active bulk operations. * - * @param ElementInterface $element * * @since 5.0.0 * @deprecated 6.0.0 use {@see \CraftCms\Cms\Element\BulkOp\BulkOps::trackElement()} instead. @@ -940,9 +961,7 @@ public function trackElementInBulkOps(ElementInterface $element): void * Ensures that we’re tracking element saves and deletes as part of a bulk operation, then executes the given * callback function. * - * @param callable $callback * - * @return mixed * @since 5.3.0 * @deprecated 6.0.0 use {@see \CraftCms\Cms\Element\BulkOp\BulkOps::ensure()} instead. */ @@ -993,21 +1012,21 @@ public function ensureBulkOp(callable $callback): mixed * } * ``` * - * @param ElementInterface $element The element that is being saved - * @param bool $runValidation Whether the element should be validated - * @param bool $propagate Whether the element should be saved across all of its supported sites - * (this can only be disabled when updating an existing element) - * @param bool|null $updateSearchIndex Whether to update the element search index for the element - * (this will happen via a background job if this is a web request) - * @param bool $forceTouch Whether to force the `dateUpdated` timestamp to be updated for the element, - * regardless of whether it’s being resaved - * @param bool|null $crossSiteValidate Whether the element should be validated across all supported sites - * @param bool $saveContent Whether all the element’s content should be saved. When false (default) only dirty fields will be saved. - * - * @return bool + * @param ElementInterface $element The element that is being saved + * @param bool $runValidation Whether the element should be validated + * @param bool $propagate Whether the element should be saved across all of its supported sites + * (this can only be disabled when updating an existing element) + * @param bool|null $updateSearchIndex Whether to update the element search index for the element + * (this will happen via a background job if this is a web request) + * @param bool $forceTouch Whether to force the `dateUpdated` timestamp to be updated for the element, + * regardless of whether it’s being resaved + * @param bool|null $crossSiteValidate Whether the element should be validated across all supported sites + * @param bool $saveContent Whether all the element’s content should be saved. When false (default) only dirty fields will be saved. + * * @throws ElementNotFoundException if $element has an invalid $id * @throws Exception if the $element doesn’t have any supported sites * @throws Throwable if reasons + * * @deprecated 6.0.0 use {@see \CraftCms\Cms\Element\Elements::saveElement()} instead. */ public function saveElement( @@ -1025,9 +1044,9 @@ public function saveElement( /** * Sets the URI on an element. * - * @param ElementInterface $element * * @throws OperationAbortedException if a unique URI could not be found + * * @since 4.6.0 * @deprecated 6.0.0 use {@see \CraftCms\Cms\Element\Elements::setElementUri()} instead. */ @@ -1039,7 +1058,7 @@ public function setElementUri(ElementInterface $element): void /** * Merges recent canonical element changes into a given derivative, such as a draft. * - * @param ElementInterface $element The derivative element + * @param ElementInterface $element The derivative element * * @since 3.7.0 * @deprecated 6.0.0 use {@see \CraftCms\Cms\Element\Elements::mergeCanonicalChanges()} instead. @@ -1053,11 +1072,13 @@ public function mergeCanonicalChanges(ElementInterface $element): void * Updates the canonical element from a given derivative, such as a draft or revision. * * @template T of ElementInterface - * @param T $element The derivative element - * @param array $newAttributes Any attributes to apply to the canonical element * + * @param T $element The derivative element + * @param array $newAttributes Any attributes to apply to the canonical element * @return T The updated canonical element + * * @throws InvalidArgumentException if the element is already a canonical element + * * @since 3.7.0 * @deprecated 6.0.0 use {@see \CraftCms\Cms\Element\Elements::updateCanonicalElement()} instead. */ @@ -1069,14 +1090,15 @@ public function updateCanonicalElement(ElementInterface $element, array $newAttr /** * Resaves all elements that match a given element query. * - * @param ElementQueryInterface|\CraftCms\Cms\Element\Queries\ElementQuery $query The element query to fetch elements with - * @param bool $continueOnError Whether to continue going if an error occurs - * @param bool $skipRevisions Whether elements that are (or belong to) a revision should be skipped - * @param bool|null $updateSearchIndex Whether to update the element search index for the element - * (this will happen via a background job if this is a web request) - * @param bool $touch Whether to update the `dateUpdated` timestamps for the elements + * @param ElementQueryInterface|ElementQuery $query The element query to fetch elements with + * @param bool $continueOnError Whether to continue going if an error occurs + * @param bool $skipRevisions Whether elements that are (or belong to) a revision should be skipped + * @param bool|null $updateSearchIndex Whether to update the element search index for the element + * (this will happen via a background job if this is a web request) + * @param bool $touch Whether to update the `dateUpdated` timestamps for the elements * * @throws Throwable if reasons + * * @since 3.2.0 * @deprecated 6.0.0 use {@see \CraftCms\Cms\Element\Elements::resaveElements()} instead. */ @@ -1093,12 +1115,13 @@ public function resaveElements( /** * Propagates all elements that match a given element query to another site(s). * - * @param ElementQueryInterface $query The element query to fetch elements with - * @param int|int[]|null $siteIds The site ID(s) that the elements should be propagated to. If null, elements will be - * @param bool $continueOnError Whether to continue going if an error occurs + * @param ElementQueryInterface $query The element query to fetch elements with + * @param int|int[]|null $siteIds The site ID(s) that the elements should be propagated to. If null, elements will be + * @param bool $continueOnError Whether to continue going if an error occurs * * @throws Throwable if reasons - * propagated to all supported sites, except the one they were queried in. + * propagated to all supported sites, except the one they were queried in. + * * @since 3.2.0 * @deprecated 6.0.0 use {@see \CraftCms\Cms\Element\Elements::propagateElements()} instead. */ @@ -1114,21 +1137,23 @@ public function propagateElements( * Duplicates an element. * * @template T of ElementInterface - * @param T $element the element to duplicate - * @param array $newAttributes any attributes to apply to the duplicate. This can contain a `siteAttributes` key, - * set to an array of site-specific attribute array, indexed by site IDs. - * @param bool $placeInStructure whether to position the cloned element after the original one in its structure. - * (This will only happen if the duplicated element is canonical.) - * @param bool $asUnpublishedDraft whether the duplicate should be created as unpublished draft - * @param bool $checkAuthorization whether to ensure the current user is authorized to save the new element, - * once its new attributes have been applied to it - * @param bool $copyModifiedFields whether to copy modified attribute/field data over to the duplicated element * + * @param T $element the element to duplicate + * @param array $newAttributes any attributes to apply to the duplicate. This can contain a `siteAttributes` key, + * set to an array of site-specific attribute array, indexed by site IDs. + * @param bool $placeInStructure whether to position the cloned element after the original one in its structure. + * (This will only happen if the duplicated element is canonical.) + * @param bool $asUnpublishedDraft whether the duplicate should be created as unpublished draft + * @param bool $checkAuthorization whether to ensure the current user is authorized to save the new element, + * once its new attributes have been applied to it + * @param bool $copyModifiedFields whether to copy modified attribute/field data over to the duplicated element * @return T the duplicated element + * * @throws UnsupportedSiteException if the element is being duplicated into a site it doesn’t support * @throws InvalidElementException if saveElement() returns false for any of the sites * @throws ForbiddenHttpException if the user isn't authorized to save the duplicated element * @throws Throwable if reasons + * * @deprecated 6.0.0 use {@see \CraftCms\Cms\Element\Elements::duplicateElement()} instead. */ public function duplicateElement( @@ -1145,12 +1170,13 @@ public function duplicateElement( /** * Updates an element’s slug and URI, along with any descendants. * - * @param ElementInterface $element The element to update. - * @param bool $updateOtherSites Whether the element’s other sites should also be updated. - * @param bool $updateDescendants Whether the element’s descendants should also be updated. - * @param bool $queue Whether the element’s slug and URI should be updated via a job in the queue. + * @param ElementInterface $element The element to update. + * @param bool $updateOtherSites Whether the element’s other sites should also be updated. + * @param bool $updateDescendants Whether the element’s descendants should also be updated. + * @param bool $queue Whether the element’s slug and URI should be updated via a job in the queue. * * @throws OperationAbortedException if a unique URI can’t be generated based on the element’s URI format + * * @deprecated 6.0.0 use {@see \CraftCms\Cms\Element\Elements::updateElementSlugAndUri()} instead. */ public function updateElementSlugAndUri( @@ -1165,7 +1191,8 @@ public function updateElementSlugAndUri( /** * Updates an element’s slug and URI, for any sites besides the given one. * - * @param ElementInterface $element The element to update. + * @param ElementInterface $element The element to update. + * * @deprecated 6.0.0 use {@see \CraftCms\Cms\Element\Elements::updateElementSlugAndUriInOtherSites()} instead. */ public function updateElementSlugAndUriInOtherSites(ElementInterface $element): void @@ -1176,9 +1203,10 @@ public function updateElementSlugAndUriInOtherSites(ElementInterface $element): /** * Updates an element’s descendants’ slugs and URIs. * - * @param ElementInterface $element The element whose descendants should be updated. - * @param bool $updateOtherSites Whether the element’s other sites should also be updated. - * @param bool $queue Whether the descendants’ slugs and URIs should be updated via a job in the queue. + * @param ElementInterface $element The element whose descendants should be updated. + * @param bool $updateOtherSites Whether the element’s other sites should also be updated. + * @param bool $queue Whether the descendants’ slugs and URIs should be updated via a job in the queue. + * * @deprecated 6.0.0 use {@see \CraftCms\Cms\Element\Elements::updateDescendantSlugsAndUris()} instead. */ public function updateDescendantSlugsAndUris( @@ -1197,12 +1225,13 @@ public function updateDescendantSlugsAndUris( * - Any structures that contain the merged element * - Any reference tags in textual custom fields referencing the merged element * - * @param int $mergedElementId The ID of the element that is going away. - * @param int $prevailingElementId The ID of the element that is sticking around. - * + * @param int $mergedElementId The ID of the element that is going away. + * @param int $prevailingElementId The ID of the element that is sticking around. * @return bool Whether the elements were merged successfully. + * * @throws ElementNotFoundException if one of the element IDs don’t exist. * @throws Throwable if reasons + * * @deprecated 6.0.0 use {@see \CraftCms\Cms\Element\Elements::mergeElementsByIds()} instead. */ public function mergeElementsByIds(int $mergedElementId, int $prevailingElementId): bool @@ -1218,11 +1247,12 @@ public function mergeElementsByIds(int $mergedElementId, int $prevailingElementI * - Any structures that contain the merged element * - Any reference tags in textual custom fields referencing the merged element * - * @param ElementInterface $mergedElement The element that is going away. - * @param ElementInterface $prevailingElement The element that is sticking around. - * + * @param ElementInterface $mergedElement The element that is going away. + * @param ElementInterface $prevailingElement The element that is sticking around. * @return bool Whether the elements were merged successfully. + * * @throws Throwable if reasons + * * @since 3.1.31 * @deprecated 6.0.0 use {@see \CraftCms\Cms\Element\Elements::mergeElements()} instead. */ @@ -1234,14 +1264,15 @@ public function mergeElements(ElementInterface $mergedElement, ElementInterface /** * Deletes an element by its ID. * - * @param int $elementId The element’s ID - * @param class-string|null $elementType The element class. - * @param int|null $siteId The site to fetch the element in. - * Defaults to the current site. - * @param bool $hardDelete Whether the element should be hard-deleted immediately, instead of soft-deleted - * + * @param int $elementId The element’s ID + * @param class-string|null $elementType The element class. + * @param int|null $siteId The site to fetch the element in. + * Defaults to the current site. + * @param bool $hardDelete Whether the element should be hard-deleted immediately, instead of soft-deleted * @return bool Whether the element was deleted successfully + * * @throws Throwable + * * @deprecated 6.0.0 use {@see \CraftCms\Cms\Element\Elements::deleteElementById()} instead. */ public function deleteElementById( @@ -1256,11 +1287,12 @@ public function deleteElementById( /** * Deletes an element. * - * @param ElementInterface $element The element to be deleted - * @param bool $hardDelete Whether the element should be hard-deleted immediately, instead of soft-deleted - * + * @param ElementInterface $element The element to be deleted + * @param bool $hardDelete Whether the element should be hard-deleted immediately, instead of soft-deleted * @return bool Whether the element was deleted successfully + * * @throws Throwable + * * @deprecated 6.0.0 use {@see \CraftCms\Cms\Element\Elements::deleteElement()} instead. */ public function deleteElement(ElementInterface $element, bool $hardDelete = false): bool @@ -1271,7 +1303,6 @@ public function deleteElement(ElementInterface $element, bool $hardDelete = fals /** * Deletes an element in the site it’s loaded in. * - * @param ElementInterface $element * * @since 4.4.0 * @deprecated 6.0.0 use {@see \CraftCms\Cms\Element\Elements::deleteElementForSite()} instead. @@ -1284,9 +1315,10 @@ public function deleteElementForSite(ElementInterface $element): void /** * Deletes elements in the site they are currently loaded in. * - * @param ElementInterface[] $elements + * @param ElementInterface[] $elements * * @throws InvalidArgumentException if all elements don’t have the same type and site ID. + * * @since 4.4.0 * @deprecated 6.0.0 use {@see \CraftCms\Cms\Element\Elements::deleteElementsForSite()} instead. */ @@ -1298,11 +1330,12 @@ public function deleteElementsForSite(array $elements): void /** * Restores an element. * - * @param ElementInterface $element * * @return bool Whether the element was restored successfully + * * @throws Exception if the $element doesn’t have any supported sites * @throws Throwable if reasons + * * @since 3.1.0 * @deprecated 6.0.0 use {@see \CraftCms\Cms\Element\Elements::restoreElement()} instead. */ @@ -1314,11 +1347,12 @@ public function restoreElement(ElementInterface $element): bool /** * Restores multiple elements. * - * @param ElementInterface[] $elements - * + * @param ElementInterface[] $elements * @return bool Whether at least one element was restored successfully + * * @throws UnsupportedSiteException if an element is being restored for a site it doesn’t support * @throws Throwable if reasons + * * @deprecated 6.0.0 use {@see \CraftCms\Cms\Element\Elements::restoreElements()} instead. */ public function restoreElements(array $elements): bool @@ -1329,12 +1363,11 @@ public function restoreElements(array $elements): bool /** * Returns the recent activity for an element. * - * @param ElementInterface $element - * @param int|null $excludeUserId * * @return ElementActivity[] + * * @since 4.5.0 - * @deprecated 6.0.0 use {@see \CraftCms\Cms\Element\ElementActivity::getRecentActivity()} instead. + * @deprecated 6.0.0 use {@see ElementActivityService::getRecentActivity()} instead. */ public function getRecentActivity(ElementInterface $element, ?int $excludeUserId = null): array { @@ -1346,12 +1379,10 @@ public function getRecentActivity(ElementInterface $element, ?int $excludeUserId /** * Tracks new activity for an element. * - * @param ElementInterface $element - * @param 'view'|'edit'|'save' $type $type - * @param User|null $user + * @param 'view'|'edit'|'save' $type $type * * @since 4.5.0 - * @deprecated 6.0.0 use {@see \CraftCms\Cms\Element\ElementActivity::trackActivity()} instead. + * @deprecated 6.0.0 use {@see ElementActivityService::trackActivity()} instead. */ public function trackActivity(ElementInterface $element, string $type, ?User $user = null): void { @@ -1377,7 +1408,9 @@ private static function activityToLegacyActivity(ElementActivityData $activity): * Returns all available element classes. * * @return string[] The available element classes. + * * @phpstan-return class-string[] + * * @deprecated 6.0.0 use {@see \CraftCms\Cms\Element\ElementTypes::getAllElementTypes()} instead. */ public function getAllElementTypes(): array @@ -1392,30 +1425,43 @@ public function getAllElementTypes(): array * Creates an element action with a given config. * * @template T of ElementActionInterface - * @param class-string|array $config The element action’s class name, or its config, with a `type` value and optionally a `settings` value + * + * @param class-string|array $config The element action’s class name, or its config, with a `type` value and optionally a `settings` value * * @phpstan-param class-string|array{type:class-string} $config + * * @return T The element action - * @deprecated 6.0.0 use {@see \CraftCms\Cms\Element\Elements::createAction()} instead. + * + * @deprecated 6.0.0 use {@see ElementActions::createAction()} instead. */ public function createAction(mixed $config): ElementActionInterface { - return ElementsFacade::createAction($config); + return ComponentHelper::createComponent($config, ElementActionInterface::class); } /** * Creates an element exporter with a given config. * * @template T of ElementExporterInterface - * @param class-string|array $config The element exporter’s class name, or its config, with a `type` value and optionally a `settings` value + * + * @param class-string|array $config The element exporter’s class name, or its config, with a `type` value and optionally a `settings` value * * @phpstan-param class-string|array{type:class-string} $config + * * @return T The element exporter - * @deprecated 6.0.0 use {@see \CraftCms\Cms\Element\Elements::createExporter()} instead. + * + * @deprecated 6.0.0 use {@see \CraftCms\Cms\Element\ElementExporters::createExporter()} instead. */ public function createExporter(mixed $config): ElementExporterInterface { - return ElementsFacade::createExporter($config); + if (is_string($config)) { + $config = ['type' => $config]; + } + + /** @var T $exporter */ + $exporter = ElementExporters::createExporter($config, $config['elementType'] ?? Element::class); + + return $exporter; } // Misc @@ -1424,9 +1470,9 @@ public function createExporter(mixed $config): ElementExporterInterface /** * Returns an element class by its handle. * - * @param string $refHandle The element class handle - * + * @param string $refHandle The element class handle * @return string|null The element class, or null if it could not be found + * * @deprecated 6.0.0 use {@see \CraftCms\Cms\Element\ElementTypes::getElementTypeByRefHandle()} instead. */ public function getElementTypeByRefHandle(string $refHandle): ?string @@ -1437,10 +1483,10 @@ public function getElementTypeByRefHandle(string $refHandle): ?string /** * Parses a string for element [reference tags](https://craftcms.com/docs/5.x/system/reference-tags.html). * - * @param string $str The string to parse - * @param int|null $defaultSiteId The default site ID to query the elements in - * + * @param string $str The string to parse + * @param int|null $defaultSiteId The default site ID to query the elements in * @return string The parsed string + * * @deprecated 6.0.0 use {@see \CraftCms\Cms\Element\Elements::parseRefs()} instead. */ public function parseRefs(string $str, ?int $defaultSiteId = null): string @@ -1454,9 +1500,10 @@ public function parseRefs(string $str, ?int $defaultSiteId = null): string * * This is used by Live Preview and Sharing features. * - * @param ElementInterface $element The element currently being edited by Live Preview. + * @param ElementInterface $element The element currently being edited by Live Preview. * * @throws InvalidArgumentException if the element is missing an ID + * * @see getPlaceholderElement() * @deprecated 6.0.0 use {@see \CraftCms\Cms\Element\Elements::setPlaceholderElement()} instead. */ @@ -1469,6 +1516,7 @@ public function setPlaceholderElement(ElementInterface $element): void * Returns all placeholder elements. * * @return ElementInterface[] + * * @since 3.2.5 * @deprecated 6.0.0 use {@see \CraftCms\Cms\Element\Elements::getPlaceholderElements()} instead. */ @@ -1480,10 +1528,10 @@ public function getPlaceholderElements(): array /** * Returns a placeholder element by its ID and site ID. * - * @param int $sourceId The element’s ID - * @param int $siteId The element’s site ID - * + * @param int $sourceId The element’s ID + * @param int $siteId The element’s site ID * @return ElementInterface|null The placeholder element if one exists, or null. + * * @see setPlaceholderElement() * @deprecated 6.0.0 use {@see \CraftCms\Cms\Element\Elements::getPlaceholderElement()} instead. */ @@ -1495,10 +1543,11 @@ public function getPlaceholderElement(int $sourceId, int $siteId): ?ElementInter /** * Normalizes a `with` element query param into an array of eager-loading plans. * - * @param string|array $with * * @phpstan-param string|array $with + * * @return EagerLoadPlan[] + * * @since 3.5.0 * @deprecated 6.0.0 use {@see \CraftCms\Cms\Element\Elements::createEagerLoadingPlans()} instead. */ @@ -1510,9 +1559,9 @@ public function createEagerLoadingPlans(string|array $with): array /** * Eager-loads additional elements onto a given set of elements. * - * @param class-string $elementType The root element type class - * @param ElementInterface[] $elements The root element models that should be updated with the eager-loaded elements - * @param array|string|EagerLoadPlan[] $with Dot-delimited paths of the elements that should be eager-loaded into the root elements + * @param class-string $elementType The root element type class + * @param ElementInterface[] $elements The root element models that should be updated with the eager-loaded elements + * @param array|string|EagerLoadPlan[] $with Dot-delimited paths of the elements that should be eager-loaded into the root elements * * @deprecated 6.0.0 use {@see \CraftCms\Cms\Element\Elements::eagerLoadElements()} instead. */ @@ -1524,14 +1573,15 @@ public function eagerLoadElements(string $elementType, array|Collection $element /** * Propagates an element to a different site. * - * @param ElementInterface $element The element to propagate - * @param int $siteId The site ID that the element should be propagated to - * @param ElementInterface|false|null $siteElement The element loaded for the propagated site (only pass this if you - * already had a reason to load it). Set to `false` if it is known to not exist yet. - * + * @param ElementInterface $element The element to propagate + * @param int $siteId The site ID that the element should be propagated to + * @param ElementInterface|false|null $siteElement The element loaded for the propagated site (only pass this if you + * already had a reason to load it). Set to `false` if it is known to not exist yet. * @return ElementInterface The element in the target site + * * @throws Exception if the element couldn't be propagated * @throws UnsupportedSiteException if the element doesn’t support `$siteId` + * * @since 3.0.13 * @deprecated 6.0.0 use {@see \CraftCms\Cms\Element\Elements::propagateElement()} instead. */ @@ -1546,10 +1596,7 @@ public function propagateElement( /** * Returns whether a user is authorized to view the given element’s edit page. * - * @param ElementInterface $element - * @param User|null $user * - * @return bool * @since 4.3.0 * @deprecated 6.0.0 use `Gate::forUser($user)->check('view', $element)` instead. */ @@ -1561,10 +1608,7 @@ public function canView(ElementInterface $element, ?User $user = null): bool /** * Returns whether a user is authorized to save the given element in its current form. * - * @param ElementInterface $element - * @param User|null $user * - * @return bool * @since 4.3.0 * @deprecated 6.0.0 use `Gate::forUser($user)->check('save', $element)` instead. */ @@ -1576,10 +1620,7 @@ public function canSave(ElementInterface $element, ?User $user = null): bool /** * Returns whether a user is authorized to save the canonical version of the given element. * - * @param ElementInterface $element - * @param User|null $user * - * @return bool * @since 5.6.0 * @deprecated 6.0.0 use `Gate::forUser($user)->check('saveCanonical', $element)` instead. */ @@ -1600,10 +1641,7 @@ public function canSaveCanonical(ElementInterface $element, ?User $user = null): * * This should always be called in conjunction with [[canView()]] or [[canSave()]]. * - * @param ElementInterface $element - * @param User|null $user * - * @return bool * @since 4.3.0 * @deprecated 6.0.0 use `Gate::forUser($user)->check('duplicate', $element)` instead. */ @@ -1615,10 +1653,7 @@ public function canDuplicate(ElementInterface $element, ?User $user = null): boo /** * Returns whether a user is authorized to duplicate the given element as an unpublished draft. * - * @param ElementInterface $element - * @param User|null $user * - * @return bool * @since 5.0.0 * @deprecated 6.0.0 use `Gate::forUser($user)->check('duplicateAsDraft', $element)` instead. */ @@ -1632,10 +1667,7 @@ public function canDuplicateAsDraft(ElementInterface $element, ?User $user = nul * * This should always be called in conjunction with [[canView()]]. * - * @param ElementInterface $element - * @param User|null $user * - * @return bool * @since 5.7.0 * @deprecated 6.0.0 use `Gate::forUser($user)->check('copy', $element)` instead. */ @@ -1649,10 +1681,7 @@ public function canCopy(ElementInterface $element, ?User $user = null): bool * * This should always be called in conjunction with [[canView()]] or [[canSave()]]. * - * @param ElementInterface $element - * @param User|null $user * - * @return bool * @since 4.3.0 * @deprecated 6.0.0 use `Gate::forUser($user)->check('delete', $element)` instead. */ @@ -1666,10 +1695,7 @@ public function canDelete(ElementInterface $element, ?User $user = null): bool * * This should always be called in conjunction with [[canView()]] or [[canSave()]]. * - * @param ElementInterface $element - * @param User|null $user * - * @return bool * @since 4.3.0 * @deprecated 6.0.0 use `Gate::forUser($user)->check('deleteForSite', $element)` instead. */ @@ -1683,10 +1709,7 @@ public function canDeleteForSite(ElementInterface $element, ?User $user = null): * * This should always be called in conjunction with [[canView()]] or [[canSave()]]. * - * @param ElementInterface $element - * @param User|null $user * - * @return bool * @since 4.3.0 * @deprecated 6.0.0 use `Gate::forUser($user)->check('createDrafts', $element)` instead. */ @@ -1910,6 +1933,33 @@ public static function registerEvents(): void $event->hardDelete = $yiiEvent->hardDelete; }); + Event::listen(function(BeforePerformAction $event) { + if (!Craft::$app->getElements()->hasEventHandlers(self::EVENT_BEFORE_PERFORM_ACTION)) { + return; + } + + Craft::$app->getElements()->trigger(self::EVENT_BEFORE_PERFORM_ACTION, $yiiEvent = new ElementActionEvent([ + 'action' => $event->action, + 'criteria' => $event->query, + 'message' => $event->message, + ])); + + $event->isValid = $yiiEvent->isValid; + $event->message = $yiiEvent->message; + }); + + Event::listen(function(AfterPerformAction $event) { + if (!Craft::$app->getElements()->hasEventHandlers(self::EVENT_AFTER_PERFORM_ACTION)) { + return; + } + + Craft::$app->getElements()->trigger(self::EVENT_AFTER_PERFORM_ACTION, new ElementActionEvent([ + 'action' => $event->action, + 'criteria' => $event->query, + 'message' => $event->message, + ])); + }); + Event::listen(function(RegisterElementTypes $event) { if (!Craft::$app->getElements()->hasEventHandlers(self::EVENT_REGISTER_ELEMENT_TYPES)) { return; diff --git a/yii2-adapter/src/CompatibilityMixins.php b/yii2-adapter/src/CompatibilityMixins.php index 6a47b10814f..5da65883398 100644 --- a/yii2-adapter/src/CompatibilityMixins.php +++ b/yii2-adapter/src/CompatibilityMixins.php @@ -10,6 +10,7 @@ use CraftCms\Cms\Asset\Data\Volume as AssetVolume; use CraftCms\Cms\Asset\Data\VolumeFolder as AssetVolumeFolder; use CraftCms\Cms\Dashboard\Widgets\Widget; +use CraftCms\Cms\Element\Actions\ElementAction; use CraftCms\Cms\Element\Element; use CraftCms\Cms\Element\Queries\ElementQuery; use CraftCms\Cms\Field\Field; @@ -54,6 +55,7 @@ public function register(): void $class::mixin(new LegacyBehaviorMixin()); } + ElementAction::mixin(new ValidateMixin()); Element::mixin(new ValidateMixin()); Element::mixin(new ElementMixin()); Field::mixin(new ValidateMixin()); diff --git a/yii2-adapter/src/Validation/LegacyElementRules.php b/yii2-adapter/src/Validation/LegacyElementRules.php index 8689ad79dd1..1fe6e94c2bb 100644 --- a/yii2-adapter/src/Validation/LegacyElementRules.php +++ b/yii2-adapter/src/Validation/LegacyElementRules.php @@ -5,9 +5,7 @@ namespace CraftCms\Yii2Adapter\Validation; use CraftCms\Cms\Element\Validation\ElementRules; -use CraftCms\Cms\Support\Arr; use ReflectionClass; -use yii\validators\Validator; class LegacyElementRules extends ElementRules { @@ -24,25 +22,10 @@ protected function defineRules(): array $method = $reflectionClass->getMethod('defineRules'); $yiiRules = $method->invoke($this->component); - // Ensure it's set and an array - $rules['*'] ??= []; - $rules['*'] = Arr::wrap($rules['*']); - - array_unshift($rules['*'], function($attribute, $value, $fail) use ($yiiRules) { - foreach ($yiiRules as $rule) { - $attributes = (array) $rule[0]; - $type = $rule[1]; - $options = array_slice($rule, 2); - - if (!in_array($attribute, $attributes, true)) { - continue; - } - - $validator = Validator::createValidator($type, $this->component, $attributes, $options); - $validator->validateAttribute($this->component, $attribute); - } - }); - - return $rules; + return LegacyYiiRules::mergeWildcardRules( + rules: $rules, + target: $this->component, + yiiRules: $yiiRules, + ); } } diff --git a/yii2-adapter/src/Validation/LegacyYiiRules.php b/yii2-adapter/src/Validation/LegacyYiiRules.php new file mode 100644 index 00000000000..2cb52637c54 --- /dev/null +++ b/yii2-adapter/src/Validation/LegacyYiiRules.php @@ -0,0 +1,152 @@ +validateAttribute($validationTarget, $attribute); + + if (!$copyErrors || !method_exists($validationTarget, 'getErrors')) { + continue; + } + + foreach ($validationTarget->getErrors($attribute) as $error) { + $fail((string)$error); + } + } + }; + } + + private static function methodValidator(object $target, string $method): Closure + { + return function(string $attribute, ?array $params, Validator $validator, mixed $current) use ($method, $target): void { + $parameterCount = (new ReflectionMethod($target, $method))->getNumberOfParameters(); + + match (true) { + $parameterCount === 0 => $target->$method(), + $parameterCount === 1 => $target->$method($attribute), + $parameterCount === 2 => $target->$method($attribute, $params), + $parameterCount === 3 => $target->$method($attribute, $params, $validator), + default => $target->$method($attribute, $params, $validator, $current), + }; + }; + } + + private static function normalizeWhenCallback(callable $callback, object $target): Closure + { + return function($model, string $attribute) use ($callback, $target): bool { + $callback = Closure::fromCallable($callback); + $parameterCount = (new ReflectionFunction($callback))->getNumberOfParameters(); + + return match (true) { + $parameterCount === 0 => (bool)$callback(), + $parameterCount === 1 => (bool)$callback($target), + default => (bool)$callback($target, $attribute), + }; + }; + } +} diff --git a/yii2-adapter/tests-laravel/Behavior/LegacyBehaviorCompatibilityTest.php b/yii2-adapter/tests-laravel/Behavior/LegacyBehaviorCompatibilityTest.php index 3d16ee4618d..c4ddb476189 100644 --- a/yii2-adapter/tests-laravel/Behavior/LegacyBehaviorCompatibilityTest.php +++ b/yii2-adapter/tests-laravel/Behavior/LegacyBehaviorCompatibilityTest.php @@ -86,13 +86,22 @@ public function updateOwnerDescription(string $description): void }); test('discovered legacy behavior targets are real wrappers rather than class aliases', function() { - $aliasTargets = collect(LegacyBehaviorCatalog::discoveredTargets()) - ->filter(fn(array $target) => str_contains((string) file_get_contents($target['path']), 'class_alias(')) + $unexpectedAliasTargets = collect(LegacyBehaviorCatalog::discoveredTargets()) + ->filter(function(array $target) { + $contents = (string) file_get_contents($target['path']); + + if (!str_contains($contents, 'class_alias(')) { + return false; + } + + return !str_contains($target['path'], '/legacy/elements/actions/') + && !str_contains($target['path'], '/legacy/elements/exporters/'); + }) ->pluck('legacyClass') ->values() ->all(); - expect($aliasTargets)->toBe([]); + expect($unexpectedAliasTargets)->toBe([]); }); test('component-backed classes inherit behaviors from base model, base component, and concrete legacy classes', function() { diff --git a/yii2-adapter/tests-laravel/Legacy/ElementActionCompatibilityTest.php b/yii2-adapter/tests-laravel/Legacy/ElementActionCompatibilityTest.php new file mode 100644 index 00000000000..10cc8f8f36d --- /dev/null +++ b/yii2-adapter/tests-laravel/Legacy/ElementActionCompatibilityTest.php @@ -0,0 +1,68 @@ +getElements()->createAction(LegacyDelete::class); + + self::assertInstanceOf(LegacyDelete::class, $action); + self::assertInstanceOf(Delete::class, $action); + self::assertInstanceOf(ElementActionInterface::class, $action); + } + + public function testLegacyElementActionSupportsYiiDefineRules(): void + { + $action = new class() extends \craft\base\ElementAction { + public ?string $status = null; + + protected function defineRules(): array + { + return [ + [['status'], 'required'], + ]; + } + }; + + self::assertFalse($action->validate()); + self::assertTrue($action->hasErrors('status')); + self::assertSame(['Status cannot be blank.'], $action->getErrors('status')); + + $action->status = 'enabled'; + + self::assertTrue($action->validate()); + self::assertFalse($action->hasErrors('status')); + } + + public function testLegacyDownloadActionsExposeSymfonyResponses(): void + { + $action = new class() extends \craft\base\ElementAction { + public static function isDownload(): bool + { + return true; + } + }; + + $response = Craft::$app->getResponse(); + $response->clear(); + $response->setStatusCode(200); + $response->content = 'downloaded'; + $response->setDownloadHeaders('entries.txt'); + + $downloadResponse = $action->getResponse(); + + self::assertNotNull($downloadResponse); + self::assertSame('downloaded', $downloadResponse->getContent()); + self::assertStringContainsString('entries.txt', (string)$downloadResponse->headers->get('content-disposition')); + } +} diff --git a/yii2-adapter/tests-laravel/Legacy/ElementExporterCompatibilityTest.php b/yii2-adapter/tests-laravel/Legacy/ElementExporterCompatibilityTest.php new file mode 100644 index 00000000000..2d7f1f44a7b --- /dev/null +++ b/yii2-adapter/tests-laravel/Legacy/ElementExporterCompatibilityTest.php @@ -0,0 +1,44 @@ +getElements()->createExporter(\craft\elements\exporters\Raw::class); + + self::assertInstanceOf(\CraftCms\Cms\Element\Exporters\Raw::class, $exporter); + } + + public function testLegacyElementExporterBaseClassStillWorks(): void + { + $exporter = new class() extends \craft\base\ElementExporter { + public function export(\CraftCms\Cms\Element\Queries\Contracts\ElementQueryInterface $query): mixed + { + return []; + } + }; + + $exporter->setElementType(Entry::class); + + self::assertSame('entries', $exporter->getFilename()); + self::assertInstanceOf(\CraftCms\Cms\Element\Contracts\ElementExporterInterface::class, $exporter); + } + + public function testLegacyRegisterExportersHandlersBridgeIntoRegisterExporters(): void + { + YiiEvent::on(Entry::class, \craft\base\Element::EVENT_REGISTER_EXPORTERS, function(\craft\events\RegisterElementExportersEvent $event) { + $event->exporters = [\craft\elements\exporters\Raw::class]; + }); + + self::assertSame([\craft\elements\exporters\Raw::class], Entry::exporters('*')); + } +}