Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
51 commits
Select commit Hold shift + click to select a range
0d3cfbf
createElement
riasvdv Mar 31, 2026
d065724
createElementQuery
riasvdv Mar 31, 2026
819b484
Element lookup
riasvdv Mar 31, 2026
4180fd4
Refactor bulkops to use a list of strings instead of keyed bools
riasvdv Mar 31, 2026
53c2d27
saveElement, setElementUri, mergeCanonicalChanges
riasvdv Mar 31, 2026
15d6c29
updateCanonicalElement
riasvdv Apr 1, 2026
5b334d5
resaveElements
riasvdv Apr 1, 2026
2c00b4e
propagateElements
riasvdv Apr 1, 2026
7008694
duplicateElement
riasvdv Apr 1, 2026
1115f39
updateElementSlugAndUri, updateElementSlugAndUriInOtherSites, updateD…
riasvdv Apr 1, 2026
ca03380
mergeElementsByIds, mergeElements
riasvdv Apr 1, 2026
95b7efd
deleteElement
riasvdv Apr 1, 2026
997a78f
deleteElementForSite, deleteElementsForSite
riasvdv Apr 1, 2026
47017d7
restoreElement, restoreElements
riasvdv Apr 1, 2026
db2ad9d
getAllElementTypes
riasvdv Apr 1, 2026
bb3c202
createAction, createExporter
riasvdv Apr 1, 2026
37882c9
parseRefs
riasvdv Apr 1, 2026
67b933f
{get|set}placeholderElement(s)
riasvdv Apr 1, 2026
7647c80
ElementEagerLoading
riasvdv Apr 1, 2026
3e1b40d
canView
riasvdv Apr 1, 2026
1113f82
canSave
riasvdv Apr 1, 2026
8a5a05f
canSaveCanonical
riasvdv Apr 1, 2026
1d74e29
canDuplicate
riasvdv Apr 1, 2026
dc70ade
canDelete
riasvdv Apr 1, 2026
c935eb1
canDeleteForSite
riasvdv Apr 1, 2026
1dd0aa1
canCreateDrafts
riasvdv Apr 1, 2026
ccd99cd
Cleanup
riasvdv Apr 1, 2026
d3ce116
phpstan
riasvdv Apr 1, 2026
89a8168
Fix some tests
riasvdv Apr 1, 2026
188c66a
Add test coverage for all actions
riasvdv Apr 1, 2026
7715d28
Rector
riasvdv Apr 1, 2026
19a4058
Replace mocks in legacy tests
riasvdv Apr 1, 2026
48ae2a4
Rename method as it confuses rector
riasvdv Apr 1, 2026
4df3bf2
Another mock
riasvdv Apr 1, 2026
ad3e14e
Extract ElementTypes service + refactor
riasvdv Apr 2, 2026
ed1f766
Refactor into separate services
riasvdv Apr 2, 2026
e5fc8ec
Fix some tests
riasvdv Apr 2, 2026
1a575b1
use a different id
riasvdv Apr 2, 2026
179e46e
Move action into service
riasvdv Apr 2, 2026
6d28a6d
Improve test coverage
riasvdv Apr 2, 2026
1c122d7
Fix compatibility
riasvdv Apr 2, 2026
3bee757
See if logout works
riasvdv Apr 2, 2026
b7eaaf8
Port element actions
riasvdv Apr 5, 2026
9e5bfef
Deprecation notices
riasvdv Apr 5, 2026
13b5ac7
Fix download response actions
riasvdv Apr 5, 2026
046f6e9
changelog
riasvdv Apr 5, 2026
fcfdb1b
Element exporters
riasvdv Apr 6, 2026
0e479d1
Fix phpstan + port ComponentHelper method
riasvdv Apr 6, 2026
a3a84f0
Fix adapter test
riasvdv Apr 6, 2026
28f8965
Fixes
riasvdv Apr 6, 2026
ae0e73f
Merge pull request #18665 from craftcms/feature/element-actions
brandonkelly Apr 7, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
The table of contents is too big for display.
Diff view
Diff view
  •  
  •  
  •  
Original file line number Diff line number Diff line change
Expand Up @@ -97,6 +97,7 @@ use craft\behaviors\CustomFieldBehavior;
use CraftCms\Cms\Field\Models\Field;
use CraftCms\Cms\FieldLayout\Models\FieldLayout;
use CraftCms\Cms\Entry\Models\Entry as EntryModel;
use CraftCms\Cms\Support\Facades\Elements;
use CraftCms\Cms\Support\Facades\Fields;

$field = Field::factory()->create([
Expand All @@ -118,7 +119,7 @@ $entry = entryQuery()->id($entry->id)->firstOrFail();
$entry->title = 'Test entry';
$entry->setFieldValue('textField', 'Foo');

Craft::$app->getElements()->saveElement($entry);
Elements::saveElement($entry);
```

## Testing element concerns (traits)
Expand Down Expand Up @@ -224,6 +225,7 @@ Use Laravel's event fakes to test that events are dispatched correctly:
```php
use CraftCms\Cms\Element\Events\BeforeSave;
use CraftCms\Cms\Element\Events\AfterSave;
use CraftCms\Cms\Support\Facades\Elements;
use Illuminate\Support\Facades\Event;

test('dispatches save events', function () {
Expand All @@ -232,7 +234,7 @@ test('dispatches save events', function () {
$entry = Entry::factory()->create();
$element = entryQuery()->id($entry->id)->one();

Craft::$app->getElements()->saveElement($element);
Elements::saveElement($element);

Event::assertDispatched(BeforeSave::class, function ($event) use ($element) {
return $event->element->id === $element->id;
Expand All @@ -253,7 +255,7 @@ test('can cancel save via event', function () {
$entry = Entry::factory()->create();
$element = entryQuery()->id($entry->id)->one();

$result = Craft::$app->getElements()->saveElement($element);
$result = Elements::saveElement($element);

expect($result)->toBeFalse();
});
Expand Down
6 changes: 6 additions & 0 deletions CHANGELOG-WIP.md
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down
4 changes: 2 additions & 2 deletions database/Factories/Concerns/HasFieldFactory.php
Original file line number Diff line number Diff line change
Expand Up @@ -4,13 +4,13 @@

namespace CraftCms\Cms\Database\Factories\Concerns;

use Craft;
use CraftCms\Cms\Database\Factories\ElementFactoryResult;
use CraftCms\Cms\Element\Element;
use CraftCms\Cms\Field\Models\Field;
use CraftCms\Cms\FieldLayout\LayoutElements\CustomField;
use CraftCms\Cms\FieldLayout\Models\FieldLayout;
use CraftCms\Cms\Support\Arr;
use CraftCms\Cms\Support\Facades\Elements;
use CraftCms\Cms\Support\Facades\EntryTypes;
use CraftCms\Cms\Support\Facades\Fields;
use CraftCms\Cms\Support\Str;
Expand Down Expand Up @@ -118,7 +118,7 @@ public function createElementWithFields(array $attributes = [], bool $save = tru
}

if ($save) {
Craft::$app->getElements()->saveElement($element);
Elements::saveElement($element);
$element = $factory->queryElement($model->id);
}

Expand Down
9 changes: 9 additions & 0 deletions database/Factories/SiteFactory.php
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@

use CraftCms\Cms\Site\Models\Site;
use CraftCms\Cms\Site\Models\SiteGroup;
use CraftCms\Cms\Site\Sites;
use Illuminate\Database\Eloquent\Factories\Factory;
use Override;

Expand All @@ -27,4 +28,12 @@ public function definition(): array
'sortOrder' => $this->faker->numberBetween(1, 100),
];
}

#[Override]
public function configure(): self
{
return $this->afterCreating(function (Site $site) {
app(Sites::class)->refreshSites();
});
}
}
10 changes: 8 additions & 2 deletions database/Factories/UserFactory.php
Original file line number Diff line number Diff line change
Expand Up @@ -4,8 +4,9 @@

namespace CraftCms\Cms\Database\Factories;

use Craft;
use CraftCms\Cms\Auth\Models\WebAuthn;
use CraftCms\Cms\Support\Facades\Elements;
use CraftCms\Cms\Support\Facades\UserPermissions;
use CraftCms\Cms\User\Models\User;
use Illuminate\Database\Eloquent\Factories\Factory;
use Illuminate\Database\Eloquent\Model;
Expand Down Expand Up @@ -88,6 +89,11 @@ public function withPasskey(string $credentialId): self
]));
}

public function withPermissions(array $permissions): self
{
return $this->afterCreating(fn (User $user) => UserPermissions::saveUserPermissions($user->id, $permissions));
}

#[Override]
protected function store(Collection $results): void
{
Expand All @@ -98,7 +104,7 @@ protected function store(Collection $results): void
}
}

if (! Craft::$app->getElements()->saveElement($element = $model->asElement())) {
if (! Elements::saveElement($element = $model->asElement())) {
dump($element->errors()->all());
throw new RuntimeException('Could not save user.');
}
Expand Down
6 changes: 6 additions & 0 deletions routes/actions.php
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -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);
Expand Down
2 changes: 1 addition & 1 deletion src/Address/Elements/Address.php
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down
45 changes: 45 additions & 0 deletions src/Asset/Actions/CopyReferenceTag.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,45 @@
<?php

declare(strict_types=1);

namespace CraftCms\Cms\Asset\Actions;

use CraftCms\Cms\Element\Actions\ElementAction;
use CraftCms\Cms\Support\Facades\HtmlStack;
use RuntimeException;

use function CraftCms\Cms\t;

class CopyReferenceTag extends ElementAction
{
#[\Override]
public function getTriggerLabel(): string
{
return t('Copy reference tag');
}

public function getTriggerHtml(): ?string
{
$refHandle = $this->elementType::refHandle();
if ($refHandle === null) {
throw new RuntimeException("Element type \"$this->elementType\" doesn't have a reference handle.");
}

HtmlStack::jsWithVars(fn ($type, $refHandle) => <<<JS
(() => {
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;
}
}
40 changes: 40 additions & 0 deletions src/Asset/Actions/CopyUrl.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,40 @@
<?php

declare(strict_types=1);

namespace CraftCms\Cms\Asset\Actions;

use CraftCms\Cms\Element\Actions\ElementAction;
use CraftCms\Cms\Support\Facades\HtmlStack;

use function CraftCms\Cms\t;

class CopyUrl extends ElementAction
{
#[\Override]
public function getTriggerLabel(): string
{
return t('Copy URL');
}

public function getTriggerHtml(): ?string
{
HtmlStack::jsWithVars(fn ($type) => <<<JS
(() => {
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;
}
}
61 changes: 61 additions & 0 deletions src/Asset/Actions/DeleteAssets.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,61 @@
<?php

declare(strict_types=1);

namespace CraftCms\Cms\Asset\Actions;

use CraftCms\Cms\Element\Actions\Delete;
use CraftCms\Cms\Support\Facades\HtmlStack;

class DeleteAssets extends Delete
{
#[\Override]
public function getTriggerHtml(): ?string
{
// Only enable for deletable elements, per canDelete()
HtmlStack::jsWithVars(fn ($type) => <<<JS
(() => {
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;
}
}
54 changes: 54 additions & 0 deletions src/Asset/Actions/DownloadAssetFile.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,54 @@
<?php

declare(strict_types=1);

namespace CraftCms\Cms\Asset\Actions;

use CraftCms\Cms\Element\Actions\ElementAction;
use CraftCms\Cms\Support\Facades\HtmlStack;

use function CraftCms\Cms\t;

class DownloadAssetFile extends ElementAction
{
#[\Override]
public function getTriggerLabel(): string
{
return t('Download');
}

public function getTriggerHtml(): ?string
{
HtmlStack::jsWithVars(fn ($type) => <<<JS
(() => {
new Craft.ElementActionTrigger({
type: $type,
activate: (selectedItems, elementIndex) => {
var \$form = Craft.createForm().appendTo(Garnish.\$bod);
$(Craft.getCsrfInput()).appendTo(\$form);
$('<input/>', {
type: 'hidden',
name: 'action',
value: 'assets/download-asset'
}).appendTo(\$form);
selectedItems.each(function() {
$('<input/>', {
type: 'hidden',
name: 'assetId[]',
value: $(this).data('id')
}).appendTo(\$form);
});
$('<input/>', {
type: 'submit',
value: 'Submit',
}).appendTo(\$form);
\$form.submit();
\$form.remove();
},
});
})();
JS, [static::class]);

return null;
}
}
Loading
Loading