, ...} $action
* @param class-string $elementType
*/
public function createAction(mixed $action, string $elementType): ElementActionInterface
diff --git a/src/Element/ElementActivity.php b/src/Element/ElementActivity.php
index 14dc6e67de5..0d5e20730ac 100644
--- a/src/Element/ElementActivity.php
+++ b/src/Element/ElementActivity.php
@@ -4,8 +4,8 @@
namespace CraftCms\Cms\Element;
-use craft\base\ElementInterface;
use CraftCms\Cms\Database\Table;
+use CraftCms\Cms\Element\Contracts\ElementInterface;
use CraftCms\Cms\Element\Data\ElementActivity as ElementActivityData;
use CraftCms\Cms\Element\Enums\ElementActivityType;
use CraftCms\Cms\Support\DateTimeHelper;
diff --git a/src/Element/ElementAttributeRenderer.php b/src/Element/ElementAttributeRenderer.php
index 0b00958f54a..40f19292700 100644
--- a/src/Element/ElementAttributeRenderer.php
+++ b/src/Element/ElementAttributeRenderer.php
@@ -4,12 +4,12 @@
namespace CraftCms\Cms\Element;
-use craft\base\ElementInterface;
use CraftCms\Cms\Cms;
use CraftCms\Cms\Cp\Html\ElementHtml;
use CraftCms\Cms\Cp\Html\PreviewHtml;
use CraftCms\Cms\Cp\Html\StatusHtml;
use CraftCms\Cms\Cp\Icons;
+use CraftCms\Cms\Element\Contracts\ElementInterface;
use CraftCms\Cms\Field\ContentBlock as ContentBlockField;
use CraftCms\Cms\Field\Contracts\EagerLoadingFieldInterface;
use CraftCms\Cms\Field\Contracts\FieldInterface;
diff --git a/src/Element/ElementCaches.php b/src/Element/ElementCaches.php
index a613369a2c1..990c1fb5f74 100644
--- a/src/Element/ElementCaches.php
+++ b/src/Element/ElementCaches.php
@@ -4,16 +4,16 @@
namespace CraftCms\Cms\Element;
-use craft\base\ElementInterface;
-use craft\base\NestedElementInterface;
+use CraftCms\Cms\Element\Contracts\ElementInterface;
+use CraftCms\Cms\Element\Contracts\NestedElementInterface;
use CraftCms\Cms\Element\Events\InvalidateElementCaches;
use CraftCms\Cms\View\CacheCollectors\DependencyCollector;
use CraftCms\Cms\View\Data\TemplateCacheContext;
use CraftCms\DependencyAwareCache\Dependency\TagDependency;
use DateTime;
use Illuminate\Container\Attributes\Singleton;
+use RuntimeException;
use Throwable;
-use yii\base\InvalidConfigException;
#[Singleton]
readonly class ElementCaches
@@ -113,7 +113,7 @@ private function tagsForElement(ElementInterface $element): array
if ($element instanceof NestedElementInterface) {
try {
$owner = $element->getOwner();
- } catch (InvalidConfigException) {
+ } catch (RuntimeException) {
$owner = null;
}
diff --git a/src/Element/ElementCollection.php b/src/Element/ElementCollection.php
index 5439f5988e1..371b58974a9 100644
--- a/src/Element/ElementCollection.php
+++ b/src/Element/ElementCollection.php
@@ -6,7 +6,7 @@
use Closure;
use Craft;
-use craft\base\ElementInterface;
+use CraftCms\Cms\Element\Contracts\ElementInterface;
use CraftCms\Cms\Support\Arr;
use CraftCms\Cms\Support\Facades\Elements;
use Illuminate\Contracts\Support\Arrayable;
diff --git a/src/Element/ElementExporters.php b/src/Element/ElementExporters.php
index 719aa49d621..ba7d33b42dd 100644
--- a/src/Element/ElementExporters.php
+++ b/src/Element/ElementExporters.php
@@ -4,9 +4,9 @@
namespace CraftCms\Cms\Element;
-use craft\base\ElementInterface;
use CraftCms\Cms\Component\ComponentHelper;
use CraftCms\Cms\Element\Contracts\ElementExporterInterface;
+use CraftCms\Cms\Element\Contracts\ElementInterface;
use CraftCms\Cms\Element\Queries\Contracts\ElementQueryInterface;
use CraftCms\Cms\Support\Json;
use CraftCms\Cms\Support\Str;
diff --git a/src/Element/ElementHelper.php b/src/Element/ElementHelper.php
index 6712de21452..8c01ba86b98 100644
--- a/src/Element/ElementHelper.php
+++ b/src/Element/ElementHelper.php
@@ -5,11 +5,11 @@
namespace CraftCms\Cms\Element;
use Craft;
-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\Element\Contracts\ElementInterface;
+use CraftCms\Cms\Element\Contracts\NestedElementInterface;
use CraftCms\Cms\Field\Exceptions\FieldNotFoundException;
use CraftCms\Cms\FieldLayout\LayoutElements\CustomField;
use CraftCms\Cms\Shared\Exceptions\OperationAbortedException;
diff --git a/src/Element/ElementRelations.php b/src/Element/ElementRelations.php
index 19169b83084..c7aa7d97e44 100644
--- a/src/Element/ElementRelations.php
+++ b/src/Element/ElementRelations.php
@@ -4,8 +4,8 @@
namespace CraftCms\Cms\Element;
-use craft\base\ElementInterface;
use CraftCms\Cms\Database\Table;
+use CraftCms\Cms\Element\Contracts\ElementInterface;
use CraftCms\Cms\Field\Contracts\FieldInterface;
use CraftCms\Cms\Field\Contracts\RelationalFieldInterface;
use CraftCms\Cms\Support\Arr;
diff --git a/src/Element/ElementSources.php b/src/Element/ElementSources.php
index 1544acf49c3..a9b962ad2cf 100644
--- a/src/Element/ElementSources.php
+++ b/src/Element/ElementSources.php
@@ -4,12 +4,12 @@
namespace CraftCms\Cms\Element;
-use craft\base\ElementInterface;
use craft\db\CoalesceColumnsExpression;
use CraftCms\Cms\Condition\Contracts\ConditionInterface;
use CraftCms\Cms\Cp\Icons;
use CraftCms\Cms\Database\Expressions\JsonExtract;
use CraftCms\Cms\Element\Conditions\Contracts\ElementConditionInterface;
+use CraftCms\Cms\Element\Contracts\ElementInterface;
use CraftCms\Cms\Element\Events\DefineSourceSortOptions;
use CraftCms\Cms\Element\Events\DefineSourceTableAttributes;
use CraftCms\Cms\Field\Contracts\PreviewableFieldInterface;
@@ -47,6 +47,8 @@ class ElementSources
public const string CONTEXT_SETTINGS = 'settings';
+ public const string CONTEXT_EMBEDDED_INDEX = 'embeddedIndex';
+
/**
* @see defineSources()
*/
diff --git a/src/Element/Elements.php b/src/Element/Elements.php
index 7e486c43bf4..77bdfcb16b4 100644
--- a/src/Element/Elements.php
+++ b/src/Element/Elements.php
@@ -4,11 +4,11 @@
namespace CraftCms\Cms\Element;
-use craft\base\ElementInterface;
use CraftCms\Cms\Address\Elements\Address;
use CraftCms\Cms\Asset\Elements\Asset;
use CraftCms\Cms\Component\ComponentHelper;
use CraftCms\Cms\Database\Table;
+use CraftCms\Cms\Element\Contracts\ElementInterface;
use CraftCms\Cms\Element\Data\EagerLoadPlan;
use CraftCms\Cms\Element\Events\RegisterElementTypes;
use CraftCms\Cms\Element\Exceptions\InvalidElementException;
diff --git a/src/Element/Enums/ElementIndexViewMode.php b/src/Element/Enums/ElementIndexViewMode.php
new file mode 100644
index 00000000000..fff44f2948a
--- /dev/null
+++ b/src/Element/Enums/ElementIndexViewMode.php
@@ -0,0 +1,51 @@
+ t('Display as cards'),
+ self::Structure => t('Display in a structured table'),
+ self::Table => t('Display in a table'),
+ self::Thumbs => t('Display as thumbnails'),
+ };
+ }
+
+ public function icon(): string
+ {
+ return match ($this) {
+ self::Cards => 'element-cards',
+ self::Structure => I18N::getLocale()->getOrientation() === 'rtl'
+ ? 'structurertl'
+ : 'structure',
+ self::Table => 'list',
+ self::Thumbs => 'grid',
+ };
+ }
+
+ public function toArray(): array
+ {
+ return [
+ 'mode' => $this->value,
+ 'title' => $this->title(),
+ 'icon' => $this->icon(),
+ ];
+ }
+}
diff --git a/src/Element/Events/AfterDelete.php b/src/Element/Events/AfterDelete.php
index fcca36f2361..4f415590547 100644
--- a/src/Element/Events/AfterDelete.php
+++ b/src/Element/Events/AfterDelete.php
@@ -4,7 +4,7 @@
namespace CraftCms\Cms\Element\Events;
-use craft\base\ElementInterface;
+use CraftCms\Cms\Element\Contracts\ElementInterface;
use CraftCms\Cms\Element\Element;
/**
diff --git a/src/Element/Events/AfterDeleteElement.php b/src/Element/Events/AfterDeleteElement.php
index 4a6b0b8b077..23478ac50f7 100644
--- a/src/Element/Events/AfterDeleteElement.php
+++ b/src/Element/Events/AfterDeleteElement.php
@@ -4,7 +4,7 @@
namespace CraftCms\Cms\Element\Events;
-use craft\base\ElementInterface;
+use CraftCms\Cms\Element\Contracts\ElementInterface;
class AfterDeleteElement
{
diff --git a/src/Element/Events/AfterDeleteForSite.php b/src/Element/Events/AfterDeleteForSite.php
index e61ddb6781d..42d10212f3b 100644
--- a/src/Element/Events/AfterDeleteForSite.php
+++ b/src/Element/Events/AfterDeleteForSite.php
@@ -4,7 +4,7 @@
namespace CraftCms\Cms\Element\Events;
-use craft\base\ElementInterface;
+use CraftCms\Cms\Element\Contracts\ElementInterface;
class AfterDeleteForSite
{
diff --git a/src/Element/Events/AfterMergeCanonicalChanges.php b/src/Element/Events/AfterMergeCanonicalChanges.php
index ac371ecb6ce..cb1e017dc52 100644
--- a/src/Element/Events/AfterMergeCanonicalChanges.php
+++ b/src/Element/Events/AfterMergeCanonicalChanges.php
@@ -4,7 +4,7 @@
namespace CraftCms\Cms\Element\Events;
-use craft\base\ElementInterface;
+use CraftCms\Cms\Element\Contracts\ElementInterface;
class AfterMergeCanonicalChanges
{
diff --git a/src/Element/Events/AfterMoveInStructure.php b/src/Element/Events/AfterMoveInStructure.php
index bd0827672e7..c7024503ee6 100644
--- a/src/Element/Events/AfterMoveInStructure.php
+++ b/src/Element/Events/AfterMoveInStructure.php
@@ -4,8 +4,8 @@
namespace CraftCms\Cms\Element\Events;
-use craft\base\ElementInterface;
use CraftCms\Cms\Element\Concerns\Structurable;
+use CraftCms\Cms\Element\Contracts\ElementInterface;
/**
* @event AfterMoveInStructure The event that is triggered after the element is moved in a structure.
diff --git a/src/Element/Events/AfterPropagate.php b/src/Element/Events/AfterPropagate.php
index 0be0ae43704..7a4159f584c 100644
--- a/src/Element/Events/AfterPropagate.php
+++ b/src/Element/Events/AfterPropagate.php
@@ -4,7 +4,7 @@
namespace CraftCms\Cms\Element\Events;
-use craft\base\ElementInterface;
+use CraftCms\Cms\Element\Contracts\ElementInterface;
use CraftCms\Cms\Element\Element;
/**
diff --git a/src/Element/Events/AfterPropagateElement.php b/src/Element/Events/AfterPropagateElement.php
index 5588825d727..ed9387a6226 100644
--- a/src/Element/Events/AfterPropagateElement.php
+++ b/src/Element/Events/AfterPropagateElement.php
@@ -4,7 +4,7 @@
namespace CraftCms\Cms\Element\Events;
-use craft\base\ElementInterface;
+use CraftCms\Cms\Element\Contracts\ElementInterface;
use CraftCms\Cms\Element\Queries\Contracts\ElementQueryInterface;
use Throwable;
diff --git a/src/Element/Events/AfterResaveElement.php b/src/Element/Events/AfterResaveElement.php
index d00629249d5..aa683056011 100644
--- a/src/Element/Events/AfterResaveElement.php
+++ b/src/Element/Events/AfterResaveElement.php
@@ -4,7 +4,7 @@
namespace CraftCms\Cms\Element\Events;
-use craft\base\ElementInterface;
+use CraftCms\Cms\Element\Contracts\ElementInterface;
use CraftCms\Cms\Element\Queries\Contracts\ElementQueryInterface;
use Throwable;
diff --git a/src/Element/Events/AfterRestore.php b/src/Element/Events/AfterRestore.php
index 8a5079d26b3..90ac221da65 100644
--- a/src/Element/Events/AfterRestore.php
+++ b/src/Element/Events/AfterRestore.php
@@ -4,7 +4,7 @@
namespace CraftCms\Cms\Element\Events;
-use craft\base\ElementInterface;
+use CraftCms\Cms\Element\Contracts\ElementInterface;
use CraftCms\Cms\Element\Element;
/**
diff --git a/src/Element/Events/AfterRestoreElement.php b/src/Element/Events/AfterRestoreElement.php
index d17836787f5..94ed17d1186 100644
--- a/src/Element/Events/AfterRestoreElement.php
+++ b/src/Element/Events/AfterRestoreElement.php
@@ -4,7 +4,7 @@
namespace CraftCms\Cms\Element\Events;
-use craft\base\ElementInterface;
+use CraftCms\Cms\Element\Contracts\ElementInterface;
class AfterRestoreElement
{
diff --git a/src/Element/Events/AfterSave.php b/src/Element/Events/AfterSave.php
index f3b5743dde3..c43733193b7 100644
--- a/src/Element/Events/AfterSave.php
+++ b/src/Element/Events/AfterSave.php
@@ -4,7 +4,7 @@
namespace CraftCms\Cms\Element\Events;
-use craft\base\ElementInterface;
+use CraftCms\Cms\Element\Contracts\ElementInterface;
use CraftCms\Cms\Element\Element;
/**
diff --git a/src/Element/Events/AfterSaveElement.php b/src/Element/Events/AfterSaveElement.php
index 0378587ead1..3702e0b37b1 100644
--- a/src/Element/Events/AfterSaveElement.php
+++ b/src/Element/Events/AfterSaveElement.php
@@ -4,7 +4,7 @@
namespace CraftCms\Cms\Element\Events;
-use craft\base\ElementInterface;
+use CraftCms\Cms\Element\Contracts\ElementInterface;
class AfterSaveElement
{
diff --git a/src/Element/Events/AfterSaveNestedElements.php b/src/Element/Events/AfterSaveNestedElements.php
index ee6ce4c762c..c6eba093a6f 100644
--- a/src/Element/Events/AfterSaveNestedElements.php
+++ b/src/Element/Events/AfterSaveNestedElements.php
@@ -4,7 +4,7 @@
namespace CraftCms\Cms\Element\Events;
-use craft\base\ElementInterface;
+use CraftCms\Cms\Element\Contracts\ElementInterface;
use CraftCms\Cms\Element\NestedElementManager;
class AfterSaveNestedElements
diff --git a/src/Element/Events/AfterUpdateSlugAndUri.php b/src/Element/Events/AfterUpdateSlugAndUri.php
index 2a50aae61be..e1291f05c1f 100644
--- a/src/Element/Events/AfterUpdateSlugAndUri.php
+++ b/src/Element/Events/AfterUpdateSlugAndUri.php
@@ -4,7 +4,7 @@
namespace CraftCms\Cms\Element\Events;
-use craft\base\ElementInterface;
+use CraftCms\Cms\Element\Contracts\ElementInterface;
class AfterUpdateSlugAndUri
{
diff --git a/src/Element/Events/AuthorizeCreateDrafts.php b/src/Element/Events/AuthorizeCreateDrafts.php
deleted file mode 100644
index c61659a918d..00000000000
--- a/src/Element/Events/AuthorizeCreateDrafts.php
+++ /dev/null
@@ -1,17 +0,0 @@
- 'nested-element-cards',
]);
- /** @var ElementQueryInterface|ElementCollection $value */
$value = $this->getValue($owner, true);
if ($value instanceof ElementCollection) {
/** @var NestedElementInterface[] $elements */
diff --git a/src/Element/Operations/ElementCanonicalChanges.php b/src/Element/Operations/ElementCanonicalChanges.php
index 8d2ad539800..0530d44feb6 100644
--- a/src/Element/Operations/ElementCanonicalChanges.php
+++ b/src/Element/Operations/ElementCanonicalChanges.php
@@ -4,9 +4,9 @@
namespace CraftCms\Cms\Element\Operations;
-use craft\base\ElementInterface;
use CraftCms\Cms\Database\Table;
use CraftCms\Cms\Element\BulkOp\BulkOps;
+use CraftCms\Cms\Element\Contracts\ElementInterface;
use CraftCms\Cms\Element\ElementHelper;
use CraftCms\Cms\Element\Events\AfterMergeCanonicalChanges;
use CraftCms\Cms\Element\Events\BeforeMergeCanonicalChanges;
diff --git a/src/Element/Operations/ElementDeletions.php b/src/Element/Operations/ElementDeletions.php
index 2ad9859e046..91babcc1f7f 100644
--- a/src/Element/Operations/ElementDeletions.php
+++ b/src/Element/Operations/ElementDeletions.php
@@ -4,9 +4,9 @@
namespace CraftCms\Cms\Element\Operations;
-use craft\base\ElementInterface;
use craft\behaviors\CustomFieldBehavior;
use CraftCms\Cms\Database\Table;
+use CraftCms\Cms\Element\Contracts\ElementInterface;
use CraftCms\Cms\Element\ElementCaches;
use CraftCms\Cms\Element\ElementCollection;
use CraftCms\Cms\Element\ElementHelper;
diff --git a/src/Element/Operations/ElementDuplicates.php b/src/Element/Operations/ElementDuplicates.php
index d3142c9a152..948c5c794a2 100644
--- a/src/Element/Operations/ElementDuplicates.php
+++ b/src/Element/Operations/ElementDuplicates.php
@@ -4,8 +4,8 @@
namespace CraftCms\Cms\Element\Operations;
-use craft\base\ElementInterface;
use CraftCms\Cms\Database\Table;
+use CraftCms\Cms\Element\Contracts\ElementInterface;
use CraftCms\Cms\Element\Drafts;
use CraftCms\Cms\Element\ElementHelper;
use CraftCms\Cms\Element\Exceptions\InvalidElementException;
diff --git a/src/Element/Operations/ElementEagerLoader.php b/src/Element/Operations/ElementEagerLoader.php
index e9742f43df1..6878d7ed0dc 100644
--- a/src/Element/Operations/ElementEagerLoader.php
+++ b/src/Element/Operations/ElementEagerLoader.php
@@ -4,8 +4,8 @@
namespace CraftCms\Cms\Element\Operations;
-use craft\base\ElementInterface;
use CraftCms\Cms\Database\Table;
+use CraftCms\Cms\Element\Contracts\ElementInterface;
use CraftCms\Cms\Element\Contracts\ExpirableElementInterface;
use CraftCms\Cms\Element\Data\EagerLoadInfo;
use CraftCms\Cms\Element\Data\EagerLoadPlan;
diff --git a/src/Element/Operations/ElementPlaceholders.php b/src/Element/Operations/ElementPlaceholders.php
index dd22571a444..83d04d3f710 100644
--- a/src/Element/Operations/ElementPlaceholders.php
+++ b/src/Element/Operations/ElementPlaceholders.php
@@ -4,7 +4,7 @@
namespace CraftCms\Cms\Element\Operations;
-use craft\base\ElementInterface;
+use CraftCms\Cms\Element\Contracts\ElementInterface;
use Illuminate\Container\Attributes\Scoped;
use InvalidArgumentException;
diff --git a/src/Element/Operations/ElementRefs.php b/src/Element/Operations/ElementRefs.php
index 54e94a88595..49674c6b436 100644
--- a/src/Element/Operations/ElementRefs.php
+++ b/src/Element/Operations/ElementRefs.php
@@ -4,7 +4,7 @@
namespace CraftCms\Cms\Element\Operations;
-use craft\base\ElementInterface;
+use CraftCms\Cms\Element\Contracts\ElementInterface;
use CraftCms\Cms\Element\Elements;
use CraftCms\Cms\Site\Exceptions\SiteNotFoundException;
use CraftCms\Cms\Site\Sites;
diff --git a/src/Element/Operations/ElementUris.php b/src/Element/Operations/ElementUris.php
index 409d4d9334f..beaf6f061be 100644
--- a/src/Element/Operations/ElementUris.php
+++ b/src/Element/Operations/ElementUris.php
@@ -4,8 +4,8 @@
namespace CraftCms\Cms\Element\Operations;
-use craft\base\ElementInterface;
use CraftCms\Cms\Database\Table;
+use CraftCms\Cms\Element\Contracts\ElementInterface;
use CraftCms\Cms\Element\ElementCaches;
use CraftCms\Cms\Element\ElementHelper;
use CraftCms\Cms\Element\Elements;
diff --git a/src/Element/Operations/ElementWrites.php b/src/Element/Operations/ElementWrites.php
index 6400971d0b8..c2787aac14a 100644
--- a/src/Element/Operations/ElementWrites.php
+++ b/src/Element/Operations/ElementWrites.php
@@ -4,9 +4,9 @@
namespace CraftCms\Cms\Element\Operations;
-use craft\base\ElementInterface;
-use craft\base\NestedElementInterface;
use CraftCms\Cms\Database\Table;
+use CraftCms\Cms\Element\Contracts\ElementInterface;
+use CraftCms\Cms\Element\Contracts\NestedElementInterface;
use CraftCms\Cms\Element\ElementCaches;
use CraftCms\Cms\Element\ElementHelper;
use CraftCms\Cms\Element\Elements;
diff --git a/src/Element/Policies/ElementPolicy.php b/src/Element/Policies/ElementPolicy.php
index b3dee610d1f..a61fc1c5c2c 100644
--- a/src/Element/Policies/ElementPolicy.php
+++ b/src/Element/Policies/ElementPolicy.php
@@ -5,9 +5,9 @@
namespace CraftCms\Cms\Element\Policies;
use BadMethodCallException;
-use craft\base\ElementInterface;
-use craft\base\NestedElementInterface;
use CraftCms\Cms\Auth\Events\AuthorizingElement;
+use CraftCms\Cms\Element\Contracts\ElementInterface;
+use CraftCms\Cms\Element\Contracts\NestedElementInterface;
use CraftCms\Cms\Field\Contracts\ElementContainerFieldInterface;
use CraftCms\Cms\Site\Models\Site;
use CraftCms\Cms\Support\Facades\Sites;
diff --git a/src/Element/Queries/AssetQuery.php b/src/Element/Queries/AssetQuery.php
index acf20bd692c..43574d1dfb1 100644
--- a/src/Element/Queries/AssetQuery.php
+++ b/src/Element/Queries/AssetQuery.php
@@ -4,9 +4,9 @@
namespace CraftCms\Cms\Element\Queries;
-use craft\base\ElementInterface;
use CraftCms\Cms\Asset\Elements\Asset;
use CraftCms\Cms\Database\Table;
+use CraftCms\Cms\Element\Contracts\ElementInterface;
use CraftCms\Cms\Element\Queries\Concerns\Asset\EagerloadsTransforms;
use CraftCms\Cms\Element\Queries\Concerns\Asset\QueriesAlt;
use CraftCms\Cms\Element\Queries\Concerns\Asset\QueriesAssetLocation;
diff --git a/src/Element/Queries/Concerns/HydratesElements.php b/src/Element/Queries/Concerns/HydratesElements.php
index d0b5ef15b66..297be022bc1 100644
--- a/src/Element/Queries/Concerns/HydratesElements.php
+++ b/src/Element/Queries/Concerns/HydratesElements.php
@@ -4,7 +4,7 @@
namespace CraftCms\Cms\Element\Queries\Concerns;
-use craft\base\ElementInterface;
+use CraftCms\Cms\Element\Contracts\ElementInterface;
use CraftCms\Cms\Element\Contracts\ExpirableElementInterface;
use CraftCms\Cms\Element\Drafts;
use CraftCms\Cms\Element\ElementHelper;
diff --git a/src/Element/Queries/Concerns/OverridesResults.php b/src/Element/Queries/Concerns/OverridesResults.php
index e61af41c477..8eaecd375a8 100644
--- a/src/Element/Queries/Concerns/OverridesResults.php
+++ b/src/Element/Queries/Concerns/OverridesResults.php
@@ -4,7 +4,7 @@
namespace CraftCms\Cms\Element\Queries\Concerns;
-use craft\base\ElementInterface;
+use CraftCms\Cms\Element\Contracts\ElementInterface;
/**
* @template TValue of ElementInterface
diff --git a/src/Element/Queries/Concerns/QueriesDraftsAndRevisions.php b/src/Element/Queries/Concerns/QueriesDraftsAndRevisions.php
index 6dff9744028..b3b88470363 100644
--- a/src/Element/Queries/Concerns/QueriesDraftsAndRevisions.php
+++ b/src/Element/Queries/Concerns/QueriesDraftsAndRevisions.php
@@ -4,8 +4,8 @@
namespace CraftCms\Cms\Element\Queries\Concerns;
-use craft\base\ElementInterface;
use CraftCms\Cms\Database\Table;
+use CraftCms\Cms\Element\Contracts\ElementInterface;
use CraftCms\Cms\Element\Queries\ElementQuery;
use CraftCms\Cms\Support\Arr;
use CraftCms\Cms\User\Elements\User;
diff --git a/src/Element/Queries/Concerns/QueriesEagerly.php b/src/Element/Queries/Concerns/QueriesEagerly.php
index 7d28cf96bdd..be54c5f879d 100644
--- a/src/Element/Queries/Concerns/QueriesEagerly.php
+++ b/src/Element/Queries/Concerns/QueriesEagerly.php
@@ -4,7 +4,7 @@
namespace CraftCms\Cms\Element\Queries\Concerns;
-use craft\base\ElementInterface;
+use CraftCms\Cms\Element\Contracts\ElementInterface;
use CraftCms\Cms\Element\Data\EagerLoadPlan;
use CraftCms\Cms\Support\Facades\Elements;
use Illuminate\Support\Collection;
diff --git a/src/Element/Queries/Concerns/QueriesNestedElements.php b/src/Element/Queries/Concerns/QueriesNestedElements.php
index 23f80f3e358..2135bebabbf 100644
--- a/src/Element/Queries/Concerns/QueriesNestedElements.php
+++ b/src/Element/Queries/Concerns/QueriesNestedElements.php
@@ -4,8 +4,8 @@
namespace CraftCms\Cms\Element\Queries\Concerns;
-use craft\base\ElementInterface;
use CraftCms\Cms\Database\Table;
+use CraftCms\Cms\Element\Contracts\ElementInterface;
use CraftCms\Cms\Element\Queries\ContentBlockQuery;
use CraftCms\Cms\Element\Queries\ElementQuery;
use CraftCms\Cms\Element\Queries\EntryQuery;
diff --git a/src/Element/Queries/Concerns/QueriesSites.php b/src/Element/Queries/Concerns/QueriesSites.php
index a395741542d..4bbe85b8987 100644
--- a/src/Element/Queries/Concerns/QueriesSites.php
+++ b/src/Element/Queries/Concerns/QueriesSites.php
@@ -30,6 +30,8 @@ trait QueriesSites
*/
public mixed $siteId = null;
+ private mixed $appliedSiteId = null;
+
protected function initQueriesSites(): void
{
$this->beforeQuery(function (ElementQuery $elementQuery) {
@@ -50,6 +52,8 @@ protected function initQueriesSites(): void
throw new QueryAbortedException($e->getMessage(), 0, $e);
}
+ $elementQuery->appliedSiteId = $elementQuery->siteId;
+
if (Sites::isMultiSite(false, true)) {
$elementQuery->whereIn('elements_sites.siteId', Arr::wrap($elementQuery->siteId));
}
diff --git a/src/Element/Queries/Concerns/QueriesStructures.php b/src/Element/Queries/Concerns/QueriesStructures.php
index 0d8f6b0e4f5..9ae935c00ac 100644
--- a/src/Element/Queries/Concerns/QueriesStructures.php
+++ b/src/Element/Queries/Concerns/QueriesStructures.php
@@ -4,8 +4,8 @@
namespace CraftCms\Cms\Element\Queries\Concerns;
-use craft\base\ElementInterface;
use CraftCms\Cms\Database\Table;
+use CraftCms\Cms\Element\Contracts\ElementInterface;
use CraftCms\Cms\Element\Queries\ElementQuery;
use CraftCms\Cms\Element\Queries\Exceptions\QueryAbortedException;
use CraftCms\Cms\Support\Facades\Elements;
diff --git a/src/Element/Queries/Concerns/QueriesUniqueElements.php b/src/Element/Queries/Concerns/QueriesUniqueElements.php
index 145f0486dff..9c36161c944 100644
--- a/src/Element/Queries/Concerns/QueriesUniqueElements.php
+++ b/src/Element/Queries/Concerns/QueriesUniqueElements.php
@@ -44,13 +44,15 @@ protected function applyUniqueParams(ElementQuery $elementQuery): void
return;
}
- if ($elementQuery->siteId &&
- (! is_array($elementQuery->siteId) || count($elementQuery->siteId) === 1)
+ $siteIds = $elementQuery->appliedSiteId ?? $elementQuery->siteId;
+
+ if ($siteIds &&
+ (! is_array($siteIds) || count($siteIds) === 1)
) {
return;
}
- $preferSites = collect($elementQuery->preferSites ?? Sites::getCurrentSite()->id)
+ $preferSites = collect($elementQuery->preferSites ?? [Sites::getCurrentSite()->id])
->map(fn (string|int $preferSite) => match (true) {
is_numeric($preferSite) => $preferSite,
! is_null($site = Sites::getSiteByHandle($preferSite)) => $site->id,
diff --git a/src/Element/Queries/Concerns/User/QueriesAuthors.php b/src/Element/Queries/Concerns/User/QueriesAuthors.php
index 9d96f4adc89..d99beba4d73 100644
--- a/src/Element/Queries/Concerns/User/QueriesAuthors.php
+++ b/src/Element/Queries/Concerns/User/QueriesAuthors.php
@@ -4,8 +4,8 @@
namespace CraftCms\Cms\Element\Queries\Concerns\User;
-use craft\base\ElementInterface;
use CraftCms\Cms\Database\Table;
+use CraftCms\Cms\Element\Contracts\ElementInterface;
use CraftCms\Cms\Element\Queries\Exceptions\QueryAbortedException;
use CraftCms\Cms\Element\Queries\UserQuery;
use Illuminate\Support\Facades\DB;
diff --git a/src/Element/Queries/Contracts/ElementQueryInterface.php b/src/Element/Queries/Contracts/ElementQueryInterface.php
index 75118473616..3d9d44b19f7 100644
--- a/src/Element/Queries/Contracts/ElementQueryInterface.php
+++ b/src/Element/Queries/Contracts/ElementQueryInterface.php
@@ -4,7 +4,7 @@
namespace CraftCms\Cms\Element\Queries\Contracts;
-use craft\base\ElementInterface;
+use CraftCms\Cms\Element\Contracts\ElementInterface;
use CraftCms\Cms\Element\Queries\ElementQuery;
use CraftCms\Cms\FieldLayout\FieldLayout;
use Illuminate\Contracts\Database\Query\Builder;
diff --git a/src/Element/Queries/Contracts/NestedElementQueryInterface.php b/src/Element/Queries/Contracts/NestedElementQueryInterface.php
index 7125f5f84c3..7e916c1a274 100644
--- a/src/Element/Queries/Contracts/NestedElementQueryInterface.php
+++ b/src/Element/Queries/Contracts/NestedElementQueryInterface.php
@@ -4,7 +4,7 @@
namespace CraftCms\Cms\Element\Queries\Contracts;
-use craft\base\ElementInterface;
+use CraftCms\Cms\Element\Contracts\ElementInterface;
/**
* NestedElementQueryInterface defines the common interface to be implemented by element query classes
diff --git a/src/Element/Queries/ElementQuery.php b/src/Element/Queries/ElementQuery.php
index 3ba7bba784c..600c7d2a78b 100644
--- a/src/Element/Queries/ElementQuery.php
+++ b/src/Element/Queries/ElementQuery.php
@@ -5,9 +5,9 @@
namespace CraftCms\Cms\Element\Queries;
use Closure;
-use craft\base\ElementInterface;
use CraftCms\Cms\Component\Component;
use CraftCms\Cms\Database\Table;
+use CraftCms\Cms\Element\Contracts\ElementInterface;
use CraftCms\Cms\Element\Element;
use CraftCms\Cms\Element\ElementCollection;
use CraftCms\Cms\Element\ElementHelper;
@@ -26,6 +26,8 @@
use Illuminate\Database\MultipleRecordsFoundException;
use Illuminate\Database\Query\Builder;
use Illuminate\Database\RecordsNotFoundException;
+use Illuminate\Pagination\LengthAwarePaginator;
+use Illuminate\Pagination\Paginator;
use Illuminate\Support\Collection;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\LazyCollection;
@@ -545,6 +547,59 @@ public function pluck($column, $key = null): Collection
return $this->query->pluck($column, $key);
}
+ /**
+ * Paginate the given query.
+ *
+ * @param int|null|Closure $perPage
+ * @param array|string $columns
+ * @param string $pageName
+ * @param int|null $page
+ * @param Closure|int|null $total
+ *
+ * @throws InvalidArgumentException
+ */
+ public function paginate($perPage = 15, $columns = ['*'], $pageName = 'page', $page = null, $total = null): LengthAwarePaginator
+ {
+ $page = $page ?: Paginator::resolveCurrentPage($pageName);
+
+ $total = value($total) ?? $this->getCountForPagination();
+
+ $perPage = value($perPage, $total);
+
+ $results = $total
+ ? $this->forPage($page, $perPage)->get($columns)
+ : new ElementCollection;
+
+ return $this->paginator($results, $total, $perPage, $page, [
+ 'path' => Paginator::resolveCurrentPath(),
+ 'pageName' => $pageName,
+ ]);
+ }
+
+ /**
+ * Paginate the given query into a simple paginator.
+ *
+ * @param int|null $perPage
+ * @param array|string $columns
+ * @param string $pageName
+ * @param int|null $page
+ * @return \Illuminate\Contracts\Pagination\Paginator
+ */
+ public function simplePaginate($perPage = 15, $columns = ['*'], $pageName = 'page', $page = null)
+ {
+ $page = $page ?: Paginator::resolveCurrentPage($pageName);
+
+ // Next we will set the limit and offset for this query so that when we get the
+ // results we get the proper section of results. Then, we'll create the full
+ // paginator instances for these results with the given page and per page.
+ $this->offset(($page - 1) * $perPage)->limit($perPage + 1);
+
+ return $this->simplePaginator($this->get($columns), $perPage, $page, [
+ 'path' => Paginator::resolveCurrentPath(),
+ 'pageName' => $pageName,
+ ]);
+ }
+
/** @TODO: Remove $_ variable after ElementQueryInterface is removed */
public function count($columns = '*', $_ = null): int
{
diff --git a/src/Element/Queries/Events/ElementHydrated.php b/src/Element/Queries/Events/ElementHydrated.php
index 49900e8e38c..d1fadcfbe1e 100644
--- a/src/Element/Queries/Events/ElementHydrated.php
+++ b/src/Element/Queries/Events/ElementHydrated.php
@@ -4,7 +4,7 @@
namespace CraftCms\Cms\Element\Queries\Events;
-use craft\base\ElementInterface;
+use CraftCms\Cms\Element\Contracts\ElementInterface;
class ElementHydrated
{
diff --git a/src/Element/Queries/Events/ElementsHydrated.php b/src/Element/Queries/Events/ElementsHydrated.php
index f220a4cf57f..70c5e9d7071 100644
--- a/src/Element/Queries/Events/ElementsHydrated.php
+++ b/src/Element/Queries/Events/ElementsHydrated.php
@@ -4,7 +4,7 @@
namespace CraftCms\Cms\Element\Queries\Events;
-use craft\base\ElementInterface;
+use CraftCms\Cms\Element\Contracts\ElementInterface;
class ElementsHydrated
{
diff --git a/src/Element/Queries/Events/HydratingElement.php b/src/Element/Queries/Events/HydratingElement.php
index fd5f9087d24..125df63c049 100644
--- a/src/Element/Queries/Events/HydratingElement.php
+++ b/src/Element/Queries/Events/HydratingElement.php
@@ -4,7 +4,7 @@
namespace CraftCms\Cms\Element\Queries\Events;
-use craft\base\ElementInterface;
+use CraftCms\Cms\Element\Contracts\ElementInterface;
class HydratingElement
{
diff --git a/src/Element/Queries/Exceptions/ElementNotFoundException.php b/src/Element/Queries/Exceptions/ElementNotFoundException.php
index 11fae7db378..50f5d05e8a5 100644
--- a/src/Element/Queries/Exceptions/ElementNotFoundException.php
+++ b/src/Element/Queries/Exceptions/ElementNotFoundException.php
@@ -8,7 +8,7 @@
use Illuminate\Database\RecordsNotFoundException;
/**
- * @template TElement of \craft\base\ElementInterface
+ * @template TElement of \CraftCms\Cms\Element\Contracts\ElementInterface
*/
class ElementNotFoundException extends RecordsNotFoundException
{
diff --git a/src/Element/Revisions.php b/src/Element/Revisions.php
index 76fe19814ec..4d142057cc1 100644
--- a/src/Element/Revisions.php
+++ b/src/Element/Revisions.php
@@ -4,9 +4,9 @@
namespace CraftCms\Cms\Element;
-use craft\base\ElementInterface;
use CraftCms\Cms\Cms;
use CraftCms\Cms\Database\Table;
+use CraftCms\Cms\Element\Contracts\ElementInterface;
use CraftCms\Cms\Element\Events\CreatingRevision;
use CraftCms\Cms\Element\Events\RevertedToRevision;
use CraftCms\Cms\Element\Events\RevertingToRevision;
@@ -189,7 +189,6 @@ public function createRevision(
*/
public function revertToRevision(ElementInterface $revision, int $creatorId): ElementInterface
{
- /** @var ElementInterface $revision */
$canonical = $revision->getCanonical();
event(new RevertingToRevision(
diff --git a/src/Element/Validation/ElementRules.php b/src/Element/Validation/ElementRules.php
index d8b8cbdaa95..8fab2f43549 100644
--- a/src/Element/Validation/ElementRules.php
+++ b/src/Element/Validation/ElementRules.php
@@ -5,8 +5,8 @@
namespace CraftCms\Cms\Element\Validation;
use Closure;
-use craft\base\ElementInterface;
use CraftCms\Cms\Cms;
+use CraftCms\Cms\Element\Contracts\ElementInterface;
use CraftCms\Cms\Element\Element;
use CraftCms\Cms\Element\ElementHelper;
use CraftCms\Cms\Element\Validation\Rules\ElementUriRule;
@@ -18,8 +18,8 @@
use CraftCms\Cms\Validation\Ruleset;
use Illuminate\Validation\Rule;
use Override;
+use RuntimeException;
use Throwable;
-use yii\base\InvalidConfigException;
use function CraftCms\Cms\t;
@@ -102,7 +102,7 @@ private function addTitleRules(array $rules): array
} else {
array_unshift($rules['title'], 'nullable');
}
- } catch (InvalidConfigException) {
+ } catch (RuntimeException) {
// Related to sectionId, fieldId and ownerId being missing
// Which will be caught by the other validation rules.
}
diff --git a/src/Entry/Conditions/AuthorConditionRule.php b/src/Entry/Conditions/AuthorConditionRule.php
index 14e1a054008..1e87ce0c426 100644
--- a/src/Entry/Conditions/AuthorConditionRule.php
+++ b/src/Entry/Conditions/AuthorConditionRule.php
@@ -4,9 +4,9 @@
namespace CraftCms\Cms\Entry\Conditions;
-use craft\base\ElementInterface;
use CraftCms\Cms\Condition\BaseElementSelectConditionRule;
use CraftCms\Cms\Element\Conditions\Contracts\ElementConditionRuleInterface;
+use CraftCms\Cms\Element\Contracts\ElementInterface;
use CraftCms\Cms\Element\Queries\Contracts\ElementQueryInterface;
use CraftCms\Cms\Element\Queries\EntryQuery;
use CraftCms\Cms\Entry\Elements\Entry;
diff --git a/src/Entry/Conditions/AuthorGroupConditionRule.php b/src/Entry/Conditions/AuthorGroupConditionRule.php
index 323040b7646..bdaa70f1a78 100644
--- a/src/Entry/Conditions/AuthorGroupConditionRule.php
+++ b/src/Entry/Conditions/AuthorGroupConditionRule.php
@@ -4,9 +4,9 @@
namespace CraftCms\Cms\Entry\Conditions;
-use craft\base\ElementInterface;
use CraftCms\Cms\Condition\BaseMultiSelectConditionRule;
use CraftCms\Cms\Element\Conditions\Contracts\ElementConditionRuleInterface;
+use CraftCms\Cms\Element\Contracts\ElementInterface;
use CraftCms\Cms\Element\Queries\Contracts\ElementQueryInterface;
use CraftCms\Cms\Element\Queries\EntryQuery;
use CraftCms\Cms\Entry\Elements\Entry;
diff --git a/src/Entry/Conditions/ExpiryDateConditionRule.php b/src/Entry/Conditions/ExpiryDateConditionRule.php
index dc778d1237b..1dfdd36ae06 100644
--- a/src/Entry/Conditions/ExpiryDateConditionRule.php
+++ b/src/Entry/Conditions/ExpiryDateConditionRule.php
@@ -4,9 +4,9 @@
namespace CraftCms\Cms\Entry\Conditions;
-use craft\base\ElementInterface;
use CraftCms\Cms\Condition\BaseDateRangeConditionRule;
use CraftCms\Cms\Element\Conditions\Contracts\ElementConditionRuleInterface;
+use CraftCms\Cms\Element\Contracts\ElementInterface;
use CraftCms\Cms\Element\Queries\Contracts\ElementQueryInterface;
use CraftCms\Cms\Element\Queries\EntryQuery;
use CraftCms\Cms\Entry\Elements\Entry;
diff --git a/src/Entry/Conditions/FieldConditionRule.php b/src/Entry/Conditions/FieldConditionRule.php
index 69765bdac05..090ebc058d6 100644
--- a/src/Entry/Conditions/FieldConditionRule.php
+++ b/src/Entry/Conditions/FieldConditionRule.php
@@ -4,10 +4,10 @@
namespace CraftCms\Cms\Entry\Conditions;
-use craft\base\ElementInterface;
use CraftCms\Cms\Condition\BaseMultiSelectConditionRule;
use CraftCms\Cms\Element\Conditions\Contracts\ElementConditionRuleInterface;
use CraftCms\Cms\Element\Conditions\HintableConditionRuleTrait;
+use CraftCms\Cms\Element\Contracts\ElementInterface;
use CraftCms\Cms\Element\Queries\Contracts\ElementQueryInterface;
use CraftCms\Cms\Element\Queries\EntryQuery;
use CraftCms\Cms\Entry\Elements\Entry;
diff --git a/src/Entry/Conditions/PostDateConditionRule.php b/src/Entry/Conditions/PostDateConditionRule.php
index ae4283a1914..5f7f5cbe174 100644
--- a/src/Entry/Conditions/PostDateConditionRule.php
+++ b/src/Entry/Conditions/PostDateConditionRule.php
@@ -4,9 +4,9 @@
namespace CraftCms\Cms\Entry\Conditions;
-use craft\base\ElementInterface;
use CraftCms\Cms\Condition\BaseDateRangeConditionRule;
use CraftCms\Cms\Element\Conditions\Contracts\ElementConditionRuleInterface;
+use CraftCms\Cms\Element\Contracts\ElementInterface;
use CraftCms\Cms\Element\Queries\Contracts\ElementQueryInterface;
use CraftCms\Cms\Element\Queries\EntryQuery;
use CraftCms\Cms\Entry\Elements\Entry;
diff --git a/src/Entry/Conditions/SavableConditionRule.php b/src/Entry/Conditions/SavableConditionRule.php
index 5f93623bfda..8d5b9196429 100644
--- a/src/Entry/Conditions/SavableConditionRule.php
+++ b/src/Entry/Conditions/SavableConditionRule.php
@@ -4,9 +4,9 @@
namespace CraftCms\Cms\Entry\Conditions;
-use craft\base\ElementInterface;
use CraftCms\Cms\Condition\BaseLightswitchConditionRule;
use CraftCms\Cms\Element\Conditions\Contracts\ElementConditionRuleInterface;
+use CraftCms\Cms\Element\Contracts\ElementInterface;
use CraftCms\Cms\Element\Queries\Contracts\ElementQueryInterface;
use CraftCms\Cms\Element\Queries\EntryQuery;
use Illuminate\Support\Facades\Gate;
diff --git a/src/Entry/Conditions/SectionConditionRule.php b/src/Entry/Conditions/SectionConditionRule.php
index 8de9a258691..319ca66880e 100644
--- a/src/Entry/Conditions/SectionConditionRule.php
+++ b/src/Entry/Conditions/SectionConditionRule.php
@@ -4,10 +4,10 @@
namespace CraftCms\Cms\Entry\Conditions;
-use craft\base\ElementInterface;
use CraftCms\Cms\Condition\BaseMultiSelectConditionRule;
use CraftCms\Cms\Element\Conditions\Contracts\ElementConditionRuleInterface;
use CraftCms\Cms\Element\Conditions\HintableConditionRuleTrait;
+use CraftCms\Cms\Element\Contracts\ElementInterface;
use CraftCms\Cms\Element\Queries\Contracts\ElementQueryInterface;
use CraftCms\Cms\Element\Queries\EntryQuery;
use CraftCms\Cms\Entry\Elements\Entry;
diff --git a/src/Entry/Conditions/TypeConditionRule.php b/src/Entry/Conditions/TypeConditionRule.php
index 3915a118f5f..21e2d5bbb22 100644
--- a/src/Entry/Conditions/TypeConditionRule.php
+++ b/src/Entry/Conditions/TypeConditionRule.php
@@ -4,9 +4,9 @@
namespace CraftCms\Cms\Entry\Conditions;
-use craft\base\ElementInterface;
use CraftCms\Cms\Condition\BaseMultiSelectConditionRule;
use CraftCms\Cms\Element\Conditions\Contracts\ElementConditionRuleInterface;
+use CraftCms\Cms\Element\Contracts\ElementInterface;
use CraftCms\Cms\Element\Queries\Contracts\ElementQueryInterface;
use CraftCms\Cms\Element\Queries\EntryQuery;
use CraftCms\Cms\Entry\Data\EntryType;
diff --git a/src/Entry/Conditions/ViewableConditionRule.php b/src/Entry/Conditions/ViewableConditionRule.php
index 02c9abac1af..0e1301e28fc 100644
--- a/src/Entry/Conditions/ViewableConditionRule.php
+++ b/src/Entry/Conditions/ViewableConditionRule.php
@@ -4,9 +4,9 @@
namespace CraftCms\Cms\Entry\Conditions;
-use craft\base\ElementInterface;
use CraftCms\Cms\Condition\BaseLightswitchConditionRule;
use CraftCms\Cms\Element\Conditions\Contracts\ElementConditionRuleInterface;
+use CraftCms\Cms\Element\Contracts\ElementInterface;
use CraftCms\Cms\Element\Queries\Contracts\ElementQueryInterface;
use CraftCms\Cms\Element\Queries\EntryQuery;
use Illuminate\Support\Facades\Gate;
diff --git a/src/Entry/Elements/Entry.php b/src/Entry/Elements/Entry.php
index 69bbced14fa..774c80b613b 100644
--- a/src/Entry/Elements/Entry.php
+++ b/src/Entry/Elements/Entry.php
@@ -5,11 +5,6 @@
namespace CraftCms\Cms\Entry\Elements;
use Craft;
-use craft\base\ElementInterface;
-use craft\base\NestedElementInterface;
-use craft\base\NestedElementTrait;
-use craft\controllers\ElementIndexesController;
-use craft\controllers\ElementsController;
use CraftCms\Cms\Cms;
use CraftCms\Cms\Component\Contracts\Colorable;
use CraftCms\Cms\Component\Contracts\Iconic;
@@ -24,8 +19,12 @@
use CraftCms\Cms\Element\Actions\DeleteForSite;
use CraftCms\Cms\Element\Actions\Duplicate;
use CraftCms\Cms\Element\Actions\Restore;
+use CraftCms\Cms\Element\Concerns\NestedElement;
use CraftCms\Cms\Element\Conditions\Contracts\ElementConditionInterface;
+use CraftCms\Cms\Element\Contracts\ElementInterface;
use CraftCms\Cms\Element\Contracts\ExpirableElementInterface;
+use CraftCms\Cms\Element\Contracts\NestedElementInterface;
+use CraftCms\Cms\Element\CurrentElementIndex;
use CraftCms\Cms\Element\Data\EagerLoadPlan;
use CraftCms\Cms\Element\Element;
use CraftCms\Cms\Element\ElementHelper;
@@ -53,8 +52,9 @@
use CraftCms\Cms\Field\Fields;
use CraftCms\Cms\Field\Matrix;
use CraftCms\Cms\FieldLayout\FieldLayout;
-use CraftCms\Cms\FieldLayout\LayoutElements\entries\EntryTitleField;
+use CraftCms\Cms\FieldLayout\LayoutElements\Entries\EntryTitleField;
use CraftCms\Cms\Gql\Interfaces\Elements\Entry as EntryInterface;
+use CraftCms\Cms\Http\Requests\ElementRequest;
use CraftCms\Cms\Section\Data\Section;
use CraftCms\Cms\Section\Data\SectionSiteSettings;
use CraftCms\Cms\Section\Enums\DefaultPlacement;
@@ -87,15 +87,12 @@
use DateTime;
use GraphQL\Type\Definition\Type;
use Illuminate\Database\Query\JoinClause;
-use Illuminate\Support\Collection;
use Illuminate\Support\Facades\Auth;
use Illuminate\Support\Facades\DB;
use InvalidArgumentException;
use Override;
-use Throwable;
+use RuntimeException;
use Tpetry\QueryExpressions\Language\Alias;
-use yii\base\Exception;
-use yii\base\InvalidConfigException;
use function CraftCms\Cms\renderObjectTemplate;
use function CraftCms\Cms\t;
@@ -112,7 +109,7 @@
#[Ruleset(EntryRules::class)]
class Entry extends Element implements Colorable, ExpirableElementInterface, Iconic, NestedElementInterface
{
- use NestedElementTrait {
+ use NestedElement {
eagerLoadingMap as traitEagerLoadingMap;
attributes as traitAttributes;
extraFields as traitExtraFields;
@@ -125,6 +122,137 @@ class Entry extends Element implements Colorable, ExpirableElementInterface, Ico
public const string STATUS_EXPIRED = 'expired';
+ /**
+ * @var int|null Section ID
+ * ---
+ * ```php
+ * echo $entry->sectionId;
+ * ```
+ * ```twig
+ * {{ entry.sectionId }}
+ * ```
+ */
+ public ?int $sectionId = null;
+
+ /**
+ * @var bool Collapsed
+ */
+ public bool $collapsed = false;
+
+ /**
+ * @var DateTime|null Post date
+ * ---
+ * ```php
+ * echo Craft::$app->formatter->asDate($entry->postDate, 'short');
+ * ```
+ * ```twig
+ * {{ entry.postDate|date('short') }}
+ * ```
+ */
+ #[AllowedInSandbox]
+ public ?DateTime $postDate = null;
+
+ /**
+ * @var DateTime|null Expiry date
+ * ---
+ * ```php
+ * if ($entry->expiryDate) {
+ * echo Craft::$app->formatter->asDate($entry->expiryDate, 'short');
+ * }
+ * ```
+ * ```twig
+ * {% if entry.expiryDate %}
+ * {{ entry.expiryDate|date('short') }}
+ * {% endif %}
+ * ```
+ */
+ #[AllowedInSandbox]
+ public ?DateTime $expiryDate = null;
+
+ /**
+ * @var self::STATUS_*|null The entry’s previous status, if it had one
+ */
+ public ?string $oldStatus = null;
+
+ /**
+ * @var self::STATUS_LIVE|self::STATUS_PENDING|self::STATUS_EXPIRED
+ */
+ private string $status;
+
+ /**
+ * @var bool Whether the entry was deleted along with its entry type
+ *
+ * @see beforeDelete()
+ *
+ * @internal
+ */
+ public bool $deletedWithEntryType = false;
+
+ /**
+ * @var bool Whether the entry was deleted along with its section
+ *
+ * @see beforeDelete()
+ *
+ * @internal
+ */
+ public bool $deletedWithSection = false;
+
+ /**
+ * @var bool Whether to force-place the entry within its structure.
+ */
+ public bool $placeInStructure = false;
+
+ /**
+ * @var int[] Entry author IDs
+ *
+ * @see getAuthorIds()
+ * @see setAuthorIds()
+ */
+ private array $_authorIds;
+
+ /**
+ * @var int[] Original entry author IDs
+ *
+ * @see getOldAuthorIds()
+ * @see setAuthorIds()
+ */
+ private ?array $_oldAuthorIds = null;
+
+ /**
+ * @var User[]|null Entry authors
+ *
+ * @see getAuthors()
+ * @see setAuthors()
+ */
+ private ?array $_authors = null;
+
+ /**
+ * @var int|null Type ID
+ *
+ * @see getType()
+ */
+ private ?int $_typeId = null;
+
+ private ?int $_oldTypeId = null;
+
+ /**
+ * @var EntryType|null Entry Type
+ *
+ * @see getType()
+ */
+ private ?EntryType $_type = null;
+
+ public function __construct($config = [])
+ {
+ parent::__construct($config);
+
+ if (isset($this->id)) {
+ $this->oldStatus = $this->getStatus();
+ }
+
+ $this->_oldTypeId = $this->_typeId;
+ }
+
#[Override]
public static function displayName(): string
{
@@ -201,9 +329,6 @@ public static function statuses(): array
];
}
- /**
- * @return EntryQuery The newly created [[EntryQuery]] instance.
- */
#[Override]
public static function find(): EntryQuery
{
@@ -387,8 +512,8 @@ protected static function defineFieldLayouts(?string $source): array
protected static function defineActions(string $source): array
{
// Get the selected site
- $elementQuery = Craft::$app->controller instanceof ElementIndexesController
- ? Craft::$app->controller->getElementQuery()
+ $elementQuery = app(CurrentElementIndex::class)->isActive()
+ ? app(CurrentElementIndex::class)->query()
: null;
$site = $elementQuery && $elementQuery->siteId
? Sites::getSiteById($elementQuery->siteId)
@@ -443,7 +568,6 @@ protected static function defineActions(string $source): array
$user->can("createEntries:$section->uid") &&
$user->can("saveEntries:$section->uid")
) {
- // Duplicate
$actions[] = [
'type' => Duplicate::class,
'asDrafts' => true,
@@ -457,27 +581,21 @@ protected static function defineActions(string $source): array
];
}
- // Copy
$actions[] = Copy::class;
-
- // Move to section
$actions[] = MoveToSection::class;
}
- // Delete?
$actions[] = Delete::class;
- if ($user->can("deleteEntries:$section->uid")) {
- if (
- $section->type === SectionType::Structure &&
- $section->maxLevels != 1 &&
- $user->can("deletePeerEntries:$section->uid")
- ) {
- $actions[] = [
- 'type' => Delete::class,
- 'withDescendants' => true,
- ];
- }
+ if ($user->can("deleteEntries:$section->uid")
+ && $section->type === SectionType::Structure
+ && $section->maxLevels !== 1
+ && $user->can("deletePeerEntries:$section->uid")
+ ) {
+ $actions[] = [
+ 'type' => Delete::class,
+ 'withDescendants' => true,
+ ];
}
} else {
$actions[] = Copy::class;
@@ -501,7 +619,6 @@ protected static function defineActions(string $source): array
$actions[] = DeleteForSite::class;
}
- // Restore
$actions[] = Restore::class;
return $actions;
@@ -533,9 +650,11 @@ protected static function defineSortOptions(): array
'label' => t('Section'),
'orderBy' => function (int $dir) {
$sectionIds = Sections::getAllSections()
- ->sort(fn (Section $a, Section $b) => $dir === SORT_ASC
- ? $a->name <=> $b->name
- : $b->name <=> $a->name)
+ ->sort(
+ fn (Section $a, Section $b) => $dir === SORT_ASC
+ ? $a->name <=> $b->name
+ : $b->name <=> $a->name
+ )
->pluck('id')
->all();
@@ -741,8 +860,6 @@ public static function eagerLoadingMap(array $sourceElements, string $handle): a
/**
* Returns the GraphQL type name that entries should use, based on their entry type.
- *
- * @since 5.0.0
*/
public static function gqlTypeName(EntryType $entryType): string
{
@@ -764,9 +881,7 @@ public static function gqlScopesByContext(mixed $context): array
/** @var Section $section */
$section = $context['section'];
- return [
- "sections.$section->uid",
- ];
+ return ["sections.$section->uid"];
}
#[Override]
@@ -778,149 +893,13 @@ protected static function prepElementQueryForTableAttribute(ElementQueryInterfac
};
}
- /**
- * @var int|null Section ID
- * ---
- * ```php
- * echo $entry->sectionId;
- * ```
- * ```twig
- * {{ entry.sectionId }}
- * ```
- */
- public ?int $sectionId = null;
-
- /**
- * @var bool Collapsed
- *
- * @since 5.0.0
- */
- public bool $collapsed = false;
-
- /**
- * @var DateTime|null Post date
- * ---
- * ```php
- * echo Craft::$app->formatter->asDate($entry->postDate, 'short');
- * ```
- * ```twig
- * {{ entry.postDate|date('short') }}
- * ```
- */
- #[AllowedInSandbox]
- public ?DateTime $postDate = null;
-
- /**
- * @var DateTime|null Expiry date
- * ---
- * ```php
- * if ($entry->expiryDate) {
- * echo Craft::$app->formatter->asDate($entry->expiryDate, 'short');
- * }
- * ```
- * ```twig
- * {% if entry.expiryDate %}
- * {{ entry.expiryDate|date('short') }}
- * {% endif %}
- * ```
- */
- #[AllowedInSandbox]
- public ?DateTime $expiryDate = null;
-
- /**
- * @var self::STATUS_*|null The entry’s previous status, if it had one
- */
- public ?string $oldStatus = null;
-
- /**
- * @var self::STATUS_LIVE|self::STATUS_PENDING|self::STATUS_EXPIRED
- */
- private string $status;
-
- /**
- * @var bool Whether the entry was deleted along with its entry type
- *
- * @see beforeDelete()
- *
- * @internal
- */
- public bool $deletedWithEntryType = false;
-
- /**
- * @var bool Whether the entry was deleted along with its section
- *
- * @see beforeDelete()
- *
- * @internal
- */
- public bool $deletedWithSection = false;
-
- /**
- * @var bool Whether to force-place the entry within its structure.
- *
- * @since 5.7.0
- */
- public bool $placeInStructure = false;
-
- /**
- * @var int[] Entry author IDs
- *
- * @see getAuthorIds()
- * @see setAuthorIds()
- */
- private array $_authorIds;
-
- /**
- * @var int[] Original entry author IDs
- *
- * @see getOldAuthorIds()
- * @see setAuthorIds()
- */
- private ?array $_oldAuthorIds = null;
-
- /**
- * @var User[]|null Entry authors
- *
- * @see getAuthors()
- * @see setAuthors()
- */
- private ?array $_authors = null;
-
- /**
- * @var int|null Type ID
- *
- * @see getType()
- */
- private ?int $_typeId = null;
-
- private ?int $_oldTypeId = null;
-
- /**
- * @var EntryType|null Entry Type
- *
- * @see getType()
- */
- private ?EntryType $_type = null;
-
- /**
- * @since 3.5.0
- */
- #[Override]
- public function init(): void
- {
- parent::init();
- if (isset($this->id)) {
- $this->oldStatus = $this->getStatus();
- }
- $this->_oldTypeId = $this->_typeId;
- }
-
#[Override]
public function attributes(): array
{
$names = array_flip($this->traitAttributes());
- unset($names['deletedWithEntryType']);
- unset($names['deletedWithSection']);
+
+ unset($names['deletedWithEntryType'], $names['deletedWithSection']);
+
$names['authorId'] = true;
$names['authorIds'] = true;
$names['typeId'] = true;
@@ -1016,7 +995,7 @@ public function getSupportedSites(): array
}
if (! isset($this->sectionId)) {
- throw new InvalidConfigException('Either `sectionId` or `fieldId` + `ownerId` must be set on the entry.');
+ throw new RuntimeException('Either `sectionId` or `fieldId` + `ownerId` must be set on the entry.');
}
$section = $this->getSection();
@@ -1062,33 +1041,22 @@ public function getSupportedSites(): array
}
foreach ($section->getSiteSettings() as $siteSettings) {
- switch ($section->propagationMethod) {
- case PropagationMethod::None:
- $include = $siteSettings->siteId === $this->siteId;
- $propagate = true;
- break;
- case PropagationMethod::SiteGroup:
- $include = $allSites[$siteSettings->siteId]->groupId === $allSites[$this->siteId]->groupId;
- $propagate = true;
- break;
- case PropagationMethod::Language:
- $include = $allSites[$siteSettings->siteId]->getLanguage() === $allSites[$this->siteId]->getLanguage();
- $propagate = true;
- break;
- case PropagationMethod::Custom:
- $include = true;
+ [$include, $propagate] = match ($section->propagationMethod) {
+ PropagationMethod::None => [$siteSettings->siteId === $this->siteId, true],
+ PropagationMethod::SiteGroup => [$allSites[$siteSettings->siteId]->groupId === $allSites[$this->siteId]->groupId, true],
+ PropagationMethod::Language => [$allSites[$siteSettings->siteId]->getLanguage() === $allSites[$this->siteId]->getLanguage(), true],
+ PropagationMethod::Custom => [
+ true,
// Only actually propagate to this site if it's the current site, or the entry has been assigned
// a status for this site, or the entry already exists for this site
- $propagate = (
+ (
$siteSettings->siteId === $this->siteId ||
$this->getEnabledForSite($siteSettings->siteId) !== null ||
isset($currentSites[$siteSettings->siteId])
- );
- break;
- default:
- $include = $propagate = true;
- break;
- }
+ ),
+ ],
+ default => [true, true],
+ };
if ($include) {
$sites[] = [
@@ -1102,18 +1070,17 @@ public function getSupportedSites(): array
return $sites;
}
- /**
- * @since 3.5.0
- */
#[Override]
protected function cacheTags(): array
{
+ $type = $this->getType();
+
$tags = [
- sprintf('entryType:%s', $this->getType()->id),
+ sprintf('entryType:%s', $type->id),
];
// Did the entry type just change?
- if ($this->getType()->id !== $this->_oldTypeId) {
+ if ($type->id !== $this->_oldTypeId) {
$tags[] = "entryType:$this->_oldTypeId";
}
@@ -1127,7 +1094,7 @@ protected function cacheTags(): array
}
/**
- * @throws InvalidConfigException if [[siteId]] is not set to a site ID that the entry’s section is enabled for
+ * @throws RuntimeException if [[siteId]] is not set to a site ID that the entry’s section is enabled for
*/
public function getUriFormat(): ?string
{
@@ -1136,13 +1103,13 @@ public function getUriFormat(): ?string
}
if (! isset($this->sectionId)) {
- throw new InvalidConfigException('Either `sectionId` or `fieldId` + `ownerId` must be set on the entry.');
+ throw new RuntimeException('Either `sectionId` or `fieldId` + `ownerId` must be set on the entry.');
}
$sectionSiteSettings = $this->getSection()->getSiteSettings();
if (! isset($sectionSiteSettings[$this->siteId])) {
- throw new InvalidConfigException('Entry’s section ('.$this->sectionId.') is not enabled for site '.$this->siteId);
+ throw new RuntimeException('Entry’s section ('.$this->sectionId.') is not enabled for site '.$this->siteId);
}
return $sectionSiteSettings[$this->siteId]->uriFormat;
@@ -1151,13 +1118,11 @@ public function getUriFormat(): ?string
protected function route(): ?array
{
// Make sure that the entry is actually live
- if (! $this->previewing && $this->getStatus() != self::STATUS_LIVE) {
+ if (! $this->previewing && $this->getStatus() !== self::STATUS_LIVE) {
return null;
}
- $section = $this->getSection();
-
- if (! $section) {
+ if (! $section = $this->getSection()) {
return null;
}
@@ -1215,7 +1180,6 @@ protected function crumbs(): array
return isset($sourceKeys[$key]);
});
- /** @var Collection $sectionOptions */
$sectionOptions = $sections
->filter(fn (Section $s) => $s->type !== SectionType::Single)
->map(fn (Section $s) => [
@@ -1259,15 +1223,13 @@ protected function crumbs(): array
$ancestors->status(null);
}
- foreach ($ancestors->all() as $ancestor) {
- if ($user->can('view', $ancestor)) {
- $crumbs[] = [
- 'html' => app(ElementHtml::class)->elementChipHtml($ancestor, [
- 'class' => 'chromeless',
- 'hyperlink' => true,
- ]),
- ];
- }
+ foreach ($ancestors->get()->filter(fn ($ancestor) => $user->can('view', $ancestor)) as $ancestor) {
+ $crumbs[] = [
+ 'html' => app(ElementHtml::class)->elementChipHtml($ancestor, [
+ 'class' => 'chromeless',
+ 'hyperlink' => true,
+ ]),
+ ];
}
}
@@ -1314,6 +1276,7 @@ protected function uiLabel(): ?string
public function getChipLabelHtml(): string
{
$html = parent::getChipLabelHtml();
+
if ($html !== '') {
return $html;
}
@@ -1410,7 +1373,7 @@ public function getFieldLayout(): ?FieldLayout
{
try {
return $this->getType()->getFieldLayout();
- } catch (InvalidConfigException) {
+ } catch (RuntimeException) {
// The entry type was probably deleted
return null;
}
@@ -1432,7 +1395,7 @@ public function getExpiryDate(): ?DateTime
* {% set section = entry.section %}
* ```
*
- * @throws InvalidConfigException if [[sectionId]] is missing or invalid
+ * @throws RuntimeException if [[sectionId]] is missing or invalid
*/
public function getSection(): ?Section
{
@@ -1440,29 +1403,18 @@ public function getSection(): ?Section
return null;
}
- $section = Sections::getSectionById($this->sectionId);
- if (! $section) {
- throw new InvalidConfigException("Invalid section ID: $this->sectionId");
+ if (! $section = Sections::getSectionById($this->sectionId)) {
+ throw new RuntimeException("Invalid section ID: $this->sectionId");
}
return $section;
}
- /**
- * Returns the entry type ID.
- *
- * @since 4.0.0
- */
public function getTypeId(): int
{
return $this->getType()->id;
}
- /**
- * Sets the entry type ID.
- *
- * @since 4.0.0
- */
public function setTypeId(int $typeId): void
{
$this->_typeId = $typeId;
@@ -1475,23 +1427,18 @@ public function setTypeId(int $typeId): void
*
* @return EntryType[]
*
- * @throws InvalidConfigException
- *
- * @since 3.6.0
+ * @throws RuntimeException
*/
public function getAvailableEntryTypes(bool $triggerEvent = true): array
{
- if (isset($this->fieldId)) {
- /** @var EntryType[] $entryTypes */
- $entryTypes = array_values(array_filter(
+ $entryTypes = match (true) {
+ isset($this->fieldId) => array_values(array_filter(
$this->getField()->getFieldLayoutProviders(),
fn ($provider) => $provider instanceof EntryType,
- ));
- } elseif (isset($this->sectionId)) {
- $entryTypes = $this->getSection()->getEntryTypes();
- } else {
- throw new InvalidConfigException('Either `sectionId` or `fieldId` + `ownerId` must be set on the entry.');
- }
+ )),
+ isset($this->sectionId) => $this->getSection()->getEntryTypes(),
+ default => throw new RuntimeException('Either `sectionId` or `fieldId` + `ownerId` must be set on the entry.'),
+ };
if ($triggerEvent) {
event($event = new DefineEntryTypes($this, $entryTypes));
@@ -1518,42 +1465,37 @@ public function getAvailableEntryTypes(bool $triggerEvent = true): array
* {% endswitch %}
* ```
*
- * @throws InvalidConfigException if [[typeId]] is invalid, or the section has no entry types
+ * @throws RuntimeException if [[typeId]] is invalid, or the section has no entry types
*/
public function getType(): EntryType
{
- if (! isset($this->_type)) {
- if (isset($this->_typeId)) {
- $entryType = Arr::first(
- $this->getAvailableEntryTypes(false),
- fn (EntryType $entryType) => $entryType->id === $this->_typeId,
- );
- if (! $entryType) {
- // Maybe the section/field no longer allows this type,
- // so get it directly from the Entries service instead
- $entryType = EntryTypes::getEntryTypeById($this->_typeId, true);
- if (! $entryType) {
- throw new InvalidConfigException("Invalid entry type ID: $this->_typeId");
- }
- }
- } else {
- // Default to the section/field's first entry type
- $entryType = Arr::first($this->getAvailableEntryTypes());
- if (! $entryType) {
- throw new InvalidConfigException('Entry is missing its type ID');
- }
+ if (isset($this->_type)) {
+ return $this->_type;
+ }
+
+ if (isset($this->_typeId)) {
+ $entryType = Arr::first(
+ $this->getAvailableEntryTypes(false),
+ fn (EntryType $entryType) => $entryType->id === $this->_typeId,
+ );
+
+ // Maybe the section/field no longer allows this type,
+ // so get it directly from the Entries service instead
+ if (! $entryType && ! $entryType = EntryTypes::getEntryTypeById($this->_typeId, true)) {
+ throw new RuntimeException("Invalid entry type ID: $this->_typeId");
}
- $this->_type = $entryType;
+
+ return $this->_type = $entryType;
}
- return $this->_type;
+ // Default to the section/field's first entry type
+ if (! $entryType = Arr::first($this->getAvailableEntryTypes())) {
+ throw new RuntimeException('Entry is missing its type ID');
+ }
+
+ return $this->_type = $entryType;
}
- /**
- * Returns the entry author’s ID.
- *
- * @since 4.0.0
- */
#[AllowedInSandbox]
public function getAuthorId(): ?int
{
@@ -1564,8 +1506,6 @@ public function getAuthorId(): ?int
* Sets the entry author’s ID.
*
* @param int|array{0:int}|string|null $authorId
- *
- * @since 4.0.0
*/
public function setAuthorId(array|int|string|null $authorId): void
{
@@ -1577,17 +1517,11 @@ public function setAuthorId(array|int|string|null $authorId): void
* Returns the primary entry authors’ IDs.
*
* @return int[]
- *
- * @since 5.0.0
*/
#[AllowedInSandbox]
public function getAuthorIds(): array
{
- if (! isset($this->_authorIds)) {
- $this->_authorIds = array_map(fn (User $author) => $author->id, $this->getAuthors());
- }
-
- return $this->_authorIds;
+ return $this->_authorIds ??= array_map(fn (User $author) => $author->id, $this->getAuthors());
}
public function getOldAuthorIds(): ?array
@@ -1599,17 +1533,13 @@ public function getOldAuthorIds(): ?array
* Sets the entry authors’ IDs.
*
* @param User[]|int[]|string|int|null $authorIds
- *
- * @since 5.0.0
*/
public function setAuthorIds(array|string|int|null $authorIds): void
{
$authorIds = $this->normalizeAuthorIds($authorIds);
- if (isset($this->_authorIds)) {
- if ($authorIds === $this->_authorIds) {
- return;
- }
+ if (isset($this->_authorIds) && $authorIds === $this->_authorIds) {
+ return;
}
$this->_authorIds = $authorIds;
@@ -1639,7 +1569,7 @@ private function normalizeAuthorIds(array|string|int|null $authorIds): array
* By {{ entry.author.name }}
* ```
*
- * @throws InvalidConfigException if [[authorId]] is set but invalid
+ * @throws RuntimeException if [[authorId]] is set but invalid
*/
#[AllowedInSandbox]
public function getAuthor(): ?User
@@ -1669,46 +1599,50 @@ public function setAuthor(?User $author = null): void
* ```
*
* @return User[]
- *
- * @since 5.0.0
*/
#[AllowedInSandbox]
public function getAuthors(): array
{
- if (! isset($this->_authors)) {
- if (! isset($this->sectionId)) {
- $authors = [];
- } elseif (isset($this->_authorIds)) {
- $authors = User::find()
- ->id($this->_authorIds)
- ->fixedOrder()
- ->status(null)
- ->all();
- } else {
- if (isset($this->elementQueryResult) && count($this->elementQueryResult) > 1) {
- // eager-load authors for all queried entries
- Elements::eagerLoadElements(self::class, $this->elementQueryResult, ['authors']);
+ if (isset($this->_authors)) {
+ return $this->_authors;
+ }
- return $this->_authors ?? [];
- }
+ if (! isset($this->sectionId)) {
+ $this->setAuthors([]);
- $authors = User::find()
- ->authorOf($this)
- ->status(null)
- ->join(
- new Alias(Table::ENTRIES_AUTHORS, 'entries_authors'),
- function (JoinClause $join) {
- $join->on('entries_authors.authorId', '=', 'users.id')
- ->where('entries_authors.entryId', '=', $this->id);
- }
- )
- ->orderBy('entries_authors.sortOrder')
- ->all();
- }
+ return $this->_authors;
+ }
+
+ if (isset($this->_authorIds)) {
+ $this->setAuthors(User::find()
+ ->id($this->_authorIds)
+ ->fixedOrder()
+ ->status(null)
+ ->all());
+
+ return $this->_authors;
+ }
+
+ if (isset($this->elementQueryResult) && count($this->elementQueryResult) > 1) {
+ // eager-load authors for all queried entries
+ Elements::eagerLoadElements(self::class, $this->elementQueryResult, ['authors']);
- $this->setAuthors($authors);
+ return [];
}
+ $this->setAuthors(User::find()
+ ->authorOf($this)
+ ->status(null)
+ ->join(
+ new Alias(Table::ENTRIES_AUTHORS, 'entries_authors'),
+ function (JoinClause $join) {
+ $join->on('entries_authors.authorId', '=', 'users.id')
+ ->where('entries_authors.entryId', '=', $this->id);
+ }
+ )
+ ->orderBy('entries_authors.sortOrder')
+ ->all());
+
return $this->_authors;
}
@@ -1716,8 +1650,6 @@ function (JoinClause $join) {
* Sets the entry authors.
*
* @param User[] $authors
- *
- * @since 5.0.0
*/
public function setAuthors(array $authors): void
{
@@ -1755,8 +1687,6 @@ private function _status(): string
* Sets the status, if it’s stored statically.
*
* @param self::STATUS_LIVE|self::STATUS_PENDING|self::STATUS_EXPIRED $status
- *
- * @since 5.7.0
*/
public function setStatus(string $status): void
{
@@ -1776,11 +1706,10 @@ public function createAnother(): self
'authorIds' => $this->getAuthorIds(),
]);
- $section = $this->getSection();
- if ($section) {
+ if ($section = $this->getSection()) {
// Set the default status based on the section's settings
/** @var SectionSiteSettings $siteSettings */
- $siteSettings = Collection::make($section->getSiteSettings())->firstWhere('siteId', $this->siteId);
+ $siteSettings = collect($section->getSiteSettings())->firstWhere('siteId', $this->siteId);
$enabled = $siteSettings->enabledByDefault;
} else {
$enabled = true;
@@ -1805,8 +1734,7 @@ public function createAnother(): self
#[Override]
public function hasRevisions(): bool
{
- $section = $this->getSection();
- if ($section) {
+ if ($section = $this->getSection()) {
return $section->enableVersioning;
}
@@ -1817,9 +1745,7 @@ public function hasRevisions(): bool
protected function cpEditUrl(): string
{
- $section = $this->getSection();
-
- if (! $section) {
+ if (! $section = $this->getSection()) {
// use the generic element editor URL
return ElementHelper::elementEditorUrl($this, false);
}
@@ -1910,14 +1836,13 @@ protected function safeActionMenuItems(): array
// Field settings
if (
! empty($this->fieldId) &&
- Craft::$app->controller instanceof ElementsController &&
- Craft::$app->controller->element === $this
+ app(ElementRequest::class)->element === $this
) {
$fieldEditId = sprintf('edit-field-%s', mt_rand());
$actions[] = [
'id' => $fieldEditId,
'icon' => 'gear',
- 'label' => Craft::t('app', 'Field settings'),
+ 'label' => t('Field settings'),
];
HtmlStack::jsWithVars(fn ($id, $params) => << 'chromeless',
'showThumb' => $this->viewMode !== 'cards',
]);
- } catch (InvalidConfigException) {
+ } catch (RuntimeException) {
return t('Unknown');
}
default:
@@ -2055,21 +1977,16 @@ protected function htmlAttributes(string $context): array
/**
* Returns whether the given user is authorized to move this entry to a different section.
- *
- * @since 5.9.14
*/
public function canMove(?User $user = null): bool
{
+ $user ??= Auth::user();
+
if (! $user) {
- $user = Auth::user();
- if (! $user) {
- return false;
- }
+ return false;
}
- $section = $this->getSection();
-
- if (! $section) {
+ if (! $section = $this->getSection()) {
return false;
}
@@ -2084,18 +2001,14 @@ public function canMove(?User $user = null): bool
}
if ($this->getIsDraft()) {
- return
- $this->draftCreatorId === $user->id ||
- $user->can("savePeerEntryDrafts:$section->uid");
+ return $this->draftCreatorId === $user->id || $user->can("savePeerEntryDrafts:$section->uid");
}
if (! $user->can("saveEntries:$section->uid")) {
return false;
}
- return
- in_array($user->id, $this->getAuthorIds(), true) ||
- $user->can("savePeerEntries:$section->uid");
+ return in_array($user->id, $this->getAuthorIds(), true) || $user->can("savePeerEntries:$section->uid");
}
/**
@@ -2108,17 +2021,12 @@ private function _moveCompatibleSectionsCount(): int
// get sections all editable sections without singles and without the section this entry belongs to
// get all entry types for them
- $sections = Sections::getEditableSections()
+ return Sections::getEditableSections()
->filter(fn (Section $s) => $s->type !== SectionType::Single && $s->id !== $this->sectionId)
- ->map(fn (Section $s) => [
- 'entryTypes' => $s->getEntryTypes(),
- ]);
-
- // get sections that use the same entry type as this entry
- $compatibleSections = $sections
- ->filter(fn (array $s) => collect($s['entryTypes'])->contains('id', $entryTypeId));
-
- return $compatibleSections->count();
+ ->map(fn (Section $s) => ['entryTypes' => $s->getEntryTypes()])
+ // get sections that use the same entry type as this entry
+ ->filter(fn (array $s) => collect($s['entryTypes'])->contains('id', $entryTypeId))
+ ->count();
}
#[Override]
@@ -2133,7 +2041,7 @@ public function metaFieldsHtml(bool $static): string
// Type
$fields['type'] = (function () use ($static) {
$entryTypes = $this->getAvailableEntryTypes();
- if (Collection::make($entryTypes)->doesntContain(fn (EntryType $entryType) => $entryType->id === $this->typeId)) {
+ if (collect($entryTypes)->doesntContain(fn (EntryType $entryType) => $entryType->id === $this->typeId)) {
$entryTypes[] = $this->getType();
}
if (count($entryTypes) <= 1 && $this->isEntryTypeAllowed($entryTypes)) {
@@ -2270,8 +2178,6 @@ public function metaFieldsHtml(bool $static): string
/**
* Checks if the "Apply Draft" and "Revert to a revision" buttons should be disabled and if so
* applies the tooltip message.
- *
- * @throws InvalidConfigException
*/
private function _applyActionBtnEntryTypeCompatibility(): void
{
@@ -2330,7 +2236,7 @@ public function showStatusField(): bool
{
try {
$showStatusField = $this->getType()->showStatusField;
- } catch (InvalidConfigException) {
+ } catch (RuntimeException) {
$showStatusField = true;
}
@@ -2383,8 +2289,6 @@ private function _parentOptionCriteria(Section $section): array
/**
* Updates the entry’s title, if its entry type has a dynamic title format.
- *
- * @since 3.0.3
*/
public function updateTitle(): void
{
@@ -2430,9 +2334,6 @@ private function _userPostDate(): ?DateTime
// Events
// -------------------------------------------------------------------------
- /**
- * @throws Exception if reasons
- */
#[Override]
public function beforeSave(bool $isNew): bool
{
@@ -2463,8 +2364,7 @@ public function beforeSave(bool $isNew): bool
}
}
- $section = $this->getSection();
- if ($section) {
+ if ($section = $this->getSection()) {
// Set the structure ID for Element::attributes() and afterSave()
if ($section->type === SectionType::Structure) {
$this->structureId = $section->structureId;
@@ -2479,7 +2379,7 @@ public function beforeSave(bool $isNew): bool
]);
if (! $parentEntry) {
- throw new InvalidConfigException("Invalid parent ID: $parentId");
+ throw new RuntimeException("Invalid parent ID: $parentId");
}
} else {
$parentEntry = null;
@@ -2514,20 +2414,18 @@ private function maybeSetDefaultAttributes(): void
}
$section = $this->getSection();
- if (
- $section?->type !== SectionType::Single &&
- $section?->maxAuthors !== 0 &&
- empty($this->getAuthors())
+ if ($section?->type !== SectionType::Single
+ && $section?->maxAuthors !== 0
+ && empty($this->getAuthors())
+ && $user = Auth::user()
) {
- if ($user = Auth::user()) {
- $this->setAuthor($user);
- }
+ $this->setAuthor($user);
}
if (
! $this->_userPostDate() &&
(
- in_array($this->scenario, [ElementRules::SCENARIO_LIVE, ElementRules::SCENARIO_DEFAULT]) ||
+ $this->ruleset->inScenarios(ElementRules::SCENARIO_LIVE, ElementRules::SCENARIO_DEFAULT) ||
! $this->getIsDraft()
)
) {
@@ -2542,9 +2440,6 @@ private function maybeSetDefaultAttributes(): void
}
}
- /**
- * @throws InvalidConfigException
- */
#[Override]
public function afterSave(bool $isNew): void
{
@@ -2594,7 +2489,7 @@ public function afterSave(bool $isNew): void
if (
(! $this->duplicateOf || $this->updatingFromDerivative || $this->placeInStructure) &&
isset($this->sectionId) &&
- $section->type == SectionType::Structure
+ $section->type === SectionType::Structure
) {
// Has the parent changed?
if ($this->placeInStructure || $this->hasNewParent()) {
@@ -2611,12 +2506,6 @@ public function afterSave(bool $isNew): void
parent::afterSave($isNew);
}
- /**
- * Save authors
- *
- * @throws Throwable
- * @throws \yii\db\Exception
- */
private function _saveAuthors(): void
{
if (! isset($this->_oldAuthorIds)) {
@@ -2663,7 +2552,7 @@ private function _placeInStructure(bool $isNew, Section $section): void
->unique()
->value('id');
- if ($parentId == $canonicalParentId) {
+ if ($parentId === $canonicalParentId) {
Structures::remove($this->structureId, $this);
return;
@@ -2673,17 +2562,13 @@ private function _placeInStructure(bool $isNew, Section $section): void
$mode = $isNew ? Mode::Insert : Mode::Auto;
if (! $parentId) {
- if ($section->defaultPlacement === DefaultPlacement::Beginning) {
- Structures::prependToRoot($this->structureId, $this, $mode);
- } else {
- Structures::appendToRoot($this->structureId, $this, $mode);
- }
+ $section->defaultPlacement === DefaultPlacement::Beginning
+ ? Structures::prependToRoot($this->structureId, $this, $mode)
+ : Structures::appendToRoot($this->structureId, $this, $mode);
} else {
- if ($section->defaultPlacement === DefaultPlacement::Beginning) {
- Structures::prepend($this->structureId, $this, $this->getParent(), $mode);
- } else {
- Structures::append($this->structureId, $this, $this->getParent(), $mode);
- }
+ $section->defaultPlacement === DefaultPlacement::Beginning
+ ? Structures::prepend($this->structureId, $this, $this->getParent(), $mode)
+ : Structures::append($this->structureId, $this, $this->getParent(), $mode);
}
}
@@ -2745,6 +2630,7 @@ public function afterRestore(): void
]);
$section = $this->getSection();
+
if ($section?->type === SectionType::Structure) {
// Add the entry back into its structure
/** @var self|null $parent */
@@ -2754,11 +2640,9 @@ public function afterRestore(): void
->where('j.id', $this->id)
->first();
- if (! $parent) {
- Structures::appendToRoot($section->structureId, $this);
- } else {
- Structures::append($section->structureId, $this, $parent);
- }
+ $parent
+ ? Structures::append($section->structureId, $this, $parent)
+ : Structures::appendToRoot($section->structureId, $this);
}
parent::afterRestore();
@@ -2770,7 +2654,7 @@ public function afterMoveInStructure(int $structureId): void
// Was the entry moved within its section's structure?
$section = $this->getSection();
- if ($section->type === SectionType::Structure && $section->structureId == $structureId) {
+ if ($section->type === SectionType::Structure && $section->structureId === $structureId) {
Elements::updateElementSlugAndUri(
element: $this,
updateOtherSites: true,
@@ -2815,10 +2699,6 @@ private function _shouldSaveRevision(): bool
/**
* Returns whether the entry’s type is allowed in its section.
- *
- * @throws InvalidConfigException
- *
- * @since 5.3.0
*/
public function isEntryTypeCompatible(): bool
{
@@ -2829,7 +2709,7 @@ public function isEntryTypeCompatible(): bool
return true;
}
- $sectionEntryTypes = Collection::make($section->getEntryTypes())
+ $sectionEntryTypes = collect($section->getEntryTypes())
->map(fn (EntryType $et) => $et->id)
->all();
@@ -2839,8 +2719,6 @@ public function isEntryTypeCompatible(): bool
/**
* Check if current typeId is in the array of passed in entry types.
* If no entry types are passed, check get all the available ones.
- *
- * @throws InvalidConfigException
*/
public function isEntryTypeAllowed(?array $entryTypes = null): bool
{
@@ -2853,8 +2731,7 @@ public function isEntryTypeAllowed(?array $entryTypes = null): bool
private function handleChangedTypeId(): void
{
- $oldLayout = EntryTypes::getEntryTypeById($this->_oldTypeId)?->getFieldLayout();
- if (! $oldLayout) {
+ if (! $oldLayout = EntryTypes::getEntryTypeById($this->_oldTypeId)?->getFieldLayout()) {
return;
}
@@ -2887,6 +2764,7 @@ protected function partialTemplatePathCandidates(): array
$templates = parent::partialTemplatePathCandidates();
$entryType = $this->getType();
+
if (isset($entryType->original) && $entryType->original->handle !== $entryType->handle) {
$templates[] = [
'template' => sprintf(
diff --git a/src/Field/Addresses.php b/src/Field/Addresses.php
index a8eaff5f3c0..067c33cee50 100644
--- a/src/Field/Addresses.php
+++ b/src/Field/Addresses.php
@@ -5,13 +5,14 @@
namespace CraftCms\Cms\Field;
use Closure;
-use craft\base\ElementInterface;
-use craft\base\NestedElementInterface;
use CraftCms\Cms\Address\Elements\Address;
use CraftCms\Cms\Database\Table as DbTable;
+use CraftCms\Cms\Element\Contracts\ElementInterface;
+use CraftCms\Cms\Element\Contracts\NestedElementInterface;
use CraftCms\Cms\Element\Drafts;
use CraftCms\Cms\Element\Element;
use CraftCms\Cms\Element\ElementCollection;
+use CraftCms\Cms\Element\Enums\ElementIndexViewMode;
use CraftCms\Cms\Element\NestedElementManager;
use CraftCms\Cms\Element\Queries\AddressQuery;
use CraftCms\Cms\Element\Queries\Contracts\ElementQueryInterface;
@@ -21,7 +22,6 @@
use CraftCms\Cms\Field\Contracts\ElementContainerFieldInterface;
use CraftCms\Cms\Field\Contracts\FieldInterface;
use CraftCms\Cms\Field\Contracts\MergeableFieldInterface;
-use CraftCms\Cms\Field\Enums\ElementIndexViewMode;
use CraftCms\Cms\Field\Enums\TranslationMethod;
use CraftCms\Cms\Field\Exceptions\InvalidFieldException;
use CraftCms\Cms\Gql\Arguments\Elements\Address as AddressArguments;
@@ -42,8 +42,8 @@
use Illuminate\Support\Facades\Validator;
use Illuminate\Validation\Rule;
use Override;
+use RuntimeException;
use Tpetry\QueryExpressions\Language\Alias;
-use yii\base\InvalidConfigException;
use function CraftCms\Cms\craftAsset;
use function CraftCms\Cms\t;
@@ -218,7 +218,7 @@ public function getSupportedSitesForElement(NestedElementInterface $element): ar
{
try {
$owner = $element->getOwner();
- } catch (InvalidConfigException) {
+ } catch (RuntimeException) {
$owner = $element->duplicateOf;
}
@@ -574,7 +574,7 @@ public function getTranslationDescription(?ElementInterface $element): ?string
}
/**
- * @throws InvalidConfigException
+ * @throws RuntimeException
*/
#[Override]
protected function inputHtml(mixed $value, ?ElementInterface $element, bool $inline): string
diff --git a/src/Field/Assets.php b/src/Field/Assets.php
index 27fc80b734b..e659955e862 100644
--- a/src/Field/Assets.php
+++ b/src/Field/Assets.php
@@ -5,7 +5,6 @@
namespace CraftCms\Cms\Field;
use Closure;
-use craft\base\ElementInterface;
use CraftCms\Cms\Asset\AssetsHelper;
use CraftCms\Cms\Asset\Data\Volume;
use CraftCms\Cms\Asset\Data\VolumeFolder;
@@ -15,6 +14,7 @@
use CraftCms\Cms\Cms;
use CraftCms\Cms\Cp\Html\PreviewHtml;
use CraftCms\Cms\Element\Conditions\ElementCondition;
+use CraftCms\Cms\Element\Contracts\ElementInterface;
use CraftCms\Cms\Element\ElementCollection;
use CraftCms\Cms\Element\ElementHelper;
use CraftCms\Cms\Element\Queries\AssetQuery;
diff --git a/src/Field/BaseOptionsField.php b/src/Field/BaseOptionsField.php
index f6edf0f9ec9..46f9f4c07e2 100644
--- a/src/Field/BaseOptionsField.php
+++ b/src/Field/BaseOptionsField.php
@@ -4,10 +4,10 @@
namespace CraftCms\Cms\Field;
-use craft\base\ElementInterface;
use CraftCms\Cms\Cp\FormFields;
use CraftCms\Cms\Cp\Icons;
use CraftCms\Cms\Database\QueryParam;
+use CraftCms\Cms\Element\Contracts\ElementInterface;
use CraftCms\Cms\Field\Conditions\OptionsFieldConditionRule;
use CraftCms\Cms\Field\Contracts\CrossSiteCopyableFieldInterface;
use CraftCms\Cms\Field\Contracts\MergeableFieldInterface;
diff --git a/src/Field/BaseRelationField.php b/src/Field/BaseRelationField.php
index 565bc03a799..64fb6c22a32 100644
--- a/src/Field/BaseRelationField.php
+++ b/src/Field/BaseRelationField.php
@@ -6,8 +6,6 @@
use Closure;
use Craft;
-use craft\base\ElementInterface;
-use craft\base\NestedElementInterface;
use CraftCms\Cms\Condition\Contracts\ConditionInterface;
use CraftCms\Cms\Cp\FormFields;
use CraftCms\Cms\Cp\Html\ElementHtml;
@@ -18,6 +16,8 @@
use CraftCms\Cms\Database\Table;
use CraftCms\Cms\Element\Conditions\Contracts\ElementConditionInterface;
use CraftCms\Cms\Element\Conditions\ElementCondition;
+use CraftCms\Cms\Element\Contracts\ElementInterface;
+use CraftCms\Cms\Element\Contracts\NestedElementInterface;
use CraftCms\Cms\Element\Drafts;
use CraftCms\Cms\Element\Element;
use CraftCms\Cms\Element\ElementCollection;
@@ -62,8 +62,8 @@
use Illuminate\Support\Facades\Validator as ValidatorFacade;
use Illuminate\Validation\Validator;
use Override;
+use RuntimeException;
use Tpetry\QueryExpressions\Language\Alias;
-use yii\base\InvalidConfigException;
use yii\db\Schema;
use function CraftCms\Cms\craftAsset;
@@ -456,12 +456,15 @@ public function getRules(): array
]);
}
+ public function afterValidate(?Validator $validator = null): void
+ {
+ $this->validateSources();
+ }
+
/**
* Ensure only one structured source is selected when maintainHierarchy is true.
- *
- * @todo This needs to be called from somewhere
*/
- public function validateSources(string $attribute): void
+ public function validateSources(): void
{
if (! $this->maintainHierarchy) {
return;
@@ -1170,7 +1173,7 @@ public function getRelationTargetIds(ElementInterface $element): array
is_array($value->id) &&
Arr::isNumeric($value->id)
) {
- $targetIds = $value->id ?: [];
+ $targetIds = $value->id;
} elseif (
$value instanceof ElementQuery &&
($where = $value->getWhereForColumn('elements.id')) !== null &&
@@ -1368,8 +1371,9 @@ public function getViewModeFieldHtml(): ?string
'width' => $key === self::VIEW_MODE_LIST ? 48 : 80,
'height' => 60,
]).
- Html::radio('viewMode', $key === $this->viewMode, [
+ Html::radio('viewMode', $key, [
'value' => $key,
+ 'checked' => $this->viewMode === $key,
]).
' '.$label.
Html::endTag('label');
@@ -1480,7 +1484,7 @@ protected function inputTemplateVariables(
if ($el) {
$disabledElementIds[] = $el->getCanonicalId();
}
- } catch (InvalidConfigException) {
+ } catch (RuntimeException) {
break;
}
} while ($el instanceof NestedElementInterface);
diff --git a/src/Field/ButtonGroup.php b/src/Field/ButtonGroup.php
index 0d9a6da1511..41cb7a1f51e 100644
--- a/src/Field/ButtonGroup.php
+++ b/src/Field/ButtonGroup.php
@@ -4,8 +4,8 @@
namespace CraftCms\Cms\Field;
-use craft\base\ElementInterface;
use CraftCms\Cms\Cp\FormFields;
+use CraftCms\Cms\Element\Contracts\ElementInterface;
use CraftCms\Cms\Field\Contracts\SortableFieldInterface;
use CraftCms\Cms\Field\Data\SingleOptionFieldData;
use CraftCms\Cms\Support\Facades\DeltaRegistry;
diff --git a/src/Field/Checkboxes.php b/src/Field/Checkboxes.php
index fa9e1fdd42a..8755f4661c6 100644
--- a/src/Field/Checkboxes.php
+++ b/src/Field/Checkboxes.php
@@ -4,7 +4,7 @@
namespace CraftCms\Cms\Field;
-use craft\base\ElementInterface;
+use CraftCms\Cms\Element\Contracts\ElementInterface;
use CraftCms\Cms\Field\Data\OptionData;
use CraftCms\Cms\Support\Facades\DeltaRegistry;
use Illuminate\Support\Collection;
diff --git a/src/Field/Color.php b/src/Field/Color.php
index 96bac95a502..077f5ecbdd4 100644
--- a/src/Field/Color.php
+++ b/src/Field/Color.php
@@ -4,8 +4,8 @@
namespace CraftCms\Cms\Field;
-use craft\base\ElementInterface;
use CraftCms\Cms\Cp\FormFields;
+use CraftCms\Cms\Element\Contracts\ElementInterface;
use CraftCms\Cms\Entry\Elements\Entry;
use CraftCms\Cms\Field\Contracts\CrossSiteCopyableFieldInterface;
use CraftCms\Cms\Field\Contracts\InlineEditableFieldInterface;
diff --git a/src/Field/Concerns/RelationalField.php b/src/Field/Concerns/RelationalField.php
index d8913e354e8..ca0f54b0201 100644
--- a/src/Field/Concerns/RelationalField.php
+++ b/src/Field/Concerns/RelationalField.php
@@ -4,7 +4,7 @@
namespace CraftCms\Cms\Field\Concerns;
-use craft\base\ElementInterface;
+use CraftCms\Cms\Element\Contracts\ElementInterface;
/**
* RelationalFieldTrait provides a base implementation for {@see \CraftCms\Cms\Field\Contracts\RelationalFieldInterface}.
diff --git a/src/Field/Conditions/CountryFieldConditionRule.php b/src/Field/Conditions/CountryFieldConditionRule.php
index 4e3204d9ba9..2a70eb67e93 100644
--- a/src/Field/Conditions/CountryFieldConditionRule.php
+++ b/src/Field/Conditions/CountryFieldConditionRule.php
@@ -8,7 +8,7 @@
use CraftCms\Cms\Condition\BaseMultiSelectConditionRule;
use CraftCms\Cms\Field\Conditions\Contracts\FieldConditionRuleInterface;
use CraftCms\Cms\Field\Country;
-use yii\base\InvalidConfigException;
+use RuntimeException;
class CountryFieldConditionRule extends BaseMultiSelectConditionRule implements FieldConditionRuleInterface
{
@@ -23,7 +23,7 @@ protected function options(): array
protected function inputHtml(): string
{
if (! $this->field() instanceof Country) {
- throw new InvalidConfigException;
+ throw new RuntimeException;
}
return parent::inputHtml();
diff --git a/src/Field/Conditions/DateFieldConditionRule.php b/src/Field/Conditions/DateFieldConditionRule.php
index cc19e299379..dd63fbac9c7 100644
--- a/src/Field/Conditions/DateFieldConditionRule.php
+++ b/src/Field/Conditions/DateFieldConditionRule.php
@@ -8,7 +8,7 @@
use CraftCms\Cms\Field\Conditions\Contracts\FieldConditionRuleInterface;
use CraftCms\Cms\Field\Date;
use DateTime;
-use yii\base\InvalidConfigException;
+use RuntimeException;
class DateFieldConditionRule extends BaseDateRangeConditionRule implements FieldConditionRuleInterface
{
@@ -18,7 +18,7 @@ class DateFieldConditionRule extends BaseDateRangeConditionRule implements Field
protected function inputHtml(): string
{
if (! $this->field() instanceof Date) {
- throw new InvalidConfigException;
+ throw new RuntimeException;
}
return parent::inputHtml();
diff --git a/src/Field/Conditions/EmptyFieldConditionRule.php b/src/Field/Conditions/EmptyFieldConditionRule.php
index 8951a33e71e..819e039af9e 100644
--- a/src/Field/Conditions/EmptyFieldConditionRule.php
+++ b/src/Field/Conditions/EmptyFieldConditionRule.php
@@ -5,11 +5,11 @@
namespace CraftCms\Cms\Field\Conditions;
use craft\base\conditions\BaseConditionRule;
-use craft\base\ElementInterface;
+use CraftCms\Cms\Element\Contracts\ElementInterface;
use CraftCms\Cms\Field\Conditions\Contracts\FieldConditionRuleInterface;
use CraftCms\Cms\Field\Exceptions\InvalidFieldException;
-use yii\base\InvalidConfigException;
-use yii\base\NotSupportedException;
+use CraftCms\Cms\Shared\Exceptions\NotSupportedException;
+use RuntimeException;
class EmptyFieldConditionRule extends BaseConditionRule implements FieldConditionRuleInterface
{
@@ -31,7 +31,7 @@ public function matchElement(ElementInterface $element): bool
{
try {
$field = $this->field();
- } catch (InvalidConfigException) {
+ } catch (RuntimeException) {
// The field doesn't exist
return true;
}
@@ -57,7 +57,7 @@ protected function elementQueryParam(): int|string|null
return match ($this->operator) {
self::OPERATOR_EMPTY => ':empty:',
self::OPERATOR_NOT_EMPTY => 'not :empty:',
- default => throw new InvalidConfigException("Invalid operator: $this->operator"),
+ default => throw new RuntimeException("Invalid operator: $this->operator"),
};
}
diff --git a/src/Field/Conditions/FieldConditionRuleTrait.php b/src/Field/Conditions/FieldConditionRuleTrait.php
index 050a8ca15f6..89121f38f53 100644
--- a/src/Field/Conditions/FieldConditionRuleTrait.php
+++ b/src/Field/Conditions/FieldConditionRuleTrait.php
@@ -4,12 +4,12 @@
namespace CraftCms\Cms\Field\Conditions;
-use craft\base\ElementInterface;
use CraftCms\Cms\Element\Conditions\Contracts\ElementConditionInterface;
+use CraftCms\Cms\Element\Contracts\ElementInterface;
use CraftCms\Cms\Field\Contracts\FieldInterface;
use Illuminate\Contracts\Database\Query\Builder;
use Illuminate\Support\Facades\Auth;
-use yii\base\InvalidConfigException;
+use RuntimeException;
use function CraftCms\Cms\t;
@@ -66,7 +66,7 @@ public function setLayoutElementUid(?string $uid): void
*
* @return FieldInterface[]
*
- * @throws InvalidConfigException if [[fieldUid]] or [[layoutElementUid]] are invalid
+ * @throws RuntimeException if [[fieldUid]] or [[layoutElementUid]] are invalid
*/
protected function fieldInstances(): array
{
@@ -75,7 +75,7 @@ protected function fieldInstances(): array
}
if (! isset($this->_fieldUid)) {
- throw new InvalidConfigException('No field UUID set on the field condition rule yet.');
+ throw new RuntimeException('No field UUID set on the field condition rule yet.');
}
// Loop through all the layout's fields, and look for the selected field instance
@@ -117,11 +117,11 @@ protected function fieldInstances(): array
if (empty($this->_fieldInstances)) {
if (! isset($this->_layoutElementUid)) {
- throw new InvalidConfigException("Field $this->_fieldUid is not included in the available field layouts.");
+ throw new RuntimeException("Field $this->_fieldUid is not included in the available field layouts.");
}
if (empty($potentialInstances)) {
- throw new InvalidConfigException("Invalid field layout element UUID: $this->_layoutElementUid");
+ throw new RuntimeException("Invalid field layout element UUID: $this->_layoutElementUid");
}
// Just go with the first one
@@ -146,7 +146,7 @@ protected function fieldInstances(): array
/**
* Returns the first custom field instance associated with this rule.
*
- * @throws InvalidConfigException if [[fieldUid]] or [[layoutElementUid]] are invalid
+ * @throws RuntimeException if [[fieldUid]] or [[layoutElementUid]] are invalid
*/
protected function field(): FieldInterface
{
@@ -165,7 +165,7 @@ public function getLabel(): string
{
$instances = $this->fieldInstances();
if (empty($instances)) {
- throw new InvalidConfigException('No field instances for this condition rule.');
+ throw new RuntimeException('No field instances for this condition rule.');
}
return $instances[0]->layoutElement->label();
@@ -185,7 +185,7 @@ public function getExclusiveQueryParams(): array
{
try {
$instances = $this->fieldInstances();
- } catch (InvalidConfigException) {
+ } catch (RuntimeException) {
return [];
}
@@ -219,7 +219,7 @@ public function matchElement(ElementInterface $element): bool
{
try {
$fieldInstances = $this->fieldInstances();
- } catch (InvalidConfigException) {
+ } catch (RuntimeException) {
// The field doesn't exist
return true;
}
diff --git a/src/Field/Conditions/GeneratedFieldConditionRule.php b/src/Field/Conditions/GeneratedFieldConditionRule.php
index 6cdd8b13b63..ce39518a2de 100644
--- a/src/Field/Conditions/GeneratedFieldConditionRule.php
+++ b/src/Field/Conditions/GeneratedFieldConditionRule.php
@@ -4,13 +4,13 @@
namespace CraftCms\Cms\Field\Conditions;
-use craft\base\ElementInterface;
use CraftCms\Cms\Condition\BaseTextConditionRule;
use CraftCms\Cms\Database\Expressions\JsonExtract;
use CraftCms\Cms\Element\Conditions\Contracts\ElementConditionInterface;
use CraftCms\Cms\Element\Conditions\Contracts\ElementConditionRuleInterface;
+use CraftCms\Cms\Element\Contracts\ElementInterface;
use CraftCms\Cms\Element\Queries\Contracts\ElementQueryInterface;
-use yii\base\InvalidConfigException;
+use RuntimeException;
use yii\db\Schema;
use function CraftCms\Cms\t;
@@ -41,7 +41,7 @@ public function getLabel(): string
{
$field = $this->getFieldConfig();
if (! $field) {
- throw new InvalidConfigException("Invalid generated field UUID: $this->fieldUid");
+ throw new RuntimeException("Invalid generated field UUID: $this->fieldUid");
}
return $field['name'];
diff --git a/src/Field/Conditions/LightswitchFieldConditionRule.php b/src/Field/Conditions/LightswitchFieldConditionRule.php
index 9a851ad3d32..7bb27644afe 100644
--- a/src/Field/Conditions/LightswitchFieldConditionRule.php
+++ b/src/Field/Conditions/LightswitchFieldConditionRule.php
@@ -7,7 +7,7 @@
use CraftCms\Cms\Condition\BaseLightswitchConditionRule;
use CraftCms\Cms\Field\Conditions\Contracts\FieldConditionRuleInterface;
use CraftCms\Cms\Field\Lightswitch;
-use yii\base\InvalidConfigException;
+use RuntimeException;
class LightswitchFieldConditionRule extends BaseLightswitchConditionRule implements FieldConditionRuleInterface
{
@@ -17,7 +17,7 @@ class LightswitchFieldConditionRule extends BaseLightswitchConditionRule impleme
protected function inputHtml(): string
{
if (! $this->field() instanceof Lightswitch) {
- throw new InvalidConfigException;
+ throw new RuntimeException;
}
return parent::inputHtml();
diff --git a/src/Field/Conditions/MoneyFieldConditionRule.php b/src/Field/Conditions/MoneyFieldConditionRule.php
index d56e171ef05..cf9860bc4ec 100644
--- a/src/Field/Conditions/MoneyFieldConditionRule.php
+++ b/src/Field/Conditions/MoneyFieldConditionRule.php
@@ -14,7 +14,7 @@
use Money\Currency;
use Money\Money as MoneyLibrary;
use Override;
-use yii\base\InvalidConfigException;
+use RuntimeException;
use function CraftCms\Cms\t;
@@ -41,7 +41,7 @@ public function setAttributes($values, $safeOnly = true): void
$field = $this->field();
if (! $field instanceof Money) {
- throw new InvalidConfigException;
+ throw new RuntimeException;
}
if (isset($value, $this->_fieldUid)) {
@@ -66,7 +66,7 @@ protected function inputHtml(): string
$field = $this->field();
if (! $field instanceof Money) {
- throw new InvalidConfigException;
+ throw new RuntimeException;
}
// don't show the value input if the condition checks for empty/notempty
diff --git a/src/Field/Conditions/OptionsFieldConditionRule.php b/src/Field/Conditions/OptionsFieldConditionRule.php
index 31945e09f07..28fd763629d 100644
--- a/src/Field/Conditions/OptionsFieldConditionRule.php
+++ b/src/Field/Conditions/OptionsFieldConditionRule.php
@@ -11,7 +11,7 @@
use CraftCms\Cms\Field\Data\OptionData;
use CraftCms\Cms\Field\Data\SingleOptionFieldData;
use Illuminate\Support\Collection;
-use yii\base\InvalidConfigException;
+use RuntimeException;
class OptionsFieldConditionRule extends BaseMultiSelectConditionRule implements FieldConditionRuleInterface
{
@@ -43,7 +43,7 @@ protected function options(): array
protected function inputHtml(): string
{
if (! $this->field() instanceof BaseOptionsField) {
- throw new InvalidConfigException;
+ throw new RuntimeException;
}
return parent::inputHtml();
diff --git a/src/Field/Conditions/RelationalFieldConditionRule.php b/src/Field/Conditions/RelationalFieldConditionRule.php
index cf77e7a7079..eb8402d88cf 100644
--- a/src/Field/Conditions/RelationalFieldConditionRule.php
+++ b/src/Field/Conditions/RelationalFieldConditionRule.php
@@ -4,9 +4,9 @@
namespace CraftCms\Cms\Field\Conditions;
-use craft\base\ElementInterface;
use CraftCms\Cms\Condition\BaseElementSelectConditionRule;
use CraftCms\Cms\Element\Conditions\Contracts\ElementConditionInterface;
+use CraftCms\Cms\Element\Contracts\ElementInterface;
use CraftCms\Cms\Element\ElementCollection;
use CraftCms\Cms\Element\Queries\Contracts\ElementQueryInterface;
use CraftCms\Cms\Field\BaseRelationField;
@@ -14,7 +14,7 @@
use CraftCms\Cms\FieldLayout\LayoutElements\BaseField;
use CraftCms\Cms\FieldLayout\LayoutElements\CustomField;
use Illuminate\Database\Query\Builder;
-use yii\base\InvalidConfigException;
+use RuntimeException;
use function CraftCms\Cms\t;
@@ -93,7 +93,7 @@ protected function operatorLabel(string $operator): string
protected function inputHtml(): string
{
if (! $this->field() instanceof BaseRelationField) {
- throw new InvalidConfigException;
+ throw new RuntimeException;
}
return match ($this->operator) {
diff --git a/src/Field/ContentBlock.php b/src/Field/ContentBlock.php
index 8b799244bca..241e04e8d0a 100644
--- a/src/Field/ContentBlock.php
+++ b/src/Field/ContentBlock.php
@@ -4,8 +4,8 @@
namespace CraftCms\Cms\Field;
-use craft\base\ElementInterface;
-use craft\base\NestedElementInterface;
+use CraftCms\Cms\Element\Contracts\ElementInterface;
+use CraftCms\Cms\Element\Contracts\NestedElementInterface;
use CraftCms\Cms\Element\Data\EagerLoadPlan;
use CraftCms\Cms\Element\Drafts;
use CraftCms\Cms\Element\Element;
@@ -42,7 +42,7 @@
use Illuminate\Validation\Validator;
use InvalidArgumentException;
use Override;
-use yii\base\InvalidConfigException;
+use RuntimeException;
use function CraftCms\Cms\craftAsset;
use function CraftCms\Cms\t;
@@ -261,7 +261,7 @@ public function getSupportedSitesForElement(NestedElementInterface $element): ar
{
try {
$owner = $element->getOwner();
- } catch (InvalidConfigException) {
+ } catch (RuntimeException) {
$owner = $element->duplicateOf;
}
diff --git a/src/Field/Contracts/CrossSiteCopyableFieldInterface.php b/src/Field/Contracts/CrossSiteCopyableFieldInterface.php
index d30b56bc53e..7676c92f672 100644
--- a/src/Field/Contracts/CrossSiteCopyableFieldInterface.php
+++ b/src/Field/Contracts/CrossSiteCopyableFieldInterface.php
@@ -4,7 +4,7 @@
namespace CraftCms\Cms\Field\Contracts;
-use craft\base\ElementInterface;
+use CraftCms\Cms\Element\Contracts\ElementInterface;
/**
* CrossSiteCopyableFieldInterface defines the common interface to be implemented by field classes
diff --git a/src/Field/Contracts/EagerLoadingFieldInterface.php b/src/Field/Contracts/EagerLoadingFieldInterface.php
index a53bd3c39dd..646d86535ef 100644
--- a/src/Field/Contracts/EagerLoadingFieldInterface.php
+++ b/src/Field/Contracts/EagerLoadingFieldInterface.php
@@ -4,7 +4,7 @@
namespace CraftCms\Cms\Field\Contracts;
-use craft\base\ElementInterface;
+use CraftCms\Cms\Element\Contracts\ElementInterface;
/**
* EagerLoadingFieldInterface defines the common interface to be implemented by field classes that support eager-loading.
diff --git a/src/Field/Contracts/ElementContainerFieldInterface.php b/src/Field/Contracts/ElementContainerFieldInterface.php
index 731b2dd3387..d490c0e63d1 100644
--- a/src/Field/Contracts/ElementContainerFieldInterface.php
+++ b/src/Field/Contracts/ElementContainerFieldInterface.php
@@ -4,7 +4,7 @@
namespace CraftCms\Cms\Field\Contracts;
-use craft\base\NestedElementInterface;
+use CraftCms\Cms\Element\Contracts\NestedElementInterface;
use CraftCms\Cms\FieldLayout\Contracts\FieldLayoutProviderInterface;
use CraftCms\Cms\User\Elements\User;
diff --git a/src/Field/Contracts/FieldInterface.php b/src/Field/Contracts/FieldInterface.php
index c45b6a6ac5a..511b8a12ab3 100644
--- a/src/Field/Contracts/FieldInterface.php
+++ b/src/Field/Contracts/FieldInterface.php
@@ -4,13 +4,13 @@
namespace CraftCms\Cms\Field\Contracts;
-use craft\base\ElementInterface;
use CraftCms\Cms\Component\Concerns\SavableComponent;
use CraftCms\Cms\Component\Contracts\Chippable;
use CraftCms\Cms\Component\Contracts\ConfigurableComponentInterface;
use CraftCms\Cms\Component\Contracts\CpEditable;
use CraftCms\Cms\Component\Contracts\Grippable;
use CraftCms\Cms\Component\Contracts\SavableComponentInterface;
+use CraftCms\Cms\Element\Contracts\ElementInterface;
use CraftCms\Cms\Element\Enums\AttributeStatus;
use CraftCms\Cms\Element\Queries\Contracts\ElementQueryInterface;
use CraftCms\Cms\Field\Enums\TranslationMethod;
diff --git a/src/Field/Contracts/InlineEditableFieldInterface.php b/src/Field/Contracts/InlineEditableFieldInterface.php
index bc746cc51a1..510bee443fd 100644
--- a/src/Field/Contracts/InlineEditableFieldInterface.php
+++ b/src/Field/Contracts/InlineEditableFieldInterface.php
@@ -4,7 +4,7 @@
namespace CraftCms\Cms\Field\Contracts;
-use craft\base\ElementInterface;
+use CraftCms\Cms\Element\Contracts\ElementInterface;
/**
* InlineEditableFieldInterface defines the common interface to be implemented by field classes
diff --git a/src/Field/Contracts/PreviewableFieldInterface.php b/src/Field/Contracts/PreviewableFieldInterface.php
index 1c50d34469b..11fd7a7841a 100644
--- a/src/Field/Contracts/PreviewableFieldInterface.php
+++ b/src/Field/Contracts/PreviewableFieldInterface.php
@@ -4,7 +4,7 @@
namespace CraftCms\Cms\Field\Contracts;
-use craft\base\ElementInterface;
+use CraftCms\Cms\Element\Contracts\ElementInterface;
/**
* PreviewableFieldInterface defines the common interface to be implemented by field classes
diff --git a/src/Field/Contracts/RelationalFieldInterface.php b/src/Field/Contracts/RelationalFieldInterface.php
index 285a776c676..d070284bf3c 100644
--- a/src/Field/Contracts/RelationalFieldInterface.php
+++ b/src/Field/Contracts/RelationalFieldInterface.php
@@ -4,7 +4,7 @@
namespace CraftCms\Cms\Field\Contracts;
-use craft\base\ElementInterface;
+use CraftCms\Cms\Element\Contracts\ElementInterface;
/**
* RelationalFieldInterface defines the common interface to be implemented by field classes
diff --git a/src/Field/Contracts/ThumbableFieldInterface.php b/src/Field/Contracts/ThumbableFieldInterface.php
index c44a6f1599a..07a713bba56 100644
--- a/src/Field/Contracts/ThumbableFieldInterface.php
+++ b/src/Field/Contracts/ThumbableFieldInterface.php
@@ -4,7 +4,7 @@
namespace CraftCms\Cms\Field\Contracts;
-use craft\base\ElementInterface;
+use CraftCms\Cms\Element\Contracts\ElementInterface;
/**
* ThumbableFieldInterface defines the common interface to be implemented by field classes
diff --git a/src/Field/Country.php b/src/Field/Country.php
index 8a817aef364..5d6d674885e 100644
--- a/src/Field/Country.php
+++ b/src/Field/Country.php
@@ -6,9 +6,9 @@
use CommerceGuys\Addressing\Country\Country as CountryModel;
use CommerceGuys\Addressing\Exception\UnknownCountryException;
-use craft\base\ElementInterface;
use CraftCms\Cms\Address\Addresses;
use CraftCms\Cms\Cp\FormFields;
+use CraftCms\Cms\Element\Contracts\ElementInterface;
use CraftCms\Cms\Field\Conditions\CountryFieldConditionRule;
use CraftCms\Cms\Field\Contracts\CrossSiteCopyableFieldInterface;
use CraftCms\Cms\Field\Contracts\InlineEditableFieldInterface;
diff --git a/src/Field/Data/JsonData.php b/src/Field/Data/JsonData.php
index 75f07265235..43162c55f15 100644
--- a/src/Field/Data/JsonData.php
+++ b/src/Field/Data/JsonData.php
@@ -13,11 +13,12 @@
use CraftCms\Cms\Support\Json;
use CraftCms\Cms\Twig\Attributes\AllowedInSandbox;
use IteratorAggregate;
+use Override;
use Stringable;
use Traversable;
#[AllowedInSandbox]
-class JsonData extends Component implements ArrayAccess, IteratorAggregate, Serializable, Stringable
+class JsonData extends Component implements IteratorAggregate, Serializable, Stringable
{
public function __construct(
private mixed $value,
@@ -31,7 +32,7 @@ public function __toString(): string
return $this->getJson();
}
- #[\Override]
+ #[Override]
public function __call($method, $parameters)
{
try {
@@ -81,16 +82,19 @@ public function getJson(bool $pretty = false, string $indent = ' '): string
return $json;
}
+ #[Override]
public function offsetGet(mixed $offset): mixed
{
return $this->value[$offset];
}
+ #[Override]
public function offsetSet(mixed $offset, mixed $value): void
{
$this->value[$offset] = $value;
}
+ #[Override]
public function offsetExists(mixed $offset): bool
{
if (is_string($this->value)) {
@@ -108,6 +112,7 @@ public function offsetExists(mixed $offset): bool
return false;
}
+ #[Override]
public function offsetUnset(mixed $offset): void
{
unset($this->value[$offset]);
diff --git a/src/Field/Data/LinkData.php b/src/Field/Data/LinkData.php
index 398c7903602..5bcaee9d894 100644
--- a/src/Field/Data/LinkData.php
+++ b/src/Field/Data/LinkData.php
@@ -4,8 +4,8 @@
namespace CraftCms\Cms\Field\Data;
-use craft\base\ElementInterface;
use craft\base\Serializable;
+use CraftCms\Cms\Element\Contracts\ElementInterface;
use CraftCms\Cms\Element\Queries\Contracts\ElementQueryInterface;
use CraftCms\Cms\Field\LinkTypes\BaseElementLinkType;
use CraftCms\Cms\Field\LinkTypes\BaseLinkType;
diff --git a/src/Field/Date.php b/src/Field/Date.php
index e4551428e7f..b3abf630b67 100644
--- a/src/Field/Date.php
+++ b/src/Field/Date.php
@@ -4,8 +4,8 @@
namespace CraftCms\Cms\Field;
-use craft\base\ElementInterface;
use CraftCms\Cms\Cms;
+use CraftCms\Cms\Element\Contracts\ElementInterface;
use CraftCms\Cms\Entry\Elements\Entry;
use CraftCms\Cms\Field\Conditions\DateFieldConditionRule;
use CraftCms\Cms\Field\Contracts\CrossSiteCopyableFieldInterface;
diff --git a/src/Field/Dropdown.php b/src/Field/Dropdown.php
index bc0b6fd8167..3fa93b6779f 100644
--- a/src/Field/Dropdown.php
+++ b/src/Field/Dropdown.php
@@ -4,8 +4,8 @@
namespace CraftCms\Cms\Field;
-use craft\base\ElementInterface;
use CraftCms\Cms\Cp\FormFields;
+use CraftCms\Cms\Element\Contracts\ElementInterface;
use CraftCms\Cms\Element\Enums\AttributeStatus;
use CraftCms\Cms\Field\Contracts\InlineEditableFieldInterface;
use CraftCms\Cms\Field\Contracts\SortableFieldInterface;
diff --git a/src/Field/Elements/ContentBlock.php b/src/Field/Elements/ContentBlock.php
index 459a3627dd2..68f0005f2c3 100644
--- a/src/Field/Elements/ContentBlock.php
+++ b/src/Field/Elements/ContentBlock.php
@@ -4,9 +4,9 @@
namespace CraftCms\Cms\Field\Elements;
-use craft\base\NestedElementInterface;
use craft\base\NestedElementTrait;
use CraftCms\Cms\Database\Table;
+use CraftCms\Cms\Element\Contracts\NestedElementInterface;
use CraftCms\Cms\Element\Element;
use CraftCms\Cms\Element\Queries\ContentBlockQuery;
use CraftCms\Cms\Field\ContentBlock as ContentBlockField;
diff --git a/src/Field/Email.php b/src/Field/Email.php
index c5678f649df..0994daf0e4f 100644
--- a/src/Field/Email.php
+++ b/src/Field/Email.php
@@ -4,8 +4,8 @@
namespace CraftCms\Cms\Field;
-use craft\base\ElementInterface;
use CraftCms\Cms\Cp\FormFields;
+use CraftCms\Cms\Element\Contracts\ElementInterface;
use CraftCms\Cms\Entry\Elements\Entry;
use CraftCms\Cms\Field\Conditions\TextFieldConditionRule;
use CraftCms\Cms\Field\Contracts\CrossSiteCopyableFieldInterface;
diff --git a/src/Field/Entries.php b/src/Field/Entries.php
index 1ac2d6ab08e..940eaa8cfdc 100644
--- a/src/Field/Entries.php
+++ b/src/Field/Entries.php
@@ -4,9 +4,9 @@
namespace CraftCms\Cms\Field;
-use craft\base\ElementInterface;
use CraftCms\Cms\Cp\Html\ElementHtml;
use CraftCms\Cms\Element\Conditions\ElementCondition;
+use CraftCms\Cms\Element\Contracts\ElementInterface;
use CraftCms\Cms\Element\ElementCollection;
use CraftCms\Cms\Element\Queries\Contracts\ElementQueryInterface;
use CraftCms\Cms\Element\Queries\EntryQuery;
diff --git a/src/Field/Enums/ElementIndexViewMode.php b/src/Field/Enums/ElementIndexViewMode.php
deleted file mode 100644
index 90957ae2f38..00000000000
--- a/src/Field/Enums/ElementIndexViewMode.php
+++ /dev/null
@@ -1,16 +0,0 @@
-entryTypes)) {
- throw new InvalidConfigException('At least one entry type is required.');
+ throw new RuntimeException('At least one entry type is required.');
}
return array_values($event->entryTypes);
@@ -456,7 +456,7 @@ public function getSupportedSitesForElement(NestedElementInterface $element): ar
{
try {
$owner = $element->getOwner();
- } catch (InvalidConfigException) {
+ } catch (RuntimeException) {
$owner = $element->duplicateOf;
}
@@ -1031,7 +1031,7 @@ public function getTranslationDescription(?ElementInterface $element): ?string
}
/**
- * @throws InvalidConfigException
+ * @throws RuntimeException
*/
#[Override]
protected function inputHtml(mixed $value, ?ElementInterface $element, bool $inline): string
diff --git a/src/Field/MissingField.php b/src/Field/MissingField.php
index 9b3f4862374..aac9729fbda 100644
--- a/src/Field/MissingField.php
+++ b/src/Field/MissingField.php
@@ -4,9 +4,9 @@
namespace CraftCms\Cms\Field;
-use craft\base\ElementInterface;
use CraftCms\Cms\Component\Concerns\MissingComponentTrait;
use CraftCms\Cms\Component\Contracts\MissingComponentInterface;
+use CraftCms\Cms\Element\Contracts\ElementInterface;
use CraftCms\Cms\Field\Contracts\FieldInterface;
use Override;
diff --git a/src/Field/Money.php b/src/Field/Money.php
index 1839dcd162e..79f610a57ae 100644
--- a/src/Field/Money.php
+++ b/src/Field/Money.php
@@ -4,8 +4,8 @@
namespace CraftCms\Cms\Field;
-use craft\base\ElementInterface;
use CraftCms\Cms\Cp\FormFields;
+use CraftCms\Cms\Element\Contracts\ElementInterface;
use CraftCms\Cms\Entry\Elements\Entry;
use CraftCms\Cms\Field\Conditions\MoneyFieldConditionRule;
use CraftCms\Cms\Field\Contracts\CrossSiteCopyableFieldInterface;
diff --git a/src/Field/MultiSelect.php b/src/Field/MultiSelect.php
index f8faa2b2a84..14b0051baa5 100644
--- a/src/Field/MultiSelect.php
+++ b/src/Field/MultiSelect.php
@@ -4,8 +4,8 @@
namespace CraftCms\Cms\Field;
-use craft\base\ElementInterface;
use CraftCms\Cms\Cp\FormFields;
+use CraftCms\Cms\Element\Contracts\ElementInterface;
use CraftCms\Cms\Field\Data\MultiOptionsFieldData;
use CraftCms\Cms\Support\Facades\DeltaRegistry;
use Illuminate\Support\Collection;
diff --git a/src/Field/Number.php b/src/Field/Number.php
index 7751c1ee3a6..86677028cd8 100644
--- a/src/Field/Number.php
+++ b/src/Field/Number.php
@@ -4,7 +4,7 @@
namespace CraftCms\Cms\Field;
-use craft\base\ElementInterface;
+use CraftCms\Cms\Element\Contracts\ElementInterface;
use CraftCms\Cms\Entry\Elements\Entry;
use CraftCms\Cms\Field\Conditions\NumberFieldConditionRule;
use CraftCms\Cms\Field\Contracts\CrossSiteCopyableFieldInterface;
diff --git a/src/Field/PlainText.php b/src/Field/PlainText.php
index 502084063bc..27b7bd1dad5 100644
--- a/src/Field/PlainText.php
+++ b/src/Field/PlainText.php
@@ -5,7 +5,7 @@
namespace CraftCms\Cms\Field;
use Craft;
-use craft\base\ElementInterface;
+use CraftCms\Cms\Element\Contracts\ElementInterface;
use CraftCms\Cms\Entry\Elements\Entry;
use CraftCms\Cms\Field\Conditions\TextFieldConditionRule;
use CraftCms\Cms\Field\Contracts\CrossSiteCopyableFieldInterface;
diff --git a/src/Field/Policies/ContentBlockPolicy.php b/src/Field/Policies/ContentBlockPolicy.php
index 852662043da..1d40f6a4ae1 100644
--- a/src/Field/Policies/ContentBlockPolicy.php
+++ b/src/Field/Policies/ContentBlockPolicy.php
@@ -4,7 +4,7 @@
namespace CraftCms\Cms\Field\Policies;
-use craft\base\ElementInterface;
+use CraftCms\Cms\Element\Contracts\ElementInterface;
use CraftCms\Cms\Element\Policies\ElementPolicy;
use CraftCms\Cms\Field\Elements\ContentBlock;
use CraftCms\Cms\User\Elements\User;
diff --git a/src/Field/RadioButtons.php b/src/Field/RadioButtons.php
index d68375919a3..f180ff64a54 100644
--- a/src/Field/RadioButtons.php
+++ b/src/Field/RadioButtons.php
@@ -4,7 +4,7 @@
namespace CraftCms\Cms\Field;
-use craft\base\ElementInterface;
+use CraftCms\Cms\Element\Contracts\ElementInterface;
use CraftCms\Cms\Field\Contracts\SortableFieldInterface;
use CraftCms\Cms\Field\Data\SingleOptionFieldData;
use CraftCms\Cms\Support\Facades\DeltaRegistry;
diff --git a/src/Field/Range.php b/src/Field/Range.php
index dece219f3ad..ece05da8d1d 100644
--- a/src/Field/Range.php
+++ b/src/Field/Range.php
@@ -4,8 +4,8 @@
namespace CraftCms\Cms\Field;
-use craft\base\ElementInterface;
use CraftCms\Cms\Cp\FormFields;
+use CraftCms\Cms\Element\Contracts\ElementInterface;
use CraftCms\Cms\Entry\Elements\Entry;
use CraftCms\Cms\Field\Conditions\NumberFieldConditionRule;
use CraftCms\Cms\Field\Contracts\InlineEditableFieldInterface;
diff --git a/src/Field/Table.php b/src/Field/Table.php
index 5a161217273..86bb948aa09 100644
--- a/src/Field/Table.php
+++ b/src/Field/Table.php
@@ -6,8 +6,8 @@
use Closure;
use Craft;
-use craft\base\ElementInterface;
use CraftCms\Cms\Cp\FormFields;
+use CraftCms\Cms\Element\Contracts\ElementInterface;
use CraftCms\Cms\Field\Contracts\CrossSiteCopyableFieldInterface;
use CraftCms\Cms\Field\Data\ColorData;
use CraftCms\Cms\Gql\GqlEntityRegistry;
diff --git a/src/Field/Time.php b/src/Field/Time.php
index 43508fa04f9..5c50cc1150d 100644
--- a/src/Field/Time.php
+++ b/src/Field/Time.php
@@ -4,7 +4,7 @@
namespace CraftCms\Cms\Field;
-use craft\base\ElementInterface;
+use CraftCms\Cms\Element\Contracts\ElementInterface;
use CraftCms\Cms\Entry\Elements\Entry;
use CraftCms\Cms\Field\Conditions\EmptyFieldConditionRule;
use CraftCms\Cms\Field\Contracts\CrossSiteCopyableFieldInterface;
diff --git a/src/FieldLayout/Concerns/HasFieldLayout.php b/src/FieldLayout/Concerns/HasFieldLayout.php
index b2c1765153d..df65d2446fa 100644
--- a/src/FieldLayout/Concerns/HasFieldLayout.php
+++ b/src/FieldLayout/Concerns/HasFieldLayout.php
@@ -4,7 +4,7 @@
namespace CraftCms\Cms\FieldLayout\Concerns;
-use craft\base\ElementInterface;
+use CraftCms\Cms\Element\Contracts\ElementInterface;
use CraftCms\Cms\Entry\Data\EntryType;
use CraftCms\Cms\Field\Contracts\FieldInterface;
use CraftCms\Cms\FieldLayout\Contracts\FieldLayoutProviderInterface;
diff --git a/src/FieldLayout/Events/CreateFieldLayoutForm.php b/src/FieldLayout/Events/CreateFieldLayoutForm.php
index 8190b53a66e..ea7185b49d3 100644
--- a/src/FieldLayout/Events/CreateFieldLayoutForm.php
+++ b/src/FieldLayout/Events/CreateFieldLayoutForm.php
@@ -4,7 +4,7 @@
namespace CraftCms\Cms\FieldLayout\Events;
-use craft\base\ElementInterface;
+use CraftCms\Cms\Element\Contracts\ElementInterface;
use CraftCms\Cms\FieldLayout\FieldLayout;
use CraftCms\Cms\FieldLayout\FieldLayoutForm;
use CraftCms\Cms\FieldLayout\FieldLayoutTab;
diff --git a/src/FieldLayout/Events/DefineActionMenuItems.php b/src/FieldLayout/Events/DefineActionMenuItems.php
index 164bc9cb4a1..56501437a75 100644
--- a/src/FieldLayout/Events/DefineActionMenuItems.php
+++ b/src/FieldLayout/Events/DefineActionMenuItems.php
@@ -4,7 +4,7 @@
namespace CraftCms\Cms\FieldLayout\Events;
-use craft\base\ElementInterface;
+use CraftCms\Cms\Element\Contracts\ElementInterface;
class DefineActionMenuItems extends \CraftCms\Cms\Element\Events\DefineActionMenuItems
{
diff --git a/src/FieldLayout/Events/DefineShowInForm.php b/src/FieldLayout/Events/DefineShowInForm.php
index 2a3ad9119f5..adb2ddbe2b3 100644
--- a/src/FieldLayout/Events/DefineShowInForm.php
+++ b/src/FieldLayout/Events/DefineShowInForm.php
@@ -4,7 +4,7 @@
namespace CraftCms\Cms\FieldLayout\Events;
-use craft\base\ElementInterface;
+use CraftCms\Cms\Element\Contracts\ElementInterface;
use CraftCms\Cms\FieldLayout\FieldLayout;
use CraftCms\Cms\FieldLayout\FieldLayoutComponent;
use CraftCms\Cms\Shared\Concerns\HandleableEvent;
diff --git a/src/FieldLayout/FieldLayout.php b/src/FieldLayout/FieldLayout.php
index dcc1ed4cddc..a319c7164a3 100644
--- a/src/FieldLayout/FieldLayout.php
+++ b/src/FieldLayout/FieldLayout.php
@@ -5,8 +5,8 @@
namespace CraftCms\Cms\FieldLayout;
use Closure;
-use craft\base\ElementInterface;
use CraftCms\Cms\Component\Component;
+use CraftCms\Cms\Element\Contracts\ElementInterface;
use CraftCms\Cms\Field\ContentBlock;
use CraftCms\Cms\Field\Contracts\FieldInterface;
use CraftCms\Cms\Field\Contracts\PreviewableFieldInterface;
@@ -39,7 +39,7 @@
use Illuminate\Support\Facades\Validator;
use InvalidArgumentException;
use Override;
-use yii\base\InvalidConfigException;
+use RuntimeException;
use function CraftCms\Cms\t;
@@ -457,7 +457,7 @@ public function getAvailableNativeFields(): array
};
if (! $field instanceof BaseField) {
- throw new InvalidConfigException('Invalid standard field config');
+ throw new RuntimeException('Invalid standard field config');
}
$field->setLayout($this);
@@ -498,7 +498,7 @@ public function getAvailableUiElements(): array
};
if (! $element instanceof FieldLayoutElement) {
- throw new InvalidConfigException('Invalid UI element config');
+ throw new RuntimeException('Invalid UI element config');
}
}
diff --git a/src/FieldLayout/FieldLayoutComponent.php b/src/FieldLayout/FieldLayoutComponent.php
index fd87a0c92d2..428bb916e99 100644
--- a/src/FieldLayout/FieldLayoutComponent.php
+++ b/src/FieldLayout/FieldLayoutComponent.php
@@ -4,11 +4,11 @@
namespace CraftCms\Cms\FieldLayout;
-use craft\base\ElementInterface;
use CraftCms\Cms\Component\Component;
use CraftCms\Cms\Condition\Contracts\ConditionInterface;
use CraftCms\Cms\Cp\FormFields;
use CraftCms\Cms\Element\Conditions\Contracts\ElementConditionInterface;
+use CraftCms\Cms\Element\Contracts\ElementInterface;
use CraftCms\Cms\FieldLayout\Events\DefineShowInForm;
use CraftCms\Cms\Support\Facades\Conditions;
use CraftCms\Cms\Support\Html;
diff --git a/src/FieldLayout/FieldLayoutElement.php b/src/FieldLayout/FieldLayoutElement.php
index c366157be13..0070f98683c 100644
--- a/src/FieldLayout/FieldLayoutElement.php
+++ b/src/FieldLayout/FieldLayoutElement.php
@@ -4,7 +4,7 @@
namespace CraftCms\Cms\FieldLayout;
-use craft\base\ElementInterface;
+use CraftCms\Cms\Element\Contracts\ElementInterface;
use DateTime;
use Override;
diff --git a/src/FieldLayout/FieldLayoutServiceProvider.php b/src/FieldLayout/FieldLayoutServiceProvider.php
index 88abb30e450..d346f9d7d99 100644
--- a/src/FieldLayout/FieldLayoutServiceProvider.php
+++ b/src/FieldLayout/FieldLayoutServiceProvider.php
@@ -9,21 +9,21 @@
use CraftCms\Cms\Cms;
use CraftCms\Cms\Entry\Elements\Entry;
use CraftCms\Cms\FieldLayout\Events\DefineNativeFields;
-use CraftCms\Cms\FieldLayout\LayoutElements\addresses\AddressField;
-use CraftCms\Cms\FieldLayout\LayoutElements\addresses\CountryCodeField;
-use CraftCms\Cms\FieldLayout\LayoutElements\addresses\LabelField;
-use CraftCms\Cms\FieldLayout\LayoutElements\addresses\LatLongField;
-use CraftCms\Cms\FieldLayout\LayoutElements\addresses\OrganizationField;
-use CraftCms\Cms\FieldLayout\LayoutElements\addresses\OrganizationTaxIdField;
-use CraftCms\Cms\FieldLayout\LayoutElements\assets\AltField;
-use CraftCms\Cms\FieldLayout\LayoutElements\assets\AssetTitleField;
-use CraftCms\Cms\FieldLayout\LayoutElements\entries\EntryTitleField;
+use CraftCms\Cms\FieldLayout\LayoutElements\Addresses\AddressField;
+use CraftCms\Cms\FieldLayout\LayoutElements\Addresses\CountryCodeField;
+use CraftCms\Cms\FieldLayout\LayoutElements\Addresses\LabelField;
+use CraftCms\Cms\FieldLayout\LayoutElements\Addresses\LatLongField;
+use CraftCms\Cms\FieldLayout\LayoutElements\Addresses\OrganizationField;
+use CraftCms\Cms\FieldLayout\LayoutElements\Addresses\OrganizationTaxIdField;
+use CraftCms\Cms\FieldLayout\LayoutElements\Assets\AltField;
+use CraftCms\Cms\FieldLayout\LayoutElements\Assets\AssetTitleField;
+use CraftCms\Cms\FieldLayout\LayoutElements\Entries\EntryTitleField;
use CraftCms\Cms\FieldLayout\LayoutElements\FullNameField;
-use CraftCms\Cms\FieldLayout\LayoutElements\users\AffiliatedSiteField;
-use CraftCms\Cms\FieldLayout\LayoutElements\users\EmailField;
-use CraftCms\Cms\FieldLayout\LayoutElements\users\FullNameField as UserFullNameField;
-use CraftCms\Cms\FieldLayout\LayoutElements\users\PhotoField;
-use CraftCms\Cms\FieldLayout\LayoutElements\users\UsernameField;
+use CraftCms\Cms\FieldLayout\LayoutElements\Users\AffiliatedSiteField;
+use CraftCms\Cms\FieldLayout\LayoutElements\Users\EmailField;
+use CraftCms\Cms\FieldLayout\LayoutElements\Users\FullNameField as UserFullNameField;
+use CraftCms\Cms\FieldLayout\LayoutElements\Users\PhotoField;
+use CraftCms\Cms\FieldLayout\LayoutElements\Users\UsernameField;
use CraftCms\Cms\Site\Sites;
use CraftCms\Cms\User\Elements\User;
use Illuminate\Support\Facades\Event;
diff --git a/src/FieldLayout/FieldLayoutTab.php b/src/FieldLayout/FieldLayoutTab.php
index 76a96d3d50a..576ae972b08 100644
--- a/src/FieldLayout/FieldLayoutTab.php
+++ b/src/FieldLayout/FieldLayoutTab.php
@@ -4,9 +4,9 @@
namespace CraftCms\Cms\FieldLayout;
-use craft\base\ElementInterface;
use CraftCms\Cms\Cp\FormFields;
use CraftCms\Cms\Cp\Icons;
+use CraftCms\Cms\Element\Contracts\ElementInterface;
use CraftCms\Cms\Field\Exceptions\FieldNotFoundException;
use CraftCms\Cms\Field\Fields;
use CraftCms\Cms\FieldLayout\LayoutElements\BaseField;
@@ -19,7 +19,7 @@
use Illuminate\Support\Facades\Log;
use InvalidArgumentException;
use Override;
-use yii\base\InvalidConfigException;
+use RuntimeException;
use function CraftCms\Cms\t;
@@ -205,7 +205,7 @@ public function getElementConfigs(): array
*
* @return FieldLayout The tab’s layout.
*
- * @throws InvalidConfigException if [[layoutId]] is set but invalid
+ * @throws RuntimeException if [[layoutId]] is set but invalid
*/
#[Override]
public function getLayout(): FieldLayout
@@ -215,11 +215,11 @@ public function getLayout(): FieldLayout
}
if (! $this->layoutId) {
- throw new InvalidConfigException('Field layout tab is missing its field layout.');
+ throw new RuntimeException('Field layout tab is missing its field layout.');
}
if (($this->_layout = app(Fields::class)->getLayoutById($this->layoutId)) === null) {
- throw new InvalidConfigException('Invalid layout ID: '.$this->layoutId);
+ throw new RuntimeException('Invalid layout ID: '.$this->layoutId);
}
return $this->_layout;
diff --git a/src/FieldLayout/LayoutElements/addresses/AddressField.php b/src/FieldLayout/LayoutElements/Addresses/AddressField.php
similarity index 98%
rename from src/FieldLayout/LayoutElements/addresses/AddressField.php
rename to src/FieldLayout/LayoutElements/Addresses/AddressField.php
index 7ec868b1bc8..b875900cfa9 100644
--- a/src/FieldLayout/LayoutElements/addresses/AddressField.php
+++ b/src/FieldLayout/LayoutElements/Addresses/AddressField.php
@@ -2,12 +2,12 @@
declare(strict_types=1);
-namespace CraftCms\Cms\FieldLayout\LayoutElements\addresses;
+namespace CraftCms\Cms\FieldLayout\LayoutElements\Addresses;
-use craft\base\ElementInterface;
use CraftCms\Cms\Address\Addresses;
use CraftCms\Cms\Address\Elements\Address;
use CraftCms\Cms\Cp\FormFields;
+use CraftCms\Cms\Element\Contracts\ElementInterface;
use CraftCms\Cms\FieldLayout\LayoutElements\BaseField;
use CraftCms\Cms\Support\Facades\HtmlStack;
use CraftCms\Cms\Support\Facades\InputNamespace;
diff --git a/src/FieldLayout/LayoutElements/addresses/CountryCodeField.php b/src/FieldLayout/LayoutElements/Addresses/CountryCodeField.php
similarity index 96%
rename from src/FieldLayout/LayoutElements/addresses/CountryCodeField.php
rename to src/FieldLayout/LayoutElements/Addresses/CountryCodeField.php
index 1a95070864f..919d41c455b 100644
--- a/src/FieldLayout/LayoutElements/addresses/CountryCodeField.php
+++ b/src/FieldLayout/LayoutElements/Addresses/CountryCodeField.php
@@ -2,13 +2,13 @@
declare(strict_types=1);
-namespace CraftCms\Cms\FieldLayout\LayoutElements\addresses;
+namespace CraftCms\Cms\FieldLayout\LayoutElements\Addresses;
use CommerceGuys\Addressing\Country\Country;
-use craft\base\ElementInterface;
use CraftCms\Cms\Address\Addresses;
use CraftCms\Cms\Address\Elements\Address;
use CraftCms\Cms\Cp\FormFields;
+use CraftCms\Cms\Element\Contracts\ElementInterface;
use CraftCms\Cms\FieldLayout\LayoutElements\BaseNativeField;
use CraftCms\Cms\Support\Arr;
use CraftCms\Cms\Support\Html;
diff --git a/src/FieldLayout/LayoutElements/addresses/LabelField.php b/src/FieldLayout/LayoutElements/Addresses/LabelField.php
similarity index 88%
rename from src/FieldLayout/LayoutElements/addresses/LabelField.php
rename to src/FieldLayout/LayoutElements/Addresses/LabelField.php
index 6583078f728..acafe0cb085 100644
--- a/src/FieldLayout/LayoutElements/addresses/LabelField.php
+++ b/src/FieldLayout/LayoutElements/Addresses/LabelField.php
@@ -2,9 +2,9 @@
declare(strict_types=1);
-namespace CraftCms\Cms\FieldLayout\LayoutElements\addresses;
+namespace CraftCms\Cms\FieldLayout\LayoutElements\Addresses;
-use craft\base\ElementInterface;
+use CraftCms\Cms\Element\Contracts\ElementInterface;
use CraftCms\Cms\FieldLayout\LayoutElements\TitleField;
use CraftCms\Cms\Support\Arr;
use Override;
diff --git a/src/FieldLayout/LayoutElements/addresses/LatLongField.php b/src/FieldLayout/LayoutElements/Addresses/LatLongField.php
similarity index 97%
rename from src/FieldLayout/LayoutElements/addresses/LatLongField.php
rename to src/FieldLayout/LayoutElements/Addresses/LatLongField.php
index 70fd93b9b10..76ed8a19461 100644
--- a/src/FieldLayout/LayoutElements/addresses/LatLongField.php
+++ b/src/FieldLayout/LayoutElements/Addresses/LatLongField.php
@@ -2,11 +2,11 @@
declare(strict_types=1);
-namespace CraftCms\Cms\FieldLayout\LayoutElements\addresses;
+namespace CraftCms\Cms\FieldLayout\LayoutElements\Addresses;
-use craft\base\ElementInterface;
use CraftCms\Cms\Address\Elements\Address;
use CraftCms\Cms\Cp\FormFields;
+use CraftCms\Cms\Element\Contracts\ElementInterface;
use CraftCms\Cms\FieldLayout\LayoutElements\BaseNativeField;
use CraftCms\Cms\Support\Arr;
use CraftCms\Cms\Support\Html;
diff --git a/src/FieldLayout/LayoutElements/addresses/OrganizationField.php b/src/FieldLayout/LayoutElements/Addresses/OrganizationField.php
similarity index 89%
rename from src/FieldLayout/LayoutElements/addresses/OrganizationField.php
rename to src/FieldLayout/LayoutElements/Addresses/OrganizationField.php
index 2e7464b5592..20ff65ddc1f 100644
--- a/src/FieldLayout/LayoutElements/addresses/OrganizationField.php
+++ b/src/FieldLayout/LayoutElements/Addresses/OrganizationField.php
@@ -2,9 +2,9 @@
declare(strict_types=1);
-namespace CraftCms\Cms\FieldLayout\LayoutElements\addresses;
+namespace CraftCms\Cms\FieldLayout\LayoutElements\Addresses;
-use craft\base\ElementInterface;
+use CraftCms\Cms\Element\Contracts\ElementInterface;
use CraftCms\Cms\FieldLayout\LayoutElements\TextField;
use CraftCms\Cms\Support\Arr;
use Override;
diff --git a/src/FieldLayout/LayoutElements/addresses/OrganizationTaxIdField.php b/src/FieldLayout/LayoutElements/Addresses/OrganizationTaxIdField.php
similarity index 90%
rename from src/FieldLayout/LayoutElements/addresses/OrganizationTaxIdField.php
rename to src/FieldLayout/LayoutElements/Addresses/OrganizationTaxIdField.php
index cf3bdb800a5..963d0c0b779 100644
--- a/src/FieldLayout/LayoutElements/addresses/OrganizationTaxIdField.php
+++ b/src/FieldLayout/LayoutElements/Addresses/OrganizationTaxIdField.php
@@ -2,9 +2,9 @@
declare(strict_types=1);
-namespace CraftCms\Cms\FieldLayout\LayoutElements\addresses;
+namespace CraftCms\Cms\FieldLayout\LayoutElements\Addresses;
-use craft\base\ElementInterface;
+use CraftCms\Cms\Element\Contracts\ElementInterface;
use CraftCms\Cms\FieldLayout\LayoutElements\TextField;
use CraftCms\Cms\Support\Arr;
use Override;
diff --git a/src/FieldLayout/LayoutElements/assets/AltField.php b/src/FieldLayout/LayoutElements/Assets/AltField.php
similarity index 95%
rename from src/FieldLayout/LayoutElements/assets/AltField.php
rename to src/FieldLayout/LayoutElements/Assets/AltField.php
index 3c68e1533ba..cb52a8fb7a1 100644
--- a/src/FieldLayout/LayoutElements/assets/AltField.php
+++ b/src/FieldLayout/LayoutElements/Assets/AltField.php
@@ -2,10 +2,10 @@
declare(strict_types=1);
-namespace CraftCms\Cms\FieldLayout\LayoutElements\assets;
+namespace CraftCms\Cms\FieldLayout\LayoutElements\Assets;
-use craft\base\ElementInterface;
use CraftCms\Cms\Asset\Elements\Asset;
+use CraftCms\Cms\Element\Contracts\ElementInterface;
use CraftCms\Cms\Field\Enums\TranslationMethod;
use CraftCms\Cms\FieldLayout\LayoutElements\TextareaField;
use CraftCms\Cms\Support\Arr;
diff --git a/src/FieldLayout/LayoutElements/assets/AssetTitleField.php b/src/FieldLayout/LayoutElements/Assets/AssetTitleField.php
similarity index 90%
rename from src/FieldLayout/LayoutElements/assets/AssetTitleField.php
rename to src/FieldLayout/LayoutElements/Assets/AssetTitleField.php
index 0c96f44c607..7a9fd547445 100644
--- a/src/FieldLayout/LayoutElements/assets/AssetTitleField.php
+++ b/src/FieldLayout/LayoutElements/Assets/AssetTitleField.php
@@ -2,10 +2,10 @@
declare(strict_types=1);
-namespace CraftCms\Cms\FieldLayout\LayoutElements\assets;
+namespace CraftCms\Cms\FieldLayout\LayoutElements\Assets;
-use craft\base\ElementInterface;
use CraftCms\Cms\Asset\Elements\Asset;
+use CraftCms\Cms\Element\Contracts\ElementInterface;
use CraftCms\Cms\Field\Enums\TranslationMethod;
use CraftCms\Cms\FieldLayout\LayoutElements\TitleField;
use InvalidArgumentException;
diff --git a/src/FieldLayout/LayoutElements/BaseField.php b/src/FieldLayout/LayoutElements/BaseField.php
index fc126f03f0f..14557069ac9 100644
--- a/src/FieldLayout/LayoutElements/BaseField.php
+++ b/src/FieldLayout/LayoutElements/BaseField.php
@@ -5,9 +5,9 @@
namespace CraftCms\Cms\FieldLayout\LayoutElements;
use Craft;
-use craft\base\ElementInterface;
use CraftCms\Cms\Cp\FormFields;
use CraftCms\Cms\Cp\Icons;
+use CraftCms\Cms\Element\Contracts\ElementInterface;
use CraftCms\Cms\Element\ElementAttributeRenderer;
use CraftCms\Cms\FieldLayout\Events\DefineActionMenuItems;
use CraftCms\Cms\FieldLayout\FieldLayoutElement;
diff --git a/src/FieldLayout/LayoutElements/BaseNativeField.php b/src/FieldLayout/LayoutElements/BaseNativeField.php
index cfd3055f928..45e4951f999 100644
--- a/src/FieldLayout/LayoutElements/BaseNativeField.php
+++ b/src/FieldLayout/LayoutElements/BaseNativeField.php
@@ -4,7 +4,7 @@
namespace CraftCms\Cms\FieldLayout\LayoutElements;
-use craft\base\ElementInterface;
+use CraftCms\Cms\Element\Contracts\ElementInterface;
use CraftCms\Cms\FieldLayout\Events\DefineNativeFields;
use CraftCms\Cms\Support\Arr;
use Override;
diff --git a/src/FieldLayout/LayoutElements/CustomField.php b/src/FieldLayout/LayoutElements/CustomField.php
index f275525433f..ab35e8eea24 100644
--- a/src/FieldLayout/LayoutElements/CustomField.php
+++ b/src/FieldLayout/LayoutElements/CustomField.php
@@ -4,12 +4,12 @@
namespace CraftCms\Cms\FieldLayout\LayoutElements;
-use craft\base\ElementInterface;
use CraftCms\Cms\Component\Contracts\Actionable;
use CraftCms\Cms\Component\Contracts\Iconic;
use CraftCms\Cms\Cp\FieldLayoutDesigner\CardDesigner;
use CraftCms\Cms\Cp\FormFields;
use CraftCms\Cms\Element\Conditions\Contracts\ElementConditionInterface;
+use CraftCms\Cms\Element\Contracts\ElementInterface;
use CraftCms\Cms\Field\ContentBlock;
use CraftCms\Cms\Field\Contracts\CrossSiteCopyableFieldInterface;
use CraftCms\Cms\Field\Contracts\FieldInterface;
@@ -27,8 +27,8 @@
use CraftCms\Cms\User\Elements\User;
use Illuminate\Support\Facades\Auth;
use Override;
+use RuntimeException;
use Throwable;
-use yii\base\InvalidConfigException;
use function CraftCms\Cms\t;
use function CraftCms\Cms\template;
@@ -335,7 +335,7 @@ public function keywords(): array
/**
* Returns the custom field this layout field is based on.
*
- * @throws InvalidConfigException
+ * @throws RuntimeException
* @throws FieldNotFoundException
*/
public function getField(): FieldInterface
@@ -345,7 +345,7 @@ public function getField(): FieldInterface
}
if (! isset($this->_fieldUid)) {
- throw new InvalidConfigException('No field UUID set.');
+ throw new RuntimeException('No field UUID set.');
}
if (($field = Fields::getFieldByUid($this->_fieldUid)) === null) {
diff --git a/src/FieldLayout/LayoutElements/entries/EntryTitleField.php b/src/FieldLayout/LayoutElements/Entries/EntryTitleField.php
similarity index 96%
rename from src/FieldLayout/LayoutElements/entries/EntryTitleField.php
rename to src/FieldLayout/LayoutElements/Entries/EntryTitleField.php
index 61c76506702..fe7e99af0b1 100644
--- a/src/FieldLayout/LayoutElements/entries/EntryTitleField.php
+++ b/src/FieldLayout/LayoutElements/Entries/EntryTitleField.php
@@ -2,10 +2,10 @@
declare(strict_types=1);
-namespace CraftCms\Cms\FieldLayout\LayoutElements\entries;
+namespace CraftCms\Cms\FieldLayout\LayoutElements\Entries;
-use craft\base\ElementInterface;
use CraftCms\Cms\Cp\FormFields;
+use CraftCms\Cms\Element\Contracts\ElementInterface;
use CraftCms\Cms\Entry\Elements\Entry;
use CraftCms\Cms\Field\Enums\TranslationMethod;
use CraftCms\Cms\FieldLayout\LayoutElements\TitleField;
diff --git a/src/FieldLayout/LayoutElements/FullNameField.php b/src/FieldLayout/LayoutElements/FullNameField.php
index 78e10591d6b..cad221ae4c1 100644
--- a/src/FieldLayout/LayoutElements/FullNameField.php
+++ b/src/FieldLayout/LayoutElements/FullNameField.php
@@ -4,9 +4,9 @@
namespace CraftCms\Cms\FieldLayout\LayoutElements;
-use craft\base\ElementInterface;
use CraftCms\Cms\Cms;
use CraftCms\Cms\Cp\FormFields;
+use CraftCms\Cms\Element\Contracts\ElementInterface;
use CraftCms\Cms\Support\Arr;
use CraftCms\Cms\Support\Html as HtmlHelper;
use Illuminate\Support\Facades\Auth;
diff --git a/src/FieldLayout/LayoutElements/Heading.php b/src/FieldLayout/LayoutElements/Heading.php
index 237bf899bad..d3f97e7ff32 100644
--- a/src/FieldLayout/LayoutElements/Heading.php
+++ b/src/FieldLayout/LayoutElements/Heading.php
@@ -4,8 +4,8 @@
namespace CraftCms\Cms\FieldLayout\LayoutElements;
-use craft\base\ElementInterface;
use CraftCms\Cms\Cp\FormFields;
+use CraftCms\Cms\Element\Contracts\ElementInterface;
use CraftCms\Cms\Support\Html;
use Override;
diff --git a/src/FieldLayout/LayoutElements/HorizontalRule.php b/src/FieldLayout/LayoutElements/HorizontalRule.php
index 8a8c9dd869a..9fa1985cd57 100644
--- a/src/FieldLayout/LayoutElements/HorizontalRule.php
+++ b/src/FieldLayout/LayoutElements/HorizontalRule.php
@@ -4,8 +4,8 @@
namespace CraftCms\Cms\FieldLayout\LayoutElements;
-use craft\base\ElementInterface;
use CraftCms\Cms\Cp\Icons;
+use CraftCms\Cms\Element\Contracts\ElementInterface;
use CraftCms\Cms\FieldLayout\FieldLayoutElement;
use CraftCms\Cms\Support\Html;
use Override;
diff --git a/src/FieldLayout/LayoutElements/Html.php b/src/FieldLayout/LayoutElements/Html.php
index c9aed077279..f0e467cdf59 100644
--- a/src/FieldLayout/LayoutElements/Html.php
+++ b/src/FieldLayout/LayoutElements/Html.php
@@ -4,10 +4,10 @@
namespace CraftCms\Cms\FieldLayout\LayoutElements;
-use craft\base\ElementInterface;
+use CraftCms\Cms\Element\Contracts\ElementInterface;
use CraftCms\Cms\FieldLayout\FieldLayoutElement;
+use CraftCms\Cms\Shared\Exceptions\NotSupportedException;
use CraftCms\Cms\Support\Html as HtmlHelper;
-use yii\base\NotSupportedException;
class Html extends FieldLayoutElement
{
diff --git a/src/FieldLayout/LayoutElements/LineBreak.php b/src/FieldLayout/LayoutElements/LineBreak.php
index dc6a7afc3df..c93a710cd91 100644
--- a/src/FieldLayout/LayoutElements/LineBreak.php
+++ b/src/FieldLayout/LayoutElements/LineBreak.php
@@ -4,8 +4,8 @@
namespace CraftCms\Cms\FieldLayout\LayoutElements;
-use craft\base\ElementInterface;
use CraftCms\Cms\Cp\Icons;
+use CraftCms\Cms\Element\Contracts\ElementInterface;
use CraftCms\Cms\FieldLayout\FieldLayoutElement;
use CraftCms\Cms\Support\Html;
use Override;
diff --git a/src/FieldLayout/LayoutElements/Markdown.php b/src/FieldLayout/LayoutElements/Markdown.php
index 648a205868a..549974fc4ed 100644
--- a/src/FieldLayout/LayoutElements/Markdown.php
+++ b/src/FieldLayout/LayoutElements/Markdown.php
@@ -4,8 +4,8 @@
namespace CraftCms\Cms\FieldLayout\LayoutElements;
-use craft\base\ElementInterface;
use CraftCms\Cms\Cp\FormFields;
+use CraftCms\Cms\Element\Contracts\ElementInterface;
use CraftCms\Cms\Support\Facades\Markdown as MarkdownFacade;
use CraftCms\Cms\Support\Html;
use CraftCms\Cms\Support\Str;
diff --git a/src/FieldLayout/LayoutElements/Template.php b/src/FieldLayout/LayoutElements/Template.php
index aaac1f440f8..e96a08d386b 100644
--- a/src/FieldLayout/LayoutElements/Template.php
+++ b/src/FieldLayout/LayoutElements/Template.php
@@ -4,8 +4,8 @@
namespace CraftCms\Cms\FieldLayout\LayoutElements;
-use craft\base\ElementInterface;
use CraftCms\Cms\Cp\FormFields;
+use CraftCms\Cms\Element\Contracts\ElementInterface;
use CraftCms\Cms\Support\Facades\Twig;
use CraftCms\Cms\Support\Html;
use CraftCms\Cms\Twig\Environment;
diff --git a/src/FieldLayout/LayoutElements/TextField.php b/src/FieldLayout/LayoutElements/TextField.php
index d95a409ffb5..e21b83ff581 100644
--- a/src/FieldLayout/LayoutElements/TextField.php
+++ b/src/FieldLayout/LayoutElements/TextField.php
@@ -4,7 +4,7 @@
namespace CraftCms\Cms\FieldLayout\LayoutElements;
-use craft\base\ElementInterface;
+use CraftCms\Cms\Element\Contracts\ElementInterface;
use CraftCms\Cms\Support\Arr;
use Illuminate\Support\Facades\Auth;
use Override;
diff --git a/src/FieldLayout/LayoutElements/TextareaField.php b/src/FieldLayout/LayoutElements/TextareaField.php
index 44af6ff14de..ce1faba054d 100644
--- a/src/FieldLayout/LayoutElements/TextareaField.php
+++ b/src/FieldLayout/LayoutElements/TextareaField.php
@@ -4,7 +4,7 @@
namespace CraftCms\Cms\FieldLayout\LayoutElements;
-use craft\base\ElementInterface;
+use CraftCms\Cms\Element\Contracts\ElementInterface;
use CraftCms\Cms\Support\Arr;
use CraftCms\Cms\Support\Html as HtmlHelper;
use Illuminate\Support\Facades\Auth;
diff --git a/src/FieldLayout/LayoutElements/Tip.php b/src/FieldLayout/LayoutElements/Tip.php
index 3b6927540a6..804d027503d 100644
--- a/src/FieldLayout/LayoutElements/Tip.php
+++ b/src/FieldLayout/LayoutElements/Tip.php
@@ -4,9 +4,9 @@
namespace CraftCms\Cms\FieldLayout\LayoutElements;
-use craft\base\ElementInterface;
use CraftCms\Cms\Cms;
use CraftCms\Cms\Cp\FormFields;
+use CraftCms\Cms\Element\Contracts\ElementInterface;
use CraftCms\Cms\Support\Facades\InputNamespace;
use CraftCms\Cms\Support\Facades\Markdown;
use CraftCms\Cms\Support\Html;
diff --git a/src/FieldLayout/LayoutElements/TitleField.php b/src/FieldLayout/LayoutElements/TitleField.php
index 263fd73a79b..a457bb1931a 100644
--- a/src/FieldLayout/LayoutElements/TitleField.php
+++ b/src/FieldLayout/LayoutElements/TitleField.php
@@ -4,7 +4,7 @@
namespace CraftCms\Cms\FieldLayout\LayoutElements;
-use craft\base\ElementInterface;
+use CraftCms\Cms\Element\Contracts\ElementInterface;
use CraftCms\Cms\Element\ElementHelper;
use CraftCms\Cms\Support\Arr;
use CraftCms\Cms\Support\Facades\HtmlStack;
diff --git a/src/FieldLayout/LayoutElements/users/AffiliatedSiteField.php b/src/FieldLayout/LayoutElements/Users/AffiliatedSiteField.php
similarity index 96%
rename from src/FieldLayout/LayoutElements/users/AffiliatedSiteField.php
rename to src/FieldLayout/LayoutElements/Users/AffiliatedSiteField.php
index d2cdc425a52..73aafc2ff1e 100644
--- a/src/FieldLayout/LayoutElements/users/AffiliatedSiteField.php
+++ b/src/FieldLayout/LayoutElements/Users/AffiliatedSiteField.php
@@ -2,10 +2,10 @@
declare(strict_types=1);
-namespace CraftCms\Cms\FieldLayout\LayoutElements\users;
+namespace CraftCms\Cms\FieldLayout\LayoutElements\Users;
-use craft\base\ElementInterface;
use CraftCms\Cms\Cp\FormFields;
+use CraftCms\Cms\Element\Contracts\ElementInterface;
use CraftCms\Cms\FieldLayout\LayoutElements\BaseNativeField;
use CraftCms\Cms\Site\Data\Site;
use CraftCms\Cms\Support\Arr;
diff --git a/src/FieldLayout/LayoutElements/users/EmailField.php b/src/FieldLayout/LayoutElements/Users/EmailField.php
similarity index 96%
rename from src/FieldLayout/LayoutElements/users/EmailField.php
rename to src/FieldLayout/LayoutElements/Users/EmailField.php
index a1e7a8ecf7e..d141c103c06 100644
--- a/src/FieldLayout/LayoutElements/users/EmailField.php
+++ b/src/FieldLayout/LayoutElements/Users/EmailField.php
@@ -2,10 +2,10 @@
declare(strict_types=1);
-namespace CraftCms\Cms\FieldLayout\LayoutElements\users;
+namespace CraftCms\Cms\FieldLayout\LayoutElements\Users;
-use craft\base\ElementInterface;
use CraftCms\Cms\Edition;
+use CraftCms\Cms\Element\Contracts\ElementInterface;
use CraftCms\Cms\FieldLayout\LayoutElements\TextField;
use CraftCms\Cms\Support\Arr;
use CraftCms\Cms\Support\Facades\HtmlStack;
diff --git a/src/FieldLayout/LayoutElements/users/FullNameField.php b/src/FieldLayout/LayoutElements/Users/FullNameField.php
similarity index 86%
rename from src/FieldLayout/LayoutElements/users/FullNameField.php
rename to src/FieldLayout/LayoutElements/Users/FullNameField.php
index 85d04dd45de..55f7f95ad61 100644
--- a/src/FieldLayout/LayoutElements/users/FullNameField.php
+++ b/src/FieldLayout/LayoutElements/Users/FullNameField.php
@@ -2,9 +2,9 @@
declare(strict_types=1);
-namespace CraftCms\Cms\FieldLayout\LayoutElements\users;
+namespace CraftCms\Cms\FieldLayout\LayoutElements\Users;
-use craft\base\ElementInterface;
+use CraftCms\Cms\Element\Contracts\ElementInterface;
use CraftCms\Cms\FieldLayout\LayoutElements\FullNameField as BaseFullNameField;
use CraftCms\Cms\User\Elements\User;
use InvalidArgumentException;
diff --git a/src/FieldLayout/LayoutElements/users/PhotoField.php b/src/FieldLayout/LayoutElements/Users/PhotoField.php
similarity index 96%
rename from src/FieldLayout/LayoutElements/users/PhotoField.php
rename to src/FieldLayout/LayoutElements/Users/PhotoField.php
index ec722864a13..8eaf327a18a 100644
--- a/src/FieldLayout/LayoutElements/users/PhotoField.php
+++ b/src/FieldLayout/LayoutElements/Users/PhotoField.php
@@ -2,9 +2,9 @@
declare(strict_types=1);
-namespace CraftCms\Cms\FieldLayout\LayoutElements\users;
+namespace CraftCms\Cms\FieldLayout\LayoutElements\Users;
-use craft\base\ElementInterface;
+use CraftCms\Cms\Element\Contracts\ElementInterface;
use CraftCms\Cms\FieldLayout\LayoutElements\BaseNativeField;
use CraftCms\Cms\Support\Arr;
use CraftCms\Cms\Support\Facades\HtmlStack;
diff --git a/src/FieldLayout/LayoutElements/users/UsernameField.php b/src/FieldLayout/LayoutElements/Users/UsernameField.php
similarity index 94%
rename from src/FieldLayout/LayoutElements/users/UsernameField.php
rename to src/FieldLayout/LayoutElements/Users/UsernameField.php
index 543e1999c4c..22292afccf6 100644
--- a/src/FieldLayout/LayoutElements/users/UsernameField.php
+++ b/src/FieldLayout/LayoutElements/Users/UsernameField.php
@@ -2,10 +2,10 @@
declare(strict_types=1);
-namespace CraftCms\Cms\FieldLayout\LayoutElements\users;
+namespace CraftCms\Cms\FieldLayout\LayoutElements\Users;
-use craft\base\ElementInterface;
use CraftCms\Cms\Cms;
+use CraftCms\Cms\Element\Contracts\ElementInterface;
use CraftCms\Cms\FieldLayout\LayoutElements\TextField;
use CraftCms\Cms\Support\Arr;
use CraftCms\Cms\User\Elements\User;
diff --git a/src/GarbageCollection/Actions/DeleteOrphanedFieldLayouts.php b/src/GarbageCollection/Actions/DeleteOrphanedFieldLayouts.php
index 07ffc166e79..f20f1608816 100644
--- a/src/GarbageCollection/Actions/DeleteOrphanedFieldLayouts.php
+++ b/src/GarbageCollection/Actions/DeleteOrphanedFieldLayouts.php
@@ -4,9 +4,9 @@
namespace CraftCms\Cms\GarbageCollection\Actions;
-use craft\base\ElementInterface;
use CraftCms\Cms\Config\GeneralConfig;
use CraftCms\Cms\Database\Table;
+use CraftCms\Cms\Element\Contracts\ElementInterface;
use CraftCms\Cms\GarbageCollection\GarbageCollection;
use Illuminate\Support\Facades\DB;
use Tpetry\QueryExpressions\Language\Alias;
diff --git a/src/GarbageCollection/Actions/DeleteOrphanedNestedElements.php b/src/GarbageCollection/Actions/DeleteOrphanedNestedElements.php
index fa95b93c6eb..ae384fad7ea 100644
--- a/src/GarbageCollection/Actions/DeleteOrphanedNestedElements.php
+++ b/src/GarbageCollection/Actions/DeleteOrphanedNestedElements.php
@@ -4,9 +4,9 @@
namespace CraftCms\Cms\GarbageCollection\Actions;
-use craft\base\ElementInterface;
use CraftCms\Cms\Config\GeneralConfig;
use CraftCms\Cms\Database\Table;
+use CraftCms\Cms\Element\Contracts\ElementInterface;
use CraftCms\Cms\GarbageCollection\GarbageCollection;
use Illuminate\Support\Facades\DB;
use Tpetry\QueryExpressions\Language\Alias;
diff --git a/src/GarbageCollection/Actions/DeletePartialElements.php b/src/GarbageCollection/Actions/DeletePartialElements.php
index 28a8947e2f2..d24cc365b65 100644
--- a/src/GarbageCollection/Actions/DeletePartialElements.php
+++ b/src/GarbageCollection/Actions/DeletePartialElements.php
@@ -4,9 +4,9 @@
namespace CraftCms\Cms\GarbageCollection\Actions;
-use craft\base\ElementInterface;
use CraftCms\Cms\Config\GeneralConfig;
use CraftCms\Cms\Database\Table;
+use CraftCms\Cms\Element\Contracts\ElementInterface;
use CraftCms\Cms\GarbageCollection\GarbageCollection;
use Illuminate\Support\Facades\DB;
use Tpetry\QueryExpressions\Language\Alias;
diff --git a/src/GarbageCollection/Actions/HardDeleteElements.php b/src/GarbageCollection/Actions/HardDeleteElements.php
index e5530011d3f..7de60dfc260 100644
--- a/src/GarbageCollection/Actions/HardDeleteElements.php
+++ b/src/GarbageCollection/Actions/HardDeleteElements.php
@@ -4,8 +4,8 @@
namespace CraftCms\Cms\GarbageCollection\Actions;
-use craft\base\NestedElementInterface;
use CraftCms\Cms\Database\Table;
+use CraftCms\Cms\Element\Contracts\NestedElementInterface;
use CraftCms\Cms\Support\Facades\Elements;
use Illuminate\Support\Facades\DB;
use Tpetry\QueryExpressions\Function\Conditional\Coalesce;
diff --git a/src/Gql/ArgumentManager.php b/src/Gql/ArgumentManager.php
index 17db53a4ac1..5960061ef7f 100644
--- a/src/Gql/ArgumentManager.php
+++ b/src/Gql/ArgumentManager.php
@@ -121,7 +121,6 @@ public function prepareArguments(array $arguments): array
protected function createHandler(string $handler): ArgumentHandlerInterface|string
{
if (is_a($handler, ArgumentHandlerInterface::class, true)) {
- /** @var ArgumentHandlerInterface $handler */
$handler = new $handler;
$handler->setArgumentManager($this);
}
diff --git a/src/Gql/Concerns/PerformsStructureMutations.php b/src/Gql/Concerns/PerformsStructureMutations.php
index 42324a6a68e..584180d0d4f 100644
--- a/src/Gql/Concerns/PerformsStructureMutations.php
+++ b/src/Gql/Concerns/PerformsStructureMutations.php
@@ -4,7 +4,7 @@
namespace CraftCms\Cms\Gql\Concerns;
-use craft\base\ElementInterface;
+use CraftCms\Cms\Element\Contracts\ElementInterface;
use CraftCms\Cms\Element\Element;
use CraftCms\Cms\Support\Facades\Elements;
use CraftCms\Cms\Support\Facades\Structures;
diff --git a/src/Gql/Events/AfterPopulateElement.php b/src/Gql/Events/AfterPopulateElement.php
index 2ff60f21272..1b2b5fab9cb 100644
--- a/src/Gql/Events/AfterPopulateElement.php
+++ b/src/Gql/Events/AfterPopulateElement.php
@@ -4,7 +4,7 @@
namespace CraftCms\Cms\Gql\Events;
-use craft\base\ElementInterface;
+use CraftCms\Cms\Element\Contracts\ElementInterface;
use CraftCms\Cms\Gql\Resolvers\ElementMutationResolver;
/**
diff --git a/src/Gql/Events/BeforePopulateElement.php b/src/Gql/Events/BeforePopulateElement.php
index 3824fd0e6cd..56a41b8549f 100644
--- a/src/Gql/Events/BeforePopulateElement.php
+++ b/src/Gql/Events/BeforePopulateElement.php
@@ -4,7 +4,7 @@
namespace CraftCms\Cms\Gql\Events;
-use craft\base\ElementInterface;
+use CraftCms\Cms\Element\Contracts\ElementInterface;
use CraftCms\Cms\Gql\Resolvers\ElementMutationResolver;
/**
diff --git a/src/Gql/Gql.php b/src/Gql/Gql.php
index d105a8fa018..2e3665f089e 100644
--- a/src/Gql/Gql.php
+++ b/src/Gql/Gql.php
@@ -4,12 +4,12 @@
namespace CraftCms\Cms\Gql;
-use craft\base\ElementInterface as BaseElementInterface;
use craft\behaviors\FieldLayoutBehavior;
use CraftCms\Cms\Asset\Volumes;
use CraftCms\Cms\Cms;
use CraftCms\Cms\Database\Table;
use CraftCms\Cms\Edition;
+use CraftCms\Cms\Element\Contracts\ElementInterface as BaseElementInterface;
use CraftCms\Cms\Element\ElementCaches;
use CraftCms\Cms\Field\Contracts\ElementContainerFieldInterface;
use CraftCms\Cms\Field\Contracts\FieldInterface;
diff --git a/src/Gql/GqlHelper.php b/src/Gql/GqlHelper.php
index b341d265f6a..117ab5df518 100644
--- a/src/Gql/GqlHelper.php
+++ b/src/Gql/GqlHelper.php
@@ -4,7 +4,7 @@
namespace CraftCms\Cms\Gql;
-use craft\base\ElementInterface;
+use CraftCms\Cms\Element\Contracts\ElementInterface;
use CraftCms\Cms\Entry\Data\EntryType;
use CraftCms\Cms\Field\Contracts\ElementContainerFieldInterface;
use CraftCms\Cms\Field\Fields;
diff --git a/src/Gql/Handlers/RelationArgumentHandler.php b/src/Gql/Handlers/RelationArgumentHandler.php
index 197fd9e09ee..f616c1739fe 100644
--- a/src/Gql/Handlers/RelationArgumentHandler.php
+++ b/src/Gql/Handlers/RelationArgumentHandler.php
@@ -4,7 +4,7 @@
namespace CraftCms\Cms\Gql\Handlers;
-use craft\base\ElementInterface;
+use CraftCms\Cms\Element\Contracts\ElementInterface;
use CraftCms\Cms\Element\Queries\ElementQuery;
use CraftCms\Cms\Support\Arr;
use CraftCms\Cms\Support\Facades\Elements;
diff --git a/src/Gql/Mutations/Entry.php b/src/Gql/Mutations/Entry.php
index 9092f09d754..40ab2632cf1 100644
--- a/src/Gql/Mutations/Entry.php
+++ b/src/Gql/Mutations/Entry.php
@@ -169,7 +169,6 @@ public static function createSaveMutations(
$draftMutationArguments = DraftMutationArguments::getArguments();
$generatedType = EntryType::generateType($entryType);
- /** @var EntryMutationResolver $resolver */
$resolver = new EntryMutationResolver;
$resolver->setResolutionData('entryType', $entryType);
$resolver->setResolutionData('section', $section);
@@ -235,7 +234,6 @@ public static function createSaveMutationsForField(
$draftMutationArguments = DraftMutationArguments::getArguments();
$generatedType = EntryType::generateType($entryType);
- /** @var EntryMutationResolver $resolver */
$resolver = new EntryMutationResolver;
$resolver->setResolutionData('entryType', $entryType);
$resolver->setResolutionData('field', $field);
diff --git a/src/Gql/Resolvers/ElementMutationResolver.php b/src/Gql/Resolvers/ElementMutationResolver.php
index fbce86c3727..b8c7ce76a66 100644
--- a/src/Gql/Resolvers/ElementMutationResolver.php
+++ b/src/Gql/Resolvers/ElementMutationResolver.php
@@ -4,7 +4,7 @@
namespace CraftCms\Cms\Gql\Resolvers;
-use craft\base\ElementInterface;
+use CraftCms\Cms\Element\Contracts\ElementInterface;
use CraftCms\Cms\Element\Element;
use CraftCms\Cms\Element\Validation\ElementRules;
use CraftCms\Cms\Gql\Events\AfterPopulateElement;
diff --git a/src/Gql/Resolvers/ElementResolver.php b/src/Gql/Resolvers/ElementResolver.php
index 5ee0782a562..a935e900bb6 100644
--- a/src/Gql/Resolvers/ElementResolver.php
+++ b/src/Gql/Resolvers/ElementResolver.php
@@ -4,8 +4,8 @@
namespace CraftCms\Cms\Gql\Resolvers;
-use craft\base\ElementInterface;
use CraftCms\Cms\Cms;
+use CraftCms\Cms\Element\Contracts\ElementInterface;
use CraftCms\Cms\Element\ElementCollection;
use CraftCms\Cms\Element\Queries\Contracts\ElementQueryInterface;
use CraftCms\Cms\Element\Queries\ElementQuery;
diff --git a/src/Gql/Resolvers/Elements/ContentBlock.php b/src/Gql/Resolvers/Elements/ContentBlock.php
index 19aa9d78a04..3f66a6efb44 100644
--- a/src/Gql/Resolvers/Elements/ContentBlock.php
+++ b/src/Gql/Resolvers/Elements/ContentBlock.php
@@ -4,7 +4,7 @@
namespace CraftCms\Cms\Gql\Resolvers\Elements;
-use craft\base\ElementInterface;
+use CraftCms\Cms\Element\Contracts\ElementInterface;
use CraftCms\Cms\Gql\GqlHelper;
use CraftCms\Cms\Gql\Resolvers\Resolver;
use GraphQL\Type\Definition\ResolveInfo;
diff --git a/src/Gql/Resolvers/Mutations/Asset.php b/src/Gql/Resolvers/Mutations/Asset.php
index 2ca2aed3150..4918ef24c08 100644
--- a/src/Gql/Resolvers/Mutations/Asset.php
+++ b/src/Gql/Resolvers/Mutations/Asset.php
@@ -4,7 +4,6 @@
namespace CraftCms\Cms\Gql\Resolvers\Mutations;
-use craft\base\ElementInterface;
use CraftCms\Cms\Asset\AssetsHelper;
use CraftCms\Cms\Asset\Data\Volume;
use CraftCms\Cms\Asset\Elements\Asset as AssetElement;
@@ -14,6 +13,7 @@
use CraftCms\Cms\Asset\Validation\AssetRules;
use CraftCms\Cms\Cms;
use CraftCms\Cms\Database\Table;
+use CraftCms\Cms\Element\Contracts\ElementInterface;
use CraftCms\Cms\Gql\Resolvers\ElementMutationResolver;
use CraftCms\Cms\Support\Arr;
use CraftCms\Cms\Support\Facades\Elements;
diff --git a/src/Gql/Types/Elements/Element.php b/src/Gql/Types/Elements/Element.php
index 8afc4f29c25..72f23072571 100644
--- a/src/Gql/Types/Elements/Element.php
+++ b/src/Gql/Types/Elements/Element.php
@@ -4,7 +4,7 @@
namespace CraftCms\Cms\Gql\Types\Elements;
-use craft\base\ElementInterface as BaseElementInterface;
+use CraftCms\Cms\Element\Contracts\ElementInterface as BaseElementInterface;
use CraftCms\Cms\Gql\ArgumentManager;
use CraftCms\Cms\Gql\Gql;
use CraftCms\Cms\Gql\Interfaces\Element as ElementInterface;
diff --git a/src/Http/Controllers/App/RenderController.php b/src/Http/Controllers/App/RenderController.php
index d36bb08b4d6..6f97e2e62e5 100644
--- a/src/Http/Controllers/App/RenderController.php
+++ b/src/Http/Controllers/App/RenderController.php
@@ -4,13 +4,13 @@
namespace CraftCms\Cms\Http\Controllers\App;
-use craft\base\ElementInterface;
use CraftCms\Cms\Asset\Data\Volume;
use CraftCms\Cms\Asset\Volumes;
use CraftCms\Cms\Component\Contracts\Chippable;
use CraftCms\Cms\Component\Contracts\Iconic;
use CraftCms\Cms\Cp\Html\ElementHtml;
use CraftCms\Cms\Cp\Html\MenuHtml;
+use CraftCms\Cms\Element\Contracts\ElementInterface;
use CraftCms\Cms\Element\Drafts;
use CraftCms\Cms\Element\Queries\Contracts\NestedElementQueryInterface;
use CraftCms\Cms\Support\Arr;
diff --git a/src/Http/Controllers/Assets/ImageEditorController.php b/src/Http/Controllers/Assets/ImageEditorController.php
index 9c44fc5565f..d82ec409af0 100644
--- a/src/Http/Controllers/Assets/ImageEditorController.php
+++ b/src/Http/Controllers/Assets/ImageEditorController.php
@@ -14,10 +14,10 @@
use CraftCms\Cms\Image\ImageTransformer;
use CraftCms\Cms\Image\ImageTransformHelper;
use CraftCms\Cms\Image\ImageTransforms;
+use CraftCms\Cms\Shared\Exceptions\NotSupportedException;
use Illuminate\Http\JsonResponse;
use Illuminate\Http\Request;
use Symfony\Component\HttpFoundation\Response;
-use yii\base\NotSupportedException;
use function CraftCms\Cms\t;
use function CraftCms\Cms\template;
diff --git a/src/Http/Controllers/Auth/OAuthController.php b/src/Http/Controllers/Auth/OAuthController.php
index bed46e23c4c..dd57dac7666 100644
--- a/src/Http/Controllers/Auth/OAuthController.php
+++ b/src/Http/Controllers/Auth/OAuthController.php
@@ -107,7 +107,7 @@ public function callback(Request $request, string $provider, OAuth $oauthManager
return $this->failedResponse(
$isCpRequest,
- implode(', ', $user->getErrorSummary(true)) ?: t('Unable to save the user.'),
+ implode(', ', $user->errors()->all()) ?: t('Unable to save the user.'),
previous: $e,
);
} catch (Throwable $e) {
diff --git a/src/Http/Controllers/Elements/Concerns/CreatesElement.php b/src/Http/Controllers/Elements/Concerns/CreatesElement.php
new file mode 100644
index 00000000000..d5c886ac13d
--- /dev/null
+++ b/src/Http/Controllers/Elements/Concerns/CreatesElement.php
@@ -0,0 +1,44 @@
+request->elementType();
+
+ $this->request->validateElementType($elementType);
+
+ /** @var ElementInterface $element */
+ $element = app()->make($elementType);
+
+ if ($this->request->has('siteId') && $element::isLocalized()) {
+ $element->siteId = $this->request->integer('siteId');
+ }
+
+ if ($this->request->has('ownerId') && $element instanceof NestedElementInterface) {
+ $element->setOwnerId($this->request->integer('ownerId'));
+ }
+
+ $element->setAttributesFromRequest($this->request->validated() + array_filter(['fieldId' => $this->request->input('fieldId')]));
+
+ Gate::authorize('save', $element);
+
+ if (! $element->slug) {
+ $element->slug = ElementHelper::tempSlug();
+ }
+
+ return $element;
+ }
+}
diff --git a/src/Http/Controllers/Elements/Concerns/EditsElement.php b/src/Http/Controllers/Elements/Concerns/EditsElement.php
new file mode 100644
index 00000000000..d98c3d8bda8
--- /dev/null
+++ b/src/Http/Controllers/Elements/Concerns/EditsElement.php
@@ -0,0 +1,46 @@
+title !== null && $element->title !== '' => $element->title,
+ ! $element->id || $element->getIsUnpublishedDraft() => t('Create a new {type}', [
+ 'type' => $element::lowerDisplayName(),
+ ]),
+ default => $element->getUiLabel(),
+ };
+
+ $docTitle = $element->getUiLabel();
+
+ if ($element->getIsDraft() && ! $element->getIsUnpublishedDraft()) {
+ if ($element->isProvisionalDraft) {
+ $docTitle .= ' — '.t('Edited');
+ } else {
+ $docTitle .= " ($element->draftName)";
+ }
+ } elseif ($element->getIsRevision()) {
+ $docTitle .= ' ('.$element->getRevisionLabel().')';
+ }
+
+ // Include site name if localized
+ if ($element::isLocalized() && Sites::isMultiSite()) {
+ $docTitle .= sprintf(' - %s', $element->getSite()->getUiLabel());
+ }
+
+ return [$docTitle, $title];
+ }
+}
diff --git a/src/Http/Controllers/Elements/Concerns/ElementCrumbs.php b/src/Http/Controllers/Elements/Concerns/ElementCrumbs.php
new file mode 100644
index 00000000000..ee422263e8f
--- /dev/null
+++ b/src/Http/Controllers/Elements/Concerns/ElementCrumbs.php
@@ -0,0 +1,30 @@
+isProvisionalDraft
+ ? $element->getCanonical(true)->getCrumbs()
+ : $element->getCrumbs();
+
+ return [
+ ...$crumbs,
+ [
+ 'html' => app(ElementHtml::class)->elementChipHtml($element, [
+ 'showDraftName' => ! $current,
+ 'class' => 'chromeless',
+ 'hyperlink' => true,
+ ]),
+ 'current' => $current,
+ ],
+ ];
+ }
+}
diff --git a/src/Http/Controllers/Elements/Concerns/InteractsWithElementIndexes.php b/src/Http/Controllers/Elements/Concerns/InteractsWithElementIndexes.php
index bcf33ff7d0b..b3a44a76281 100644
--- a/src/Http/Controllers/Elements/Concerns/InteractsWithElementIndexes.php
+++ b/src/Http/Controllers/Elements/Concerns/InteractsWithElementIndexes.php
@@ -4,14 +4,17 @@
namespace CraftCms\Cms\Http\Controllers\Elements\Concerns;
-use craft\base\ElementInterface;
use CraftCms\Cms\Element\Conditions\Contracts\ElementConditionInterface;
+use CraftCms\Cms\Element\Conditions\Contracts\ElementConditionRuleInterface;
use CraftCms\Cms\Element\Conditions\ElementCondition;
+use CraftCms\Cms\Element\Contracts\ElementInterface;
use CraftCms\Cms\Element\ElementHelper;
use CraftCms\Cms\Element\ElementSources;
use CraftCms\Cms\Element\Queries\Contracts\ElementQueryInterface;
use CraftCms\Cms\Element\Queries\ExcludeDescendantIdsExpression;
+use CraftCms\Cms\FieldLayout\FieldLayout;
use CraftCms\Cms\Support\Facades\Conditions;
+use CraftCms\Cms\Support\Facades\ElementExporters;
use CraftCms\Cms\Support\Facades\Elements;
use CraftCms\Cms\Support\Facades\ElementSources as ElementSourcesFacade;
use CraftCms\Cms\Support\Typecast;
@@ -20,7 +23,7 @@
trait InteractsWithElementIndexes
{
- protected function condition(): ?ElementConditionInterface
+ protected function resolveElementIndexCondition(): ?ElementConditionInterface
{
/** @var array|null $conditionConfig */
/** @phpstan-var array{class:class-string}|null $conditionConfig */
@@ -58,7 +61,7 @@ protected function condition(): ?ElementConditionInterface
* @param class-string $elementType
* @return array{0:?string,1:?array}
*/
- protected function source(string $elementType, ?string $sourceKey, string $context): array
+ protected function resolveSource(string $elementType, ?string $sourceKey, string $context): array
{
if (! isset($sourceKey)) {
return [$sourceKey, null];
@@ -82,7 +85,24 @@ protected function source(string $elementType, ?string $sourceKey, string $conte
return [$sourceKey, $source];
}
- protected function viewState(): array
+ /**
+ * @return FieldLayout[]|null
+ */
+ protected function resolveFieldLayouts(): ?array
+ {
+ $fieldLayouts = request()->input('fieldLayouts');
+
+ if (empty($fieldLayouts) || ! is_array($fieldLayouts)) {
+ return null;
+ }
+
+ return array_map(
+ FieldLayout::createFromConfig(...),
+ $fieldLayouts,
+ );
+ }
+
+ protected function resolveViewState(): array
{
$viewState = request()->input('viewState', []);
@@ -95,18 +115,22 @@ protected function viewState(): array
/**
* @param class-string $elementType
+ * @return array{query:ElementQueryInterface,unfilteredQuery:ElementQueryInterface|null}
*/
- protected function elementQuery(
+ protected function buildElementQueryState(
string $elementType,
?array $source,
?ElementConditionInterface $condition,
- ): ElementQueryInterface {
+ ): array {
$query = $elementType::find();
if (! $source) {
$query->id(false);
- return $query;
+ return [
+ 'query' => $query,
+ 'unfilteredQuery' => null,
+ ];
}
if ($source['type'] === ElementSources::TYPE_CUSTOM) {
@@ -115,9 +139,9 @@ protected function elementQuery(
$sourceCondition->modifyQuery($query);
}
- $applyCriteria = function (array $criteria) use ($query): void {
+ $applyCriteria = function (array $criteria) use ($query): bool {
if (! $criteria) {
- return;
+ return false;
}
if (isset($criteria['trashed'])) {
@@ -137,15 +161,24 @@ protected function elementQuery(
}
Typecast::configure($query, ElementHelper::cleanseQueryCriteria($criteria));
+
+ return true;
};
$applyCriteria(request()->input('baseCriteria') ?? []);
+ $unfilteredQuery = clone $query;
+ $hasFilters = false;
+
if ($condition) {
$condition->modifyQuery($query);
+
+ $hasFilters = true;
}
- $applyCriteria(request()->input('criteria') ?? []);
+ if ($applyCriteria(request()->input('criteria') ?? [])) {
+ $hasFilters = true;
+ }
$filterConditionConfig = request()->input('filterConfig');
@@ -158,12 +191,17 @@ protected function elementQuery(
/** @var ElementConditionInterface $filterCondition */
$filterCondition = Conditions::createCondition($filterConditionConfig);
$filterCondition->modifyQuery($query);
+
+ $hasFilters = true;
}
$collapsedElementIds = request()->input('collapsedElementIds');
if (! $collapsedElementIds) {
- return $query;
+ return [
+ 'query' => $query,
+ 'unfilteredQuery' => $hasFilters ? $unfilteredQuery : null,
+ ];
}
$descendantQuery = (clone $query)
@@ -180,7 +218,10 @@ protected function elementQuery(
->all();
if (empty($collapsedElements)) {
- return $query;
+ return [
+ 'query' => $query,
+ 'unfilteredQuery' => $hasFilters ? $unfilteredQuery : null,
+ ];
}
$descendantIds = [];
@@ -198,14 +239,63 @@ protected function elementQuery(
}
if (empty($descendantIds)) {
- return $query;
+ return [
+ 'query' => $query,
+ 'unfilteredQuery' => $hasFilters ? $unfilteredQuery : null,
+ ];
}
- return $query->where(new ExcludeDescendantIdsExpression($descendantIds));
+ $query->where(new ExcludeDescendantIdsExpression($descendantIds));
+
+ return [
+ 'query' => $query,
+ 'unfilteredQuery' => $unfilteredQuery,
+ ];
}
- protected function isAdministrative(string $context): bool
+ /**
+ * @param class-string $elementType
+ */
+ protected function availableExporters(string $elementType, string $sourceKey): ?array
{
- return in_array($context, ['index', 'embedded-index'], true);
+ if (request()->isMobileBrowser()) {
+ return null;
+ }
+
+ return ElementExporters::availableExporters($elementType, $sourceKey);
+ }
+
+ protected function populateFilterHudQueryParams(
+ ElementConditionInterface $condition,
+ ?array $source,
+ ?string $sourceKey,
+ ?ElementConditionInterface $currentCondition,
+ ): void {
+ if ($source !== null) {
+ if ($source['type'] === ElementSources::TYPE_NATIVE) {
+ $condition->queryParams = array_keys($source['criteria'] ?? []);
+ $condition->sourceKey = $sourceKey;
+ } else {
+ /** @var ElementConditionInterface $sourceCondition */
+ $sourceCondition = Conditions::createCondition($source['condition']);
+ $condition->queryParams = [];
+
+ foreach ($sourceCondition->getConditionRules() as $rule) {
+ /** @var ElementConditionRuleInterface $rule */
+ array_push($condition->queryParams, ...$rule->getExclusiveQueryParams());
+ }
+ }
+ }
+
+ if ($currentCondition) {
+ foreach ($currentCondition->getConditionRules() as $rule) {
+ /** @var ElementConditionRuleInterface $rule */
+ array_push($condition->queryParams, ...$rule->getExclusiveQueryParams());
+ }
+ }
+
+ $condition->queryParams[] = 'site';
+ $condition->queryParams[] = 'status';
+ $condition->queryParams = array_values(array_unique($condition->queryParams));
}
}
diff --git a/src/Http/Controllers/Elements/Concerns/SavesElement.php b/src/Http/Controllers/Elements/Concerns/SavesElement.php
new file mode 100644
index 00000000000..aa04aff74ce
--- /dev/null
+++ b/src/Http/Controllers/Elements/Concerns/SavesElement.php
@@ -0,0 +1,101 @@
+getIsRevision()) {
+ return false;
+ }
+
+ if ($element->isProvisionalDraft) {
+ $element = $element->getCanonical(true);
+ }
+
+ return $user->can('save', $element);
+ }
+
+ protected function applyParamsToElement(ElementInterface $element): void
+ {
+ $fieldsLocation = $this->request->input('fieldsLocation', 'fields');
+ $applyParams = $this->request->boolean('applyParams', true) || ! $this->request->isMethod('POST');
+
+ if (! $applyParams) {
+ return;
+ }
+
+ $enabled = $this->request->boolean('setEnabled', true) ? true : null;
+
+ if (! is_null($enabledForSite = $this->request->input('enabledForSite'))) {
+ if (is_array($enabledForSite)) {
+ // Make sure they are allowed to edit all of the posted site IDs
+ $editableSiteIds = Sites::getEditableSiteIds()->all();
+
+ if (array_diff(array_keys($enabledForSite), $editableSiteIds)) {
+ abort(403, 'User not authorized to edit element statuses for all the submitted site IDs.');
+ }
+
+ // Set the global status to true if it's enabled for *any* sites, or if already enabled.
+ $element->enabled = in_array(true, $enabledForSite) || $element->enabled;
+ }
+
+ $element->setEnabledForSite($enabledForSite);
+ } elseif (isset($enabled)) {
+ $element->enabled = $enabled;
+ }
+
+ if ($this->request->boolean('fresh')) {
+ $element->setIsFresh();
+
+ if ($element->getIsUnpublishedDraft()) {
+ $element->propagateAll = true;
+ }
+ }
+
+ if ($element->getIsDraft()) {
+ /** @var ElementInterface $element */
+ if ($this->request->has('draftName')) {
+ $element->draftName = $this->request->input('draftName');
+ }
+ if ($this->request->has('notes')) {
+ $element->draftNotes = $this->request->input('notes');
+ }
+ } elseif ($this->request->has('notes')) {
+ $element->setRevisionNotes($this->request->input('notes'));
+ }
+
+ if ($this->request->has('updateSearchIndexImmediately')) {
+ $element->updateSearchIndexImmediately = $this->request->boolean('updateSearchIndexImmediately');
+ }
+
+ $element->ruleset->withScenario(
+ ElementRules::SCENARIO_LIVE,
+ function () use ($element) {
+ $element->setAttributesFromRequest($this->request->validated() + array_filter(['fieldId' => $this->request->input('fieldId')]));
+
+ if ($this->request->has('slug')) {
+ $element->slug = $this->request->input('slug');
+ }
+ },
+ );
+
+ // Now that the element is fully configured, make sure the user can actually view it
+ Gate::authorize('view', $element);
+
+ // Set the custom field values
+ $element->setFieldValuesFromRequest($fieldsLocation);
+ }
+}
diff --git a/src/Http/Controllers/Elements/Concerns/UpdatesFieldLayout.php b/src/Http/Controllers/Elements/Concerns/UpdatesFieldLayout.php
new file mode 100644
index 00000000000..9e485ffdde1
--- /dev/null
+++ b/src/Http/Controllers/Elements/Concerns/UpdatesFieldLayout.php
@@ -0,0 +1,71 @@
+header('X-Craft-Namespace');
+ $fieldLayout = $element->getFieldLayout();
+ $form = $fieldLayout->createForm($element, false, $formConfig + [
+ 'namespace' => $namespace,
+ 'registerDeltas' => false,
+ 'visibleElements' => request()->input('visibleLayoutElements'),
+ 'staticElements' => request()->input('staticLayoutElements'),
+ ]);
+ $missingElements = [];
+
+ foreach ($form->tabs as $tab) {
+ if (! $tab->getUid()) {
+ continue;
+ }
+
+ $elementInfo = [];
+
+ foreach ($tab->elements as $formElement) {
+ if ($formElement->isConditional) {
+ $elementInfo[] = [
+ 'uid' => $formElement->layoutElement->uid,
+ 'html' => $formElement->html,
+ 'static' => $formElement->isStatic,
+ ];
+ }
+ }
+
+ $missingElements[] = [
+ 'uid' => $tab->getUid(),
+ 'id' => $tab->getId(),
+ 'elements' => $elementInfo,
+ ];
+ }
+
+ $tabs = $form->getTabMenu();
+ if (count($tabs) > 1) {
+ $selectedTab = request()->input('selectedTab');
+ $selectedTab = isset($tabs[$selectedTab]) ? $selectedTab : null;
+ $tabHtml = InputNamespace::namespaceInputs(fn () => template('_includes/tabs', [
+ 'tabs' => $tabs,
+ 'selectedTab' => $selectedTab,
+ ], templateMode: TemplateMode::Cp), $namespace);
+ } else {
+ $tabHtml = null;
+ }
+
+ return [
+ 'tabs' => $tabHtml,
+ 'missingElements' => $missingElements,
+ 'headHtml' => HtmlStack::headHtml(),
+ 'bodyHtml' => HtmlStack::bodyHtml(),
+ ];
+ }
+}
diff --git a/src/Http/Controllers/Elements/CopyElementValuesController.php b/src/Http/Controllers/Elements/CopyElementValuesController.php
new file mode 100644
index 00000000000..161f5e2d384
--- /dev/null
+++ b/src/Http/Controllers/Elements/CopyElementValuesController.php
@@ -0,0 +1,106 @@
+request->element(checkForProvisionalDraft: true);
+
+ if ($element instanceof Response) {
+ return $element;
+ }
+
+ if (! $element || $element->getIsRevision()) {
+ abort(400, 'No element was identified by the request.');
+ }
+
+ $this->request->validate([
+ 'fromSiteId' => ['required', 'integer'],
+ 'layoutElementUid' => ['required', 'uuid'],
+ 'namespace' => ['nullable', 'string'],
+ ]);
+
+ $copyFromSiteId = $this->request->integer('fromSiteId');
+
+ if (! $site = $this->sites->getSiteById($copyFromSiteId)) {
+ abort(400, "Invalid site ID: $copyFromSiteId");
+ }
+
+ $this->requirePermission("editSite:$site->uid");
+
+ $layoutElementUid = $this->request->input('layoutElementUid');
+ $namespace = $this->request->input('namespace');
+
+ $fromElement = $element::find()
+ ->id($element->id)
+ ->structureId($element->structureId)
+ ->siteId($copyFromSiteId)
+ ->drafts(null)
+ ->provisionalDrafts(null)
+ ->one();
+
+ if (! $fromElement) {
+ throw new UnsupportedSiteException($element, $copyFromSiteId, 'Attempting to copy element content from an unsupported site.');
+ }
+
+ $layoutElement = $element->getFieldLayout()->getElementByUid($layoutElementUid);
+ if (! $layoutElement instanceof BaseField || ! $layoutElement->isCrossSiteCopyable($element)) {
+ abort(400, "Invalid layout element UUID: $layoutElementUid");
+ }
+
+ if ($layoutElement instanceof CustomField) {
+ /** @var FieldInterface&CrossSiteCopyableFieldInterface $field */
+ $field = $layoutElement->getField();
+ $field->copyCrossSiteValue($fromElement, $element);
+ } else {
+ $attribute = $layoutElement->attribute();
+ $element->$attribute = $fromElement->$attribute;
+ }
+
+ $html = InputNamespace::namespaceInputs(
+ html: fn () => $layoutElement->formHtml($element),
+ namespace: $namespace
+ );
+
+ if ($html) {
+ $html = Html::modifyTagAttributes($html, [
+ 'data' => [
+ 'layout-element' => $layoutElement->uid,
+ ],
+ ]);
+ }
+
+ return new ElementResponse()->success($element, t('Field value copied.'), [
+ 'fieldHtml' => $html,
+ 'headHtml' => HtmlStack::headHtml(),
+ 'bodyHtml' => HtmlStack::bodyHtml(),
+ ]);
+ }
+}
diff --git a/src/Http/Controllers/Elements/CreateElementController.php b/src/Http/Controllers/Elements/CreateElementController.php
new file mode 100644
index 00000000000..0825be11189
--- /dev/null
+++ b/src/Http/Controllers/Elements/CreateElementController.php
@@ -0,0 +1,55 @@
+createElement();
+ $element->ruleset->useScenario(ElementRules::SCENARIO_ESSENTIALS);
+
+ if (! $drafts->saveElementAsDraft($element, $request->user()->id, markAsSaved: false)) {
+ return new ElementResponse()->failure($element, mb_ucfirst(t('Couldn’t create {type}.', [
+ 'type' => $element::lowerDisplayName(),
+ ])));
+ }
+
+ // Redirect to its edit page
+ $editUrl = $element->getCpEditUrl() ?? Url::actionUrl('elements/edit', [
+ 'draftId' => $element->draftId,
+ 'siteId' => $element->siteId,
+ ]);
+
+ $response = new ElementResponse()->success($element, t('{type} created.', [
+ 'type' => t('Draft'),
+ ]), array_filter([
+ 'cpEditUrl' => $this->request->isCpRequest ? $editUrl : null,
+ ]));
+
+ if (! $this->request->acceptsJson()) {
+ return redirect(Url::urlWithParams($editUrl, ['fresh' => '1']));
+ }
+
+ return $response;
+ }
+}
diff --git a/src/Http/Controllers/Elements/DeleteElementController.php b/src/Http/Controllers/Elements/DeleteElementController.php
new file mode 100644
index 00000000000..26d4286b50f
--- /dev/null
+++ b/src/Http/Controllers/Elements/DeleteElementController.php
@@ -0,0 +1,77 @@
+request->element();
+
+ // If this is a provisional draft, delete the canonical
+ if ($element && $element->isProvisionalDraft) {
+ $element = $element->getCanonical(true);
+ }
+
+ if (! $element || $element->getIsDraft() || $element->getIsRevision()) {
+ abort(400, 'No element was identified by the request.');
+ }
+
+ Gate::authorize('delete', $element);
+
+ if (! $this->elements->deleteElement($element)) {
+ return new ElementResponse()->failure($element, t('Couldn’t delete {type}.', [
+ 'type' => $element::lowerDisplayName(),
+ ]));
+ }
+
+ return new ElementResponse()->success($element, t('{type} deleted.', [
+ 'type' => $element::displayName(),
+ ]));
+ }
+
+ public function destroyForSite(): Response
+ {
+ $element = $this->request->element(checkForProvisionalDraft: true);
+
+ if (! $element || $element->getIsRevision()) {
+ abort(400, 'No element was identified by the request.');
+ }
+
+ Gate::authorize('deleteForSite', $element);
+
+ $this->elements->deleteElementForSite($element);
+
+ if ($element->isProvisionalDraft) {
+ // see if the canonical element exists for this site
+ $canonical = $element->getCanonical();
+
+ if ($canonical->id !== $element->id) {
+ $element = $canonical;
+
+ $this->elements->deleteElementForSite($element);
+ }
+ }
+
+ return new ElementResponse()->success($element, t('{type} deleted for site.', [
+ 'type' => $element->getIsDraft() && ! $element->isProvisionalDraft
+ ? t('Draft')
+ : $element::displayName(),
+ ]));
+ }
+}
diff --git a/src/Http/Controllers/Elements/DuplicateElementController.php b/src/Http/Controllers/Elements/DuplicateElementController.php
new file mode 100644
index 00000000000..76814d562d4
--- /dev/null
+++ b/src/Http/Controllers/Elements/DuplicateElementController.php
@@ -0,0 +1,162 @@
+request->element();
+
+ if (! $element || $element->getIsRevision()) {
+ abort(400, 'No element was identified by the request.');
+ }
+
+ // save as a new is now available to people who can create drafts
+ $asUnpublishedDraft = $this->request->boolean('asUnpublishedDraft') && $element::hasDrafts();
+ $asUnpublishedDraft
+ ? Gate::authorize('duplicateAsDraft', $element)
+ : Gate::authorize('duplicate', $element);
+
+ $newAttributes = [
+ 'isProvisionalDraft' => false,
+ 'draftId' => null,
+ ];
+
+ if ($asUnpublishedDraft &&
+ ($element->getIsCanonical() || $element->isProvisionalDraft) &&
+ $element->slug === $element->getCanonical()->slug
+ ) {
+ $newAttributes += [
+ 'slug' => null,
+ ];
+ }
+
+ if ($element instanceof NestedElementInterface) {
+ $newAttributes += [
+ 'primaryOwnerId' => $element->getOwnerId(),
+ 'ownerId' => $element->getOwnerId(),
+ 'sortOrder' => null,
+ ];
+ }
+
+ try {
+ $newElement = $this->elements->duplicateElement(
+ $element,
+ $newAttributes,
+ asUnpublishedDraft: $asUnpublishedDraft,
+ );
+ } catch (InvalidElementException $e) {
+ return new ElementResponse()->failure($e->element, t('Couldn’t duplicate {type}.', [
+ 'type' => $element::lowerDisplayName(),
+ ]));
+ }
+
+ // If the original element is a provisional draft,
+ // delete the draft as the changes are likely no longer wanted.
+ if ($this->request->boolean('deleteProvisionalDraft') && $element->isProvisionalDraft) {
+ $this->elements->deleteElement($element);
+ }
+
+ return new ElementResponse()->success($newElement, t('{type} duplicated.', [
+ 'type' => $element::displayName(),
+ ]));
+ }
+
+ public function bulkDuplicate(): Response
+ {
+ $this->request->validate([
+ 'elements' => ['required', 'array'],
+ 'newAttributes' => ['required', 'array'],
+ ]);
+
+ $elementInfo = $this->request->array('elements');
+ $newAttributes = $this->request->array('newAttributes');
+
+ $newElementInfo = [];
+
+ $result = DB::transaction(function () use ($elementInfo, $newAttributes, &$newElementInfo) {
+ return BulkOps::ensure(function () use ($elementInfo, $newAttributes, &$newElementInfo) {
+ foreach ($elementInfo as $info) {
+ $element = $this->request->element($info);
+
+ if (! $element instanceof ElementInterface) {
+ Log::warning(sprintf('Unable to duplicate element: %s', Json::encode($info)), [__METHOD__]);
+
+ continue;
+ }
+
+ $safeNewAttributes = collect($newAttributes)
+ ->only($element->safeAttributes())
+ ->all();
+
+ // if element is a revision, we need to nullify some additional attributes
+ if ($element->getIsRevision()) {
+ $safeNewAttributes['revisionId'] = null;
+
+ if ($element->dateDeleted !== null) {
+ $safeNewAttributes['dateDeleted'] = null;
+ $safeNewAttributes['deletedWithOwner'] = null;
+ $safeNewAttributes['trashed'] = false;
+ }
+ }
+
+ try {
+ $newElement = $this->elements->duplicateElement(
+ $element,
+ $safeNewAttributes + $element::baseBulkDuplicateAttributes(),
+ false,
+ checkAuthorization: true,
+ );
+ } catch (InvalidElementException $e) {
+ return new ElementResponse()->failure($e->element, t('Couldn’t duplicate {type}.', [
+ 'type' => $element::lowerDisplayName(),
+ ]));
+ }
+
+ $newElementInfo[] = $newElement->toArray($newElement->attributes());
+ }
+
+ return null;
+ });
+ });
+
+ if ($result !== null) {
+ return $result;
+ }
+
+ /** @var class-string $elementType */
+ $elementType = $elementInfo[0]['type'];
+
+ return $this->asSuccess(mb_ucfirst(t('{type} duplicated.', [
+ 'type' => count($elementInfo) === 1 ? $elementType::displayName() : $elementType::pluralDisplayName(),
+ ])), [
+ 'newElements' => $newElementInfo,
+ ]);
+ }
+}
diff --git a/src/Http/Controllers/Elements/EditElementController.php b/src/Http/Controllers/Elements/EditElementController.php
new file mode 100644
index 00000000000..9770b0b8b14
--- /dev/null
+++ b/src/Http/Controllers/Elements/EditElementController.php
@@ -0,0 +1,744 @@
+element = $element;
+
+ return $this;
+ }
+
+ public function __invoke(): Response|CpScreenResponse
+ {
+ $strictSite = $this->request->acceptsJson();
+ $elementId = $this->request->route('id') ?? $this->request->integer('elementId');
+
+ /**
+ * @var Element|Response|null $element
+ */
+ $element = $this->element ?? $this->request->element([
+ 'id' => $elementId,
+ ], checkForProvisionalDraft: true, strictSite: $strictSite);
+
+ if ($element instanceof Response) {
+ return $element;
+ }
+
+ if (! $element) {
+ abort(400, 'No element was identified by the request.');
+ }
+
+ // If this is an outdated draft, merge in the latest canonical changes
+ $mergeCanonicalChanges = (
+ $element::trackChanges() &&
+ $element->getIsDraft() &&
+ ! $element->getIsUnpublishedDraft() &&
+ ElementHelper::isOutdated($element)
+ );
+
+ if ($mergeCanonicalChanges) {
+ $this->elements->mergeCanonicalChanges($element);
+ }
+
+ $this->applyParamsToElement($element);
+
+ // Prevalidate?
+ if ($this->request->boolean('prevalidate') && $element->enabled && $element->getEnabledForSite()) {
+ $element->ruleset->useScenario(ElementRules::SCENARIO_LIVE);
+ $element->validate();
+ }
+
+ // Figure out what we're dealing with here
+ $isCanonical = $element->getIsCanonical();
+ $isDraft = $element->getIsDraft();
+ $isUnpublishedDraft = $element->getIsUnpublishedDraft();
+ $isRevision = $element->getIsRevision();
+ $isCurrent = $isCanonical || $element->isProvisionalDraft;
+ $canonical = $element->getCanonical(true);
+
+ // Site info
+ $supportedSites = ElementHelper::supportedSitesForElement($element, true);
+ $allEditableSiteIds = $this->sites->getEditableSiteIds()->all();
+ $propSites = array_values(array_filter($supportedSites, fn ($site) => $site['propagate']));
+ $propSiteIds = array_column($propSites, 'siteId');
+ $propEditableSiteIds = array_intersect($propSiteIds, $allEditableSiteIds);
+ $addlEditableSites = array_values(array_filter($supportedSites, fn ($site) => ! $site['propagate'] && in_array($site['siteId'], $allEditableSiteIds)));
+ $canEditMultipleSites = count($propEditableSiteIds) > 1 || $addlEditableSites;
+
+ // Permissions
+ $canSave = $this->canSave($element, $this->request->user());
+ $canSaveCanonical = Gate::check('saveCanonical', $element);
+ $canCreateDrafts = Gate::check('createDrafts', $canonical);
+ $canDuplicate = ! $isRevision && Gate::check('duplicateAsDraft', $element);
+
+ // Preview targets
+ $previewTargets = $element->id ? $element->getPreviewTargets() : [];
+ $enablePreview = (
+ ! empty($previewTargets) &&
+ ! $this->request->isMobileBrowser(true) &&
+ (
+ ($isDraft && $canSave) ||
+ ($isCurrent && $canCreateDrafts)
+ )
+ );
+
+ if ($previewTargets) {
+ match (true) {
+ $isDraft && ! $element->isProvisionalDraft => SessionAuth::authorize("previewDraft:$element->draftId"),
+ $isRevision => SessionAuth::authorize("previewRevision:$element->revisionId"),
+ default => SessionAuth::authorize("previewElement:$canonical->id"),
+ };
+ }
+
+ // Screen prep
+ [$docTitle, $title] = $this->editElementTitles($element);
+ $enabledForSite = $element->getEnabledForSite();
+ $hasRoute = $element->getRoute() !== null;
+ $redirectUrl = $this->request->getSigned('returnUrl') ?? Url::cpReferralUrl() ?? ElementHelper::postEditUrl($element);
+
+ // Site statuses
+ if ($canEditMultipleSites) {
+ $siteStatuses = ElementHelper::siteStatusesForElement($element, true);
+ } else {
+ $siteStatuses = [
+ $element->siteId => $element->enabled,
+ ];
+ }
+
+ $previewToken = $previewTargets ? Str::random(32, extendedChars: true) : null;
+
+ $notice = match (true) {
+ $element->isProvisionalDraft => $this->draftNotice(...),
+ $isRevision => fn () => $this->revisionNotice($element::lowerDisplayName()),
+ default => null,
+ };
+
+ $enabledSiteIds = $element->enabled && $element->id
+ ? array_flip($this->elements->getEnabledSiteIdsForElement($element->id))
+ : [];
+
+ $response = new CpScreenResponse()
+ ->editUrl($element->getCpEditUrl())
+ ->docTitle($docTitle)
+ ->title($title)
+ ->site($element::isLocalized() ? $element->getSite() : null)
+ ->selectableSites(array_map(fn (int $siteId) => [
+ 'site' => $this->sites->getSiteById($siteId),
+ 'status' => isset($enabledSiteIds[$siteId]) ? 'enabled' : 'disabled',
+ ], $propEditableSiteIds))
+ ->crumbs($this->crumbs($element))
+ ->contextMenuItems(fn () => $this->contextMenuItems(
+ element: $element,
+ isUnpublishedDraft: $isUnpublishedDraft,
+ canCreateDrafts: $canCreateDrafts,
+ ))
+ ->toolbarHtml(
+ // if we're in a slideout, we don't want to add the .flex-grow to the header toolbar
+ // as it'll mess with the width available for the tabs
+ // see https://github.com/craftcms/cms/issues/17260
+ ($this->isSlideout() ? '' : Html::tag('div', attributes: ['class' => 'flex-grow'])).
+ Html::tag('div', attributes: ['class' => 'activity-container']),
+ )
+ ->additionalButtonsHtml(fn () => $this->additionalButtons(
+ element: $element,
+ canonical: $canonical,
+ isRevision: $isRevision,
+ canSave: $canSave,
+ canSaveCanonical: $canSaveCanonical,
+ canCreateDrafts: $canCreateDrafts,
+ canDuplicate: $canDuplicate,
+ previewTargets: $previewTargets,
+ enablePreview: $enablePreview,
+ isCurrent: $isCurrent,
+ isUnpublishedDraft: $isUnpublishedDraft,
+ isDraft: $isDraft
+ ))
+ ->actionMenuItems(fn () => $this->actionMenuItems($element, $previewTargets))
+ ->noticeHtml($notice)
+ ->errorSummary(fn () => new ElementResponse()->errorSummary($element))
+ ->prepareScreen(
+ fn (CpScreenResponse $response, string $containerId) => $this->prepareEditor(
+ $element,
+ $isUnpublishedDraft,
+ $canSave,
+ $response,
+ $containerId,
+ fn (?FieldLayoutForm $form) => $this->editorContent($element, $canSave, $form),
+ fn (?FieldLayoutForm $form) => $this->editorSidebar($element, $mergeCanonicalChanges, $canSave),
+ fn (?FieldLayoutForm $form) => [
+ 'additionalSites' => $addlEditableSites,
+ 'canCreateDrafts' => $canCreateDrafts,
+ 'canEditMultipleSites' => $canEditMultipleSites,
+ 'canSave' => $canSave,
+ 'canSaveCanonical' => $canSaveCanonical,
+ 'elementId' => $element->id,
+ 'canonicalId' => $canonical->id,
+ 'draftId' => $element->draftId,
+ 'draftName' => $isDraft ? $element->draftName : null,
+ 'elementType' => $element::class,
+ 'enablePreview' => $enablePreview,
+ 'enabledForSite' => $element->enabled && $enabledForSite,
+ 'hashedCpEditUrl' => Crypt::encrypt('{cpEditUrl}'),
+ 'isLive' => $isCurrent && ! $element->getIsDraft() && $element->enabled && $enabledForSite && $hasRoute,
+ 'isProvisionalDraft' => $element->isProvisionalDraft,
+ 'isUnpublishedDraft' => $isUnpublishedDraft,
+ 'previewTargets' => $previewTargets,
+ 'previewToken' => $previewToken,
+ 'hashedPreviewToken' => $previewToken ? Crypt::encrypt($previewToken) : null,
+ 'previewParamValue' => $previewTargets ? Crypt::encrypt(Str::random(10)) : null,
+ 'revisionId' => $element->revisionId,
+ 'fieldId' => $element instanceof NestedElementInterface ? $element->getField()?->id : null,
+ 'ownerId' => $element instanceof NestedElementInterface ? $element->getOwnerId() : null,
+ 'siteId' => $element->siteId,
+ 'siteStatuses' => $siteStatuses,
+ 'siteToken' => (! app()->isLive() || ! $element->getSite()->getEnabled()) ? Crypt::encrypt((string) $element->siteId) : null,
+ 'visibleLayoutElements' => $form?->getVisibleElements() ?? [],
+ 'staticLayoutElements' => $form?->getStaticElements() ?? [],
+ 'updatedTimestamp' => $element->dateUpdated?->getTimestamp(),
+ 'canonicalUpdatedTimestamp' => $canonical->dateUpdated?->getTimestamp(),
+ 'isStatic' => $isRevision || ! $canSave,
+ ]
+ )
+ );
+
+ if ($canSave) {
+ match (true) {
+ $isUnpublishedDraft => $response->when(
+ value: $canSaveCanonical,
+ callback: fn (CpScreenResponse $response) => $response
+ ->submitButtonLabel(mb_ucfirst(t('Create {type}', [
+ 'type' => $element::lowerDisplayName(),
+ ])))
+ ->action('elements/apply-draft')
+ ->redirectUrl("$redirectUrl#"),
+ default: fn (CpScreenResponse $response) => $response
+ ->action('elements/save-draft')
+ ->redirectUrl("$redirectUrl#")
+ ),
+ $element->isProvisionalDraft => $response
+ ->action('elements/apply-draft')
+ ->redirectUrl("$redirectUrl#"),
+ $isDraft => $response
+ ->submitButtonLabel(mb_ucfirst(t('Save {type}', [
+ 'type' => t('draft'),
+ ])))
+ ->action('elements/save-draft')
+ ->redirectUrl('{cpEditUrl}'),
+ default => $response
+ ->action('elements/save')
+ ->redirectUrl("$redirectUrl#")
+ };
+
+ $response
+ ->saveShortcutRedirectUrl('{cpEditUrl}')
+ ->altActions($element->getAltActions());
+ }
+
+ return $response;
+ }
+
+ private function draftNotice(): string
+ {
+ return
+ Html::beginTag('div', [
+ 'class' => 'draft-notice',
+ ]).
+ Html::tag('div', '', [
+ 'class' => ['draft-icon'],
+ 'aria' => ['hidden' => 'true'],
+ 'data' => ['icon' => 'edit'],
+ ]).
+ Html::tag('p', t('Showing your unsaved changes.')).
+ Html::button(t('Discard'), [
+ 'class' => ['discard-changes-btn', 'btn'],
+ ]).
+ Html::endTag('div');
+ }
+
+ private function revisionNotice(string $elementType): string
+ {
+ return
+ Html::beginTag('div', [
+ 'class' => 'content-notice',
+ ]).
+ Html::tag('div', '', [
+ 'class' => ['content-notice-icon'],
+ 'aria' => ['hidden' => 'true'],
+ 'data' => ['icon' => 'lightbulb'],
+ ]).
+ Html::tag('p', t('You’re viewing a revision. None of the {type}’s fields are editable.', [
+ 'type' => $elementType,
+ ])).
+ Html::endTag('div');
+ }
+
+ private function contextMenuItems(
+ ElementInterface $element,
+ bool $isUnpublishedDraft,
+ bool $canCreateDrafts,
+ ): array {
+ if ($element->isProvisionalDraft) {
+ $element = $element->getCanonical(true);
+ }
+
+ if (! $element->id || $element->getIsUnpublishedDraft()) {
+ return [];
+ }
+
+ if (! $isUnpublishedDraft) {
+ $drafts = $element::find()
+ ->draftOf($element)
+ ->siteId($element->siteId)
+ ->status(null)
+ ->orderByDesc('dateUpdated')
+ ->with(['draftCreator'])
+ ->get()
+ ->filter(fn (ElementInterface $draft) => $this->request->user()->can('view', $draft))
+ ->all();
+ } else {
+ $drafts = [];
+ }
+
+ $revisionsPageUrl = null;
+ $hasMoreRevisions = false;
+
+ if ($element->hasRevisions() && Cms::config()->maxRevisions !== 1) {
+ $revisionsQuery = $element::find()
+ ->revisionOf($element)
+ ->siteId($element->siteId)
+ ->status(null)
+ ->offset(1)
+ ->limit(Cms::config()->maxRevisions ? min(Cms::config()->maxRevisions - 1, 10) : 10)
+ ->orderByDesc('dateCreated')
+ ->with(['revisionCreator']);
+
+ $revisions = (clone $revisionsQuery)->get();
+ $revisionsPageUrl = $element->getCpRevisionsUrl();
+
+ if ($revisionsPageUrl) {
+ $hasMoreRevisions = ($revisionsQuery->getCountForPagination() - 1) > 0;
+ }
+ } else {
+ $revisions = collect();
+ }
+
+ // if we're viewing a revision, make sure it's in the list
+ if (
+ $element->getIsRevision() &&
+ $revisions->doesntContain(fn (ElementInterface $revision) => $revision->id === $element->id)
+ ) {
+ $revisions[] = $element;
+ }
+
+ if (empty($drafts) && empty($revisions) && ! $canCreateDrafts) {
+ return [];
+ }
+
+ $formatter = I18N::getFormatter();
+
+ $baseParams = $this->request->query();
+ unset($baseParams['draftId'], $baseParams['revisionId'], $baseParams['siteId'], $baseParams['fresh']);
+ if (isset(Cms::config()->pathParam)) {
+ unset($baseParams[Cms::config()->pathParam]);
+ }
+
+ $isDraft = $element->getIsDraft();
+ $isRevision = $element->getIsRevision();
+ $cpEditUrl = Url::cpUrl($element->getCpEditUrl(), [
+ 'draftId' => null,
+ 'revisionId' => null,
+ ]);
+
+ $revision = $element->getCurrentRevision();
+ $creator = $revision?->getRevisionCreator();
+ $timestamp = $formatter->asTimestamp($revision->dateCreated ?? $element->dateUpdated, Locale::LENGTH_SHORT, true);
+
+ $items = [
+ [
+ 'heading' => t('Context'),
+ 'headingTag' => 'h2',
+ 'headingAttributes' => ['class' => ['visually-hidden']],
+ 'listAttributes' => ['class' => ['revision-group-current']],
+ 'items' => [
+ [
+ 'label' => t('Current'),
+ 'description' => $creator
+ ? t('Saved {timestamp} by {creator}', [
+ 'timestamp' => $timestamp,
+ 'creator' => $creator->name,
+ ])
+ : t('Last saved {timestamp}', [
+ 'timestamp' => $timestamp,
+ ]),
+ 'url' => $cpEditUrl,
+ 'selected' => ! $isDraft && ! $isRevision,
+ ],
+ ],
+ ],
+ ];
+
+ if (! empty($drafts)) {
+ $items[] = [
+ 'heading' => t('Drafts'),
+ 'listAttributes' => ['class' => ['revision-group-drafts']],
+ 'items' => array_map(function ($draft) use ($element, $formatter, $cpEditUrl, $baseParams) {
+ /** @var ElementInterface $draft */
+ $creator = $draft->getDraftCreator();
+ $timestamp = $formatter->asTimestamp($draft->dateUpdated, Locale::LENGTH_SHORT, true);
+ $timestampWithDate = $formatter->asDatetime($draft->dateUpdated, Locale::LENGTH_SHORT);
+
+ return [
+ 'label' => $draft->draftName,
+ 'description' => $creator
+ ? Template::raw(t('Saved by {creator}', [
+ 'timestampWithDate' => $timestampWithDate,
+ 'timestamp' => $timestamp,
+ 'creator' => Html::encode($creator->name),
+ ]))
+ : Template::raw(t('Last saved ', [
+ 'timestampWithDate' => $timestampWithDate,
+ 'timestamp' => $timestamp,
+ ])),
+ 'url' => Url::urlWithParams($cpEditUrl, array_merge($baseParams, [
+ 'draftId' => $draft->draftId,
+ ])),
+ 'selected' => $draft->id === $element->id,
+ ];
+ }, $drafts),
+ ];
+ }
+
+ if (! empty($revisions)) {
+ $items[] = [
+ 'heading' => t('Recent Revisions'),
+ 'listAttributes' => ['class' => ['revision-group-revisions']],
+ 'items' => $revisions->map(function (ElementInterface $revision) use ($element, $formatter, $cpEditUrl, $baseParams) {
+ $creator = $revision->getRevisionCreator();
+ $timestamp = $formatter->asTimestamp($revision->dateCreated, Locale::LENGTH_SHORT, true);
+ $timestampWithDate = $formatter->asDatetime($revision->dateCreated, Locale::LENGTH_SHORT);
+
+ return [
+ 'label' => $revision->getRevisionLabel(),
+ 'description' => $creator
+ ? Template::raw(t('Saved by {creator}', [
+ 'timestampWithDate' => $timestampWithDate,
+ 'timestamp' => $timestamp,
+ 'creator' => Html::encode($creator->name),
+ ]))
+ : Template::raw(t('Saved ', [
+ 'timestampWithDate' => $timestampWithDate,
+ 'timestamp' => $timestamp,
+ ])),
+ 'url' => Url::urlWithParams($cpEditUrl, array_merge($baseParams, [
+ 'revisionId' => $revision->revisionId,
+ ])),
+ 'selected' => $revision->id === $element->id,
+ ];
+ })->all(),
+ ];
+ }
+
+ if ($hasMoreRevisions && $revisionsPageUrl) {
+ $items[] = ['type' => MenuItemType::HR];
+ $items[] = [
+ 'label' => t('View all revisions'),
+ 'url' => $revisionsPageUrl,
+ 'attributes' => [
+ 'class' => ['go'],
+ ],
+ ];
+ }
+
+ return $items;
+ }
+
+ private function isSlideout(): bool
+ {
+ return $this->request->hasHeader('X-Craft-Container-Id');
+ }
+
+ private function additionalButtons(
+ ElementInterface $element,
+ ElementInterface $canonical,
+ bool $isRevision,
+ bool $canSave,
+ bool $canSaveCanonical,
+ bool $canCreateDrafts,
+ bool $canDuplicate,
+ ?array $previewTargets,
+ bool $enablePreview,
+ bool $isCurrent,
+ bool $isUnpublishedDraft,
+ bool $isDraft,
+ ): string {
+ $components = [];
+
+ // Preview (View will be added later by JS)
+ if ($previewTargets) {
+ $components[] =
+ Html::beginTag('div', [
+ 'class' => ['preview-btn-container', 'btngroup'],
+ ]).
+ ($enablePreview
+ ? Html::beginTag('button', [
+ 'type' => 'button',
+ 'class' => ['preview-btn', 'btn'],
+ ]).
+ Html::tag('span', t('Preview'), ['class' => 'label']).
+ Html::endTag('button')
+ : '').
+ Html::endTag('div');
+ }
+
+ // Create a draft
+ if ($isCurrent && ! $isUnpublishedDraft && $canCreateDrafts) {
+ if ($canSave) {
+ $components[] = Html::button(t('Create a draft'), [
+ 'class' => ['btn', 'formsubmit'],
+ 'data' => [
+ 'action' => 'elements/save-draft',
+ 'redirect' => Crypt::encrypt('{cpEditUrl}'),
+ 'params' => ['dropProvisional' => 1],
+ ],
+ ]);
+ } else {
+ $components[] = Html::beginForm().
+ Html::actionInput('elements/save-draft').
+ Html::redirectInput('{cpEditUrl}').
+ Html::hiddenInput('elementId', (string) $canonical->id).
+ Html::button(t('Create a draft'), [
+ 'class' => ['btn', 'formsubmit'],
+ ]).
+ Html::endForm();
+ }
+ }
+
+ if (! $canSave && $canDuplicate) {
+ // save as a new is now available to people who can create drafts
+ $components[] = Html::beginForm().
+ Html::actionInput('elements/duplicate').
+ Html::redirectInput('{cpEditUrl}').
+ Html::hiddenInput('elementId', (string) $canonical->id).
+ Html::hiddenInput('asUnpublishedDraft', '1').
+ Html::button(t('Save as a new {type}', ['type' => $element::lowerDisplayName()]), [
+ 'class' => ['btn', 'formsubmit'],
+ ]).
+ Html::endForm();
+ }
+
+ // Apply draft
+ if ($isDraft && ! $isCurrent && $canSave && $canSaveCanonical) {
+ $components[] = Html::button(t('Apply draft'), [
+ 'class' => ['btn', 'secondary', 'formsubmit', 'tooltip-draft-btn'],
+ 'data' => [
+ 'action' => 'elements/apply-draft',
+ 'redirect' => Crypt::encrypt('{cpEditUrl}'),
+ ],
+ ]);
+ }
+
+ // Revert content from this revision
+ if ($isRevision && $canSaveCanonical && $element->hasRevisions()) {
+ $components[] = Html::beginForm().
+ Html::actionInput('elements/revert').
+ Html::redirectInput('{cpEditUrl}').
+ Html::hiddenInput('elementId', (string) $canonical->id).
+ Html::hiddenInput('revisionId', (string) $element->revisionId).
+ Html::button(t('Revert content from this revision'), [
+ 'class' => ['btn', 'formsubmit', 'revision-draft-btn'],
+ ]).
+ Html::endForm();
+ }
+
+ $components[] = $element->getAdditionalButtons();
+
+ return implode("\n", array_filter($components));
+ }
+
+ private function actionMenuItems(ElementInterface $element, array $previewTargets): array
+ {
+ if (! $element->id) {
+ return [];
+ }
+
+ $hideViewAction = ! empty($previewTargets) && ! $this->isSlideout();
+
+ return array_filter(
+ $element->getActionMenuItems(),
+ function (array $item) use ($hideViewAction) {
+ // filter out "Edit" item - no point showing edit action on the edit page,
+ if (str_starts_with($item['id'] ?? '', 'action-edit-')) {
+ return false;
+ }
+
+ // and "View in a new tab" item, if we have at least one preview target, and it's not a slideout
+ // as that action is already covered by the "View" button;
+ // (https://github.com/craftcms/cms/issues/16556)
+ if ($hideViewAction && str_starts_with($item['id'] ?? '', 'action-view-')) {
+ return false;
+ }
+
+ return true;
+ },
+ );
+ }
+
+ private function prepareEditor(
+ ElementInterface $element,
+ bool $isUnpublishedDraft,
+ bool $canSave,
+ CpScreenResponse $response,
+ string $containerId,
+ callable $contentFn,
+ callable $sidebarFn,
+ callable $jsSettingsFn,
+ ) {
+ $fieldLayout = $element->getFieldLayout();
+ $form = $fieldLayout?->createForm($element, ! $canSave, [
+ 'registerDeltas' => true,
+ ]);
+ $contentHtml = $contentFn($form);
+ $sidebarHtml = $sidebarFn($form);
+
+ if ($contentHtml === '' && $sidebarHtml !== '' && $this->request->acceptsJson()) {
+ $contentHtml = Html::tag('div', $sidebarHtml, [
+ 'class' => 'details',
+ ]);
+ $sidebarHtml = '';
+ $response->slideoutBodyClass = 'so-full-details';
+ }
+
+ if ($canSave) {
+ $components = [];
+
+ if ($element->id) {
+ // don't use the canonical ID if this is a normal element that's keeping track of its canonical
+ // e.g. nested Matrix entries that were duplicated for an owner's draft
+ $id = $element->getIsDraft() || $element->getIsRevision() ? $element->getCanonicalId() : $element->id;
+ $components[] = Html::hiddenInput('elementId', (string) $id);
+ }
+
+ if ($element->siteId) {
+ $components[] = Html::hiddenInput('siteId', (string) $element->siteId);
+ }
+
+ if ($element->fieldLayoutId) {
+ $components[] = Html::hiddenInput('fieldLayoutId', (string) $element->fieldLayoutId);
+ }
+
+ if ($isUnpublishedDraft && $this->request->boolean('fresh')) {
+ $components[] = Html::hiddenInput('fresh', '1');
+ }
+
+ if ($this->request->boolean('updateSearchIndexImmediately')) {
+ $components[] = Html::hiddenInput('updateSearchIndexImmediately', '1');
+ }
+
+ $components[] = $contentHtml;
+ $contentHtml = implode("\n", $components);
+ }
+
+ $response->tabs($form?->getTabMenu() ?? []);
+ $response->contentHtml($contentHtml);
+ $response->metaSidebarHtml($sidebarHtml);
+
+ $settings = $jsSettingsFn($form);
+
+ if ($this->isSlideout()) {
+ HtmlStack::jsWithVars(fn ($settings) => << <<prepareEditScreen($response, $containerId);
+ }
+
+ private function editorContent(ElementInterface $element, bool $canSave, ?FieldLayoutForm $form): string
+ {
+ $html = $form?->render() ?? '';
+
+ event($event = new DefineElementEditorContent($element, $html, ! $canSave));
+
+ return trim($event->html);
+ }
+
+ private function editorSidebar(ElementInterface $element, bool $mergedCanonicalChanges, bool $canSave): string
+ {
+ $components = [];
+
+ if ($mergedCanonicalChanges) {
+ $components[] =
+ Html::beginTag('div', [
+ 'class' => ['meta', 'warning'],
+ ]).
+ Html::tag('p', t('Recent changes to the Current revision have been merged into this draft.')).
+ Html::endTag('div');
+ }
+
+ $components[] = $element->getSidebarHtml(! $canSave);
+
+ if ($this->request->route()?->getActionName()) {
+ $components[] = app(ContentHtml::class)->metadataHtml($element->getMetadata());
+ }
+
+ return trim(implode("\n", $components));
+ }
+}
diff --git a/src/Http/Controllers/Elements/ElementActivityController.php b/src/Http/Controllers/Elements/ElementActivityController.php
new file mode 100644
index 00000000000..2d7b4156ecb
--- /dev/null
+++ b/src/Http/Controllers/Elements/ElementActivityController.php
@@ -0,0 +1,43 @@
+request->element();
+
+ if ($element instanceof Response) {
+ return $element;
+ }
+
+ if (! $element || $element->getIsRevision()) {
+ abort(400, 'No element was identified by the request.');
+ }
+
+ $activity = $this->elementActivity->getRecentActivity($element, $this->request->user()->id);
+
+ $this->elementActivity->trackActivity($element, ElementActivityType::View, $this->request->user());
+
+ return new JsonResponse([
+ 'activity' => $activity->map(fn (ElementActivityData $record) => $record->toActivityRow($element))->all(),
+ 'updatedTimestamp' => $element->dateUpdated->getTimestamp(),
+ 'canonicalUpdatedTimestamp' => $element->getCanonical()->dateUpdated->getTimestamp(),
+ ]);
+ }
+}
diff --git a/src/Http/Controllers/Elements/ElementDraftsController.php b/src/Http/Controllers/Elements/ElementDraftsController.php
new file mode 100644
index 00000000000..b022c151c54
--- /dev/null
+++ b/src/Http/Controllers/Elements/ElementDraftsController.php
@@ -0,0 +1,392 @@
+request->element();
+
+ // this can happen if we're creating e.g. nested entry in a matrix field (cards or element index)
+ // and we hit "create entry" before the autosave kicks in
+ if ($element instanceof Response) {
+ return $element;
+ }
+
+ if (! $element || $element->getIsRevision()) {
+ abort(400, 'No element was identified by the request.');
+ }
+
+ $provisional = $this->request->boolean('provisional');
+
+ if (! $element->getIsDraft() && ! $provisional) {
+ Gate::authorize('createDrafts', $element);
+ } elseif (! $this->canSave($element, $this->request->user())) {
+ abort(403, 'User not authorized to save this element.');
+ }
+
+ if (! $element->getIsDraft() && $provisional) {
+ // Make sure a provisional draft doesn't already exist for this element/user combo
+ $existingProvisionalDraft = $element::find()
+ ->provisionalDrafts()
+ ->draftOf($element->id)
+ ->draftCreator($this->request->user()->id)
+ ->site('*')
+ ->status(null)
+ ->one();
+
+ if ($existingProvisionalDraft) {
+ Log::warning("Overwriting an existing provisional draft for element/user $element->id/{$this->request->user()->id}", [__METHOD__]);
+
+ $this->elements->deleteElement($existingProvisionalDraft, true);
+ }
+ }
+
+ // Keep track of all newly-created draft IDs
+ $draftElementIds = [];
+ $draftElementUids = [];
+
+ Event::listen(function (DraftCreated $event) use (&$draftElementIds, &$draftElementUids) {
+ $draftElementIds[$event->canonical->id] = $event->draft->id;
+ $draftElementUids[$event->canonical->uid] = $event->draft->uid;
+ });
+
+ DB::beginTransaction();
+
+ try {
+ // Are we creating the draft here?
+ if (! $element->getIsDraft()) {
+ /** @var Element $element */
+ $draft = $this->drafts->createDraft(
+ canonical: $element,
+ creatorId: $this->request->user()->id,
+ provisional: $provisional,
+ );
+
+ $draft->setCanonical($element);
+
+ $element = $draft;
+ }
+
+ // keep track of the original field layout ID, in case it changes here
+ $oldFieldLayoutId = $element->getFieldLayout()?->id;
+
+ $this->applyParamsToElement($element);
+
+ // Make sure nothing just changed that would prevent the user from saving
+ if (! $this->canSave($element, $this->request->user())) {
+ abort(403, 'User not authorized to save this element.');
+ }
+
+ if ($this->request->boolean('dropProvisional')) {
+ $element->isProvisionalDraft = false;
+ }
+
+ $element->ruleset->useScenario(ElementRules::SCENARIO_ESSENTIALS);
+
+ // If the field layout ID changed, save all content
+ $saveContent = $element->getFieldLayout()?->id !== $oldFieldLayoutId;
+
+ if (! $this->elements->saveElement($element, saveContent: $saveContent)) {
+ DB::rollBack();
+
+ return new ElementResponse()->failure($element, mb_ucfirst(t('Couldn’t save {type}.', [
+ 'type' => t('draft'),
+ ])));
+ }
+
+ DB::commit();
+ } catch (Throwable $e) {
+ DB::rollBack();
+ throw $e;
+ }
+
+ $this->elementActivity->trackActivity($element, ElementActivityType::Save);
+
+ $data = [
+ 'canonicalId' => $element->getCanonicalId(),
+ 'elementId' => $element->id,
+ 'draftId' => $element->draftId,
+ 'timestamp' => I18N::getFormatter()->asTimestamp($element->dateUpdated, 'short', true),
+ 'creator' => $element->getDraftCreator()?->getName(),
+ 'draftName' => $element->draftName,
+ 'draftNotes' => $element->draftNotes,
+ 'modifiedAttributes' => $element->getModifiedAttributes(),
+ 'draftElementIds' => $draftElementIds,
+ 'draftElementUids' => $draftElementUids,
+ ];
+
+ if ($this->request->isCpRequest()) {
+ [$docTitle, $title] = $this->editElementTitles($element);
+ $previewTargets = $element->getPreviewTargets();
+ $data += $this->fieldLayoutData($element, [
+ 'registerDeltas' => true,
+ ]);
+ $data += [
+ 'docTitle' => $docTitle,
+ 'title' => $title,
+ 'previewTargets' => $previewTargets,
+ 'previewParamValue' => $previewTargets ? Crypt::encrypt(Str::random(10)) : null,
+ 'deltaNames' => DeltaRegistry::getNames(),
+ 'initialDeltaValues' => DeltaRegistry::getInitialValues(),
+ 'updatedTimestamp' => $element->dateUpdated->getTimestamp(),
+ 'canonicalUpdatedTimestamp' => $element->getCanonical()->dateUpdated->getTimestamp(),
+ ];
+ }
+
+ // Make sure the user is authorized to preview the draft
+ SessionAuth::authorize("previewDraft:$element->draftId");
+
+ return new ElementResponse()->success($element, t('{type} saved.', [
+ 'type' => t('Draft'),
+ ]), $data, true);
+ }
+
+ public function ensure(): Response
+ {
+ $element = $this->request->element(checkForProvisionalDraft: true);
+
+ if (! $element || $element->getIsRevision()) {
+ abort(400, 'No element was identified by the request.');
+ }
+
+ if ($element->getIsDraft()) {
+ return $this->asSuccess(data: [
+ 'elementId' => $element->id,
+ ]);
+ }
+
+ Gate::authorize('createDrafts', $element);
+
+ // Make sure a provisional draft doesn't already exist for this element/user combo
+ $provisionalId = $element::find()
+ ->provisionalDrafts()
+ ->draftOf($element->id)
+ ->draftCreator($this->request->user()->id)
+ ->site('*')
+ ->status(null)
+ ->ids()[0] ?? null;
+
+ if ($provisionalId) {
+ return $this->asSuccess(data: [
+ 'elementId' => $provisionalId,
+ ]);
+ }
+
+ $draft = $this->drafts->createDraft(
+ canonical: $element,
+ creatorId: $this->request->user()->id,
+ provisional: true,
+ );
+
+ return $this->asSuccess(data: [
+ 'elementId' => $draft->id,
+ ]);
+ }
+
+ public function apply(): Response
+ {
+ $element = $this->request->element();
+
+ // this can happen if creating element via slideout, and we hit "create entry" before the autosave kicks in
+ if ($element instanceof Response) {
+ return $element;
+ }
+
+ if (! $element || ! $element->getIsDraft()) {
+ abort(400, 'No draft was identified by the request.');
+ }
+
+ // keep track of the original field layout ID, in case it changes here
+ $oldFieldLayoutId = $element->getFieldLayout()?->id;
+
+ $this->applyParamsToElement($element);
+
+ Gate::authorize('save', $element);
+
+ $isUnpublishedDraft = $element->getIsUnpublishedDraft();
+
+ if (! Gate::check('saveCanonical', $element)) {
+ abort(403, $isUnpublishedDraft
+ ? 'User not authorized to create this element.'
+ : 'User not authorized to save this element.'
+ );
+ }
+
+ // Validate and save the draft
+ if ($element->enabled && $element->getEnabledForSite()) {
+ $element->ruleset->useScenario(ElementRules::SCENARIO_LIVE);
+ }
+
+ // if we're about to apply an unpublished draft, set propagateRequired to true
+ if ($isUnpublishedDraft) {
+ $element->propagateRequired = true;
+ }
+
+ $element->applyingDraft = true;
+
+ // If the field layout ID changed, save all content
+ $saveContent = $element->getFieldLayout()?->id !== $oldFieldLayoutId;
+
+ $namespace = $this->request->header('X-Craft-Namespace');
+ $crossSiteValidate = $namespace === null && Sites::isMultiSite();
+
+ if (! $this->elements->saveElement(
+ element: $element,
+ crossSiteValidate: $crossSiteValidate,
+ saveContent: $saveContent,
+ )) {
+ // save the draft anyway, so we don’t lose the latest changes
+ // (see https://github.com/craftcms/cms/issues/18657)
+ $errors = $element->getErrors();
+ $invalidNestedElementIds = $element->getInvalidNestedElementIds();
+ $element->ruleset->useScenario(ElementRules::SCENARIO_ESSENTIALS);
+ $this->elements->saveElement(element: $element, saveContent: $saveContent);
+ $element->clearErrors();
+ $element->errors()->merge($errors);
+ $element->addInvalidNestedElementIds($invalidNestedElementIds);
+
+ return new ElementResponse()->applyDraftFailure($element);
+ }
+
+ $element->applyingDraft = false;
+
+ if (! $isUnpublishedDraft) {
+ $mutex = Cache::lock("element:$element->canonicalId", 15);
+ if (! $mutex->get()) {
+ abort(500, 'Could not acquire a lock to save the element.');
+ }
+ }
+
+ $attributes = [];
+
+ if ($element instanceof NestedElementInterface) {
+ $attributes['updateSearchIndexForOwner'] = true;
+ }
+
+ try {
+ $element->propagateRequired = false;
+ $canonical = $this->drafts->applyDraft($element, $attributes);
+ } catch (InvalidElementException) {
+ return new ElementResponse()->applyDraftFailure($element);
+ } finally {
+ if (! $isUnpublishedDraft) {
+ $mutex->release();
+ }
+ }
+
+ $this->elementActivity->trackActivity($canonical, ElementActivityType::Save);
+
+ if (! $this->request->expectsJson()) {
+ // Tell all browser windows about the element save
+ session()->broadcastToJs([
+ 'event' => 'saveElement',
+ 'id' => $canonical->id,
+ ]);
+
+ if (! $isUnpublishedDraft) {
+ session()->broadcastToJs([
+ 'event' => 'deleteDraft',
+ 'canonicalId' => $element->getCanonicalId(),
+ 'draftId' => $element->draftId,
+ ]);
+ }
+ }
+
+ $message = match (true) {
+ $isUnpublishedDraft => t('{type} created.', [
+ 'type' => $element::displayName(),
+ ]),
+ $element->isProvisionalDraft => t('{type} saved.', [
+ 'type' => $element::displayName(),
+ ]),
+ default => t('Draft applied.'),
+ };
+
+ return new ElementResponse()->success($canonical, $message, supportsAddAnother: true);
+ }
+
+ public function destroy(): Response
+ {
+ $element = $this->request->element();
+
+ if ($element instanceof Response) {
+ return $element;
+ }
+
+ if (! $element || ! $element->getIsDraft()) {
+ abort(400, 'No draft was identified by the request.');
+ }
+
+ Gate::authorize('delete', $element);
+
+ if (! $this->elements->deleteElement($element, true)) {
+ return new ElementResponse()->failure($element, t('Couldn’t delete {type}.', [
+ 'type' => t('draft'),
+ ]));
+ }
+
+ $message = $element->isProvisionalDraft
+ ? t('Changes discarded.')
+ : t('{type} deleted.', [
+ 'type' => t('Draft'),
+ ]);
+
+ if (! $this->request->acceptsJson()) {
+ // Tell all browser windows about the draft deletion
+ session()->broadcastToJs([
+ 'event' => 'deleteDraft',
+ 'canonicalId' => $element->getCanonicalId(),
+ 'draftId' => $element->draftId,
+ ]);
+ }
+
+ return new ElementResponse()->success($element, $message);
+ }
+}
diff --git a/src/Http/Controllers/Elements/ElementIndex/ElementIndexController.php b/src/Http/Controllers/Elements/ElementIndex/ElementIndexController.php
new file mode 100644
index 00000000000..ec9bd91eb9a
--- /dev/null
+++ b/src/Http/Controllers/Elements/ElementIndex/ElementIndexController.php
@@ -0,0 +1,158 @@
+request->elementType();
+ [$sourceKey, $source] = $this->resolveSource($elementType, $this->request->input('source'), $this->request->context());
+ $elementQueryState = $this->buildElementQueryState(
+ elementType: $elementType,
+ source: $source,
+ condition: $this->request->condition(),
+ );
+
+ $total = $elementType::indexElementCount($elementQueryState['query'], $sourceKey);
+ $unfilteredTotal = $elementQueryState['unfilteredQuery']
+ ? $elementType::indexElementCount($elementQueryState['unfilteredQuery'], $sourceKey)
+ : $total;
+
+ return new JsonResponse([
+ 'resultSet' => $this->request->input('resultSet'),
+ 'total' => $total,
+ 'unfilteredTotal' => $unfilteredTotal,
+ ]);
+ }
+
+ public function filterHud(CurrentElementIndex $currentElementIndex): JsonResponse
+ {
+ $elementType = $this->request->elementType();
+ $context = $this->request->context();
+ [$sourceKey, $source] = $this->resolveSource($elementType, $this->request->input('source'), $context);
+ $fieldLayouts = $this->resolveFieldLayouts();
+ $currentCondition = $this->resolveElementIndexCondition();
+ $id = $this->request->input('id');
+
+ abort_if($id === null || $id === '', 400, 'Request missing required body param');
+
+ $conditionConfig = $this->request->input('conditionConfig');
+ $serialized = $this->request->input('serialized');
+
+ if (! $conditionConfig && $serialized) {
+ parse_str((string) $serialized, $conditionConfig);
+ $conditionConfig = $conditionConfig['condition'] ?? null;
+ }
+
+ /** @var ElementConditionInterface $condition */
+ $condition = $conditionConfig
+ ? $this->conditions->createCondition($conditionConfig)
+ : $elementType::createCondition();
+
+ if (! empty($fieldLayouts)) {
+ $condition->setFieldLayouts($fieldLayouts);
+ }
+
+ $condition->mainTag = 'div';
+ $condition->id = (string) $id;
+ $condition->addRuleLabel = t('Add a filter');
+
+ $this->populateFilterHudQueryParams($condition, $source, $sourceKey, $currentCondition);
+ $currentElementIndex->activate();
+
+ return new JsonResponse([
+ 'hudHtml' => $condition->getBuilderHtml(),
+ 'headHtml' => HtmlStack::headHtml(),
+ 'bodyHtml' => HtmlStack::bodyHtml(),
+ ]);
+ }
+
+ public function elementTableHtml(): JsonResponse
+ {
+ $this->request->validate([
+ 'id' => ['required', 'integer', 'min:1'],
+ ]);
+
+ $elementType = $this->request->elementType();
+ [$sourceKey, $source] = $this->resolveSource($elementType, $this->request->input('source'), $this->request->context());
+ $elementQuery = $this->buildElementQueryState(
+ elementType: $elementType,
+ source: $source,
+ condition: $this->request->condition(),
+ )['query'];
+
+ abort_if(! $sourceKey, 400, 'Request missing required body param');
+
+ /** @var ElementInterface|null $element */
+ $element = (clone $elementQuery)
+ ->draftOf($this->request->integer('id'))
+ ->draftCreator($this->request->user())
+ ->provisionalDrafts()
+ ->status(null)
+ ->one();
+
+ if (! $element) {
+ /** @var ElementInterface|null $element */
+ $element = (clone $elementQuery)
+ ->id($this->request->integer('id'))
+ ->status(null)
+ ->one();
+ }
+
+ abort_if(! $element, 400, 'Invalid element ID: '.$this->request->integer('id'));
+
+ $attributes = $this->elementSources->getTableAttributes(
+ elementType: $elementType,
+ sourceKey: $sourceKey,
+ customAttributes: $this->resolveViewState()['tableColumns'] ?? null,
+ fieldLayouts: $this->resolveFieldLayouts(),
+ );
+
+ $attributeHtml = [];
+
+ foreach ($attributes as [$attribute]) {
+ $attributeHtml[$attribute] = $element->getAttributeHtml($attribute);
+ }
+
+ return new JsonResponse([
+ 'attributeHtml' => $attributeHtml,
+ ]);
+ }
+}
diff --git a/src/Http/Controllers/Elements/ElementIndex/ElementIndexSourcesController.php b/src/Http/Controllers/Elements/ElementIndex/ElementIndexSourcesController.php
new file mode 100644
index 00000000000..dcf6a63d0f5
--- /dev/null
+++ b/src/Http/Controllers/Elements/ElementIndex/ElementIndexSourcesController.php
@@ -0,0 +1,112 @@
+request->validate([
+ 'stepKey' => ['required', 'string'],
+ ]);
+
+ $elementType = $this->request->elementType();
+ $sourceKey = $this->request->input('source', '');
+ $stepKey = $this->request->input('stepKey');
+
+ $currentElementIndex->activate();
+
+ return new JsonResponse([
+ 'sourcePath' => $elementType::sourcePath(
+ sourceKey: $sourceKey,
+ stepKey: $stepKey,
+ context: $this->request->context(),
+ ),
+ ]);
+ }
+
+ public function sourceAttributeInfo(CurrentElementIndex $currentElementIndex): JsonResponse
+ {
+ $elementType = $this->request->elementType();
+ $context = $this->request->context();
+ [$sourceKey] = $this->resolveSource($elementType, $this->request->input('source'), $context);
+ $fieldLayouts = $this->resolveFieldLayouts();
+
+ $currentElementIndex->activate();
+
+ if (! $sourceKey) {
+ return new JsonResponse([
+ 'sortOptions' => [],
+ 'tableColumns' => [],
+ 'defaultTableColumns' => [],
+ ]);
+ }
+
+ $sortOptions = $this->elementSources->getSourceSortOptions($elementType, $sourceKey)
+ ->map(fn (array $option) => [
+ 'label' => $option['label'],
+ 'attr' => $option['attribute'] ?? $option['orderBy'],
+ 'defaultDir' => $option['defaultDir'] ?? 'asc',
+ ])
+ ->values()
+ ->all();
+
+ $tableColumns = $this->elementSources->getSourceTableAttributes($elementType, $sourceKey)
+ ->map(fn (array $attribute, string $key) => [
+ ...$attribute,
+ 'attr' => $key,
+ ])
+ ->values()
+ ->all();
+
+ $defaultTableColumns = $this->elementSources->getTableAttributes(
+ elementType: $elementType,
+ sourceKey: $sourceKey,
+ fieldLayouts: $fieldLayouts,
+ )
+ ->map(fn (array $attribute) => $attribute[0])
+ ->filter(fn (string $attribute) => $attribute !== 'title')
+ ->values()
+ ->all();
+
+ return new JsonResponse(compact(
+ 'sortOptions',
+ 'tableColumns',
+ 'defaultTableColumns',
+ ));
+ }
+
+ public function getSourceTreeHtml(CurrentElementIndex $currentElementIndex): JsonResponse
+ {
+ $currentElementIndex->activate();
+
+ $sources = $this->elementSources->getSources(
+ elementType: $elementType = $this->request->elementType(),
+ context: $this->request->context(),
+ )->all();
+
+ return new JsonResponse([
+ 'html' => template('_elements/sources', [
+ 'elementType' => $elementType,
+ 'sources' => $sources,
+ ]),
+ ]);
+ }
+}
diff --git a/src/Http/Controllers/Elements/ExportElementIndexController.php b/src/Http/Controllers/Elements/ElementIndex/ExportElementIndexController.php
similarity index 77%
rename from src/Http/Controllers/Elements/ExportElementIndexController.php
rename to src/Http/Controllers/Elements/ElementIndex/ExportElementIndexController.php
index e7f272d041f..6f40d8b8840 100644
--- a/src/Http/Controllers/Elements/ExportElementIndexController.php
+++ b/src/Http/Controllers/Elements/ElementIndex/ExportElementIndexController.php
@@ -2,17 +2,16 @@
declare(strict_types=1);
-namespace CraftCms\Cms\Http\Controllers\Elements;
+namespace CraftCms\Cms\Http\Controllers\Elements\ElementIndex;
use Closure;
-use craft\base\ElementInterface;
use CraftCms\Cms\Element\Contracts\ElementExporterInterface;
+use CraftCms\Cms\Element\Contracts\ElementInterface;
use CraftCms\Cms\Element\ElementExporters;
-use CraftCms\Cms\Element\ElementSources;
use CraftCms\Cms\Element\Exceptions\InvalidTypeException;
use CraftCms\Cms\Element\Exporters\Raw;
use CraftCms\Cms\Http\Controllers\Elements\Concerns\InteractsWithElementIndexes;
-use Illuminate\Http\Request;
+use CraftCms\Cms\Http\Requests\ElementIndexRequest;
use Symfony\Component\HttpFoundation\Response;
readonly class ExportElementIndexController
@@ -20,7 +19,7 @@
use InteractsWithElementIndexes;
public function __construct(
- private Request $request,
+ private ElementIndexRequest $request,
private ElementExporters $elementExporters,
) {}
@@ -42,11 +41,11 @@ function (string $attribute, mixed $value, Closure $fail): void {
/** @var class-string $elementType */
$elementType = $validated['elementType'];
- $context = $this->request->input('context', ElementSources::CONTEXT_INDEX);
+ $context = $this->request->context();
- [$sourceKey, $source] = $this->source($elementType, $this->request->input('source'), $context);
+ [$sourceKey, $source] = $this->resolveSource($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');
+ abort_if(! $this->request->isAdministrative(), 400, 'Request missing index context');
$exporters = $this->availableExporters($elementType, $sourceKey);
$exporter = $this->elementExporters->resolveExporter(
@@ -58,7 +57,11 @@ function (string $attribute, mixed $value, Closure $fail): void {
return $this->elementExporters->export(
exporter: $exporter,
- query: $this->elementQuery($elementType, $source, $this->condition()),
+ query: $this->buildElementQueryState(
+ elementType: $elementType,
+ source: $source,
+ condition: $this->request->condition()
+ )['query'],
format: $this->request->input('format', 'csv'),
);
}
diff --git a/src/Http/Controllers/Elements/ElementIndex/SaveElementIndexElementsController.php b/src/Http/Controllers/Elements/ElementIndex/SaveElementIndexElementsController.php
new file mode 100644
index 00000000000..6e9929c9e11
--- /dev/null
+++ b/src/Http/Controllers/Elements/ElementIndex/SaveElementIndexElementsController.php
@@ -0,0 +1,143 @@
+request->elementType();
+
+ $this->request->validate([
+ 'siteId' => ['required', 'integer', 'min:1'],
+ 'namespace' => ['required', 'string'],
+ $namespace = $this->request->input('namespace') => ['required', 'array'],
+ ]);
+
+ $data = $this->request->array($namespace);
+
+ $elements = $this->getElements(
+ elementType: $elementType,
+ siteId: $this->request->integer('siteId'),
+ data: $data,
+ );
+
+ if ($elements->isEmpty()) {
+ throw ValidationException::withMessages([
+ $namespace => 'No valid element IDs provided.',
+ ]);
+ }
+
+ foreach ($elements as $element) {
+ Gate::authorize('save', $element);
+ }
+
+ $errors = $this->validateElements($elements, $namespace, $data);
+
+ if (! empty($errors)) {
+ return new JsonResponse([
+ 'errors' => $errors,
+ ]);
+ }
+
+ DB::transaction(function () use ($elements) {
+ foreach ($elements as $element) {
+ if (! $this->elements->saveElement($element)) {
+ Log::error("Couldn’t save element {$element->id}: ".implode(', ', $element->getFirstErrors()));
+ abort(500, "Couldn’t save element {$element->id}");
+ }
+ }
+ });
+
+ return $this->asSuccess();
+ }
+
+ /**
+ * @param class-string $elementType
+ * @param array $data
+ * @return Collection
+ */
+ private function getElements(string $elementType, int $siteId, array $data): Collection
+ {
+ $elementIds = array_map(
+ fn (string $key): int => (int) Str::chopStart($key, 'element-'),
+ array_keys($data),
+ );
+
+ /** @var Collection */
+ return $elementType::find()
+ ->id($elementIds)
+ ->status(null)
+ ->drafts(null)
+ ->provisionalDrafts(null)
+ ->siteId($siteId)
+ ->get();
+ }
+
+ /**
+ * @param Collection $elements
+ * @param array $data
+ * @return array>>
+ */
+ private function validateElements(Collection $elements, string $namespace, array $data): array
+ {
+ $errors = [];
+
+ foreach ($elements as $element) {
+ $attributes = Arr::except($data["element-$element->id"] ?? [], 'fields');
+
+ if (! empty($attributes)) {
+ $element->ruleset->withScenario(
+ ElementRules::SCENARIO_LIVE,
+ fn () => $element->setAttributesFromRequest($attributes),
+ );
+ }
+
+ $element->setFieldValuesFromRequest("$namespace.element-$element->id.fields");
+
+ if ($element->getIsUnpublishedDraft()) {
+ $element->ruleset->useScenario(ElementRules::SCENARIO_ESSENTIALS);
+ } elseif ($element->enabled && $element->getEnabledForSite()) {
+ $element->ruleset->useScenario(ElementRules::SCENARIO_LIVE);
+ }
+
+ $names = array_merge(
+ array_keys($attributes),
+ array_map(
+ fn (string $handle): string => "field:$handle",
+ array_keys($data["element-$element->id"]['fields'] ?? []),
+ ),
+ );
+
+ if (! $element->validate($names)) {
+ $errors[$element->getCanonicalId()] = $element->errors()->getMessages();
+ }
+ }
+
+ return $errors;
+ }
+}
diff --git a/src/Http/Controllers/Elements/ElementRedirectController.php b/src/Http/Controllers/Elements/ElementRedirectController.php
new file mode 100644
index 00000000000..9a320dff56c
--- /dev/null
+++ b/src/Http/Controllers/Elements/ElementRedirectController.php
@@ -0,0 +1,53 @@
+request->route('id');
+ $uid = $this->request->route('uid');
+
+ if (is_numeric($id)) {
+ $id = (int) $id;
+ } else {
+ $id = null;
+ }
+
+ $element = $this->request->element([
+ 'id' => $id,
+ 'uid' => $uid,
+ ]);
+
+ if ($element instanceof Response) {
+ return $element;
+ }
+
+ $url = $element->getCpEditUrl();
+
+ abort_if(! $url, 500, 'The element doesn’t have an edit page.');
+
+ $editUrl = Url::removeParam(Url::cpUrl('edit'), 'site');
+
+ if (str_starts_with((string) $url, $editUrl)) {
+ return app(EditElementController::class)->setElement($element)();
+ }
+
+ return redirect($url);
+ }
+}
diff --git a/src/Http/Controllers/Elements/ElementRevisionsController.php b/src/Http/Controllers/Elements/ElementRevisionsController.php
new file mode 100644
index 00000000000..4ebd78cf136
--- /dev/null
+++ b/src/Http/Controllers/Elements/ElementRevisionsController.php
@@ -0,0 +1,84 @@
+request->element([
+ 'id' => $this->request->route('id'),
+ ]);
+
+ if ($element->getIsUnpublishedDraft()) {
+ abort(400, 'Unpublished drafts don\'t have revisions');
+ }
+
+ if (! $element->hasRevisions()) {
+ abort(400, 'Element doesn\'t have revisions');
+ }
+
+ return new CpScreenResponse()
+ ->title(t('Revisions for “{title}”', [
+ 'title' => $element->getUiLabel(),
+ ]))
+ ->crumbs([
+ ...$this->crumbs($element, current: false),
+ [
+ 'label' => t('Revisions'),
+ 'current' => true,
+ ],
+ ])
+ ->contentTemplate('_elements/revisions', [
+ 'element' => $element,
+ 'revisionsQuery' => $element::find()
+ ->revisionOf($element)
+ ->site('*')
+ ->preferSites([$element->siteId])
+ ->unique()
+ ->status(null)
+ ->whereNot('elements.dateCreated', Query::prepareDateForDb($element->dateUpdated))
+ ->with(['revisionCreator']),
+ ]);
+ }
+
+ public function revert(Revisions $revisions, ElementActivity $elementActivity): Response
+ {
+ $element = $this->request->element();
+
+ if (! $element || ! $element->getIsRevision()) {
+ abort(400, 'No revision was identified by the request.');
+ }
+
+ Gate::authorize('save', $element->getCanonical(true));
+
+ $canonical = $revisions->revertToRevision($element, $this->request->user()->id);
+
+ $elementActivity->trackActivity($canonical, ElementActivityType::Save);
+
+ return new ElementResponse()->success($canonical, t('{type} reverted to past revision.', [
+ 'type' => $element::displayName(),
+ ]));
+ }
+}
diff --git a/src/Http/Controllers/Elements/ElementSelectorModalController.php b/src/Http/Controllers/Elements/ElementSelectorModalController.php
new file mode 100644
index 00000000000..c63288f16c4
--- /dev/null
+++ b/src/Http/Controllers/Elements/ElementSelectorModalController.php
@@ -0,0 +1,60 @@
+validate([
+ 'showSiteMenu' => ['nullable', 'in:0,1'],
+ 'sources' => ['nullable', 'array'],
+ 'sources.*' => ['string'],
+ ]);
+
+ $elementType = $request->elementType();
+ $currentElementIndex->activate();
+ $condition = $request->condition();
+ $hasStatuses = $elementType::hasStatuses();
+
+ if ($hasStatuses) {
+ $statuses = $elementType::statuses();
+
+ if ($condition) {
+ /** @var StatusConditionRule|null $statusRule */
+ $statusRule = collect($condition->getConditionRules())
+ ->firstWhere(fn ($rule) => $rule instanceof StatusConditionRule);
+
+ if ($statusRule) {
+ $statusValues = $statusRule->getValues();
+ $statuses = collect($statuses)
+ ->filter(function ($info, string $status) use ($statusRule, $statusValues) {
+ $inValues = in_array($status, $statusValues);
+
+ return $statusRule->operator === 'in' ? $inValues : ! $inValues;
+ });
+ }
+ }
+ }
+
+ return new JsonResponse([
+ 'html' => $elementIndexHtml->html($elementType, [
+ 'class' => 'content',
+ 'context' => $request->context(),
+ 'registerJs' => false,
+ 'showSiteMenu' => $request->input('showSiteMenu', 'auto'),
+ 'showStatusMenu' => $hasStatuses,
+ 'sources' => $request->input('sources'),
+ 'statuses' => $statuses ?? null,
+ ]),
+ ]);
+ }
+}
diff --git a/yii2-adapter/legacy/controllers/ElementIndexSettingsController.php b/src/Http/Controllers/Elements/ElementSourcesController.php
similarity index 71%
rename from yii2-adapter/legacy/controllers/ElementIndexSettingsController.php
rename to src/Http/Controllers/Elements/ElementSourcesController.php
index 280177e25b1..23af62bf907 100644
--- a/yii2-adapter/legacy/controllers/ElementIndexSettingsController.php
+++ b/src/Http/Controllers/Elements/ElementSourcesController.php
@@ -1,67 +1,40 @@
- * @since 3.0.0
- */
-class ElementIndexSettingsController extends BaseElementsController
+readonly class ElementSourcesController
{
- /**
- * @inheritdoc
- */
- public function beforeAction($action): bool
- {
- if (!parent::beforeAction($action)) {
- return false;
- }
-
- $this->requireAcceptsJson();
- $this->requireAdmin();
+ use RespondsWithFlash;
- return true;
- }
-
- /**
- * Returns all the info needed by the Customize Sources modal.
- *
- * @return Response
- */
- public function actionGetCustomizeSourcesModalData(): Response
+ public function show(ElementIndexRequest $request, ElementSources $elementSources, Fields $fields, UserGroups $userGroups): JsonResponse
{
/** @var class-string $elementType */
- $elementType = $this->elementType();
+ $elementType = $request->elementType();
// Global sort options
- $baseSortOptions = Collection::make($elementType::sortOptions())
- ->map(fn($option, $key) => [
+ $baseSortOptions = collect($elementType::sortOptions())
+ ->map(fn ($option, $key) => [
'label' => $option['label'] ?? $option,
'attr' => $option['attribute'] ?? $option['orderBy'] ?? $key,
'defaultDir' => $option['defaultDir'] ?? 'asc',
@@ -70,17 +43,16 @@ public function actionGetCustomizeSourcesModalData(): Response
->all();
// Get the source info
- $sourcesService = app(ElementSources::class);
- $sources = $sourcesService->getSources($elementType, ElementSources::CONTEXT_INDEX, true)->all();
+ $sources = $elementSources->getSources($elementType, ElementSources::CONTEXT_INDEX, true)->all();
$multiPage = $elementType::multiPageSources();
foreach ($sources as &$source) {
if ($multiPage) {
// ensure we're using the EN translation here
- $language = Craft::$app->language;
- Craft::$app->language = Craft::$app->sourceLanguage;
+ $language = app()->getLocale();
+ app()->setLocale('en');
$source['page'] ??= $elementType::pluralDisplayName();
- Craft::$app->language = $language;
+ app()->setLocale($language);
}
if ($source['type'] === ElementSources::TYPE_HEADING) {
@@ -99,8 +71,8 @@ public function actionGetCustomizeSourcesModalData(): Response
: null,
]),
$baseSortOptions,
- $sourcesService->getSourceSortOptions($elementType, $source['key'])
- ->map(fn($option) => [
+ $elementSources->getSourceSortOptions($elementType, $source['key'])
+ ->map(fn ($option) => [
'label' => $option['label'],
'attr' => $option['attribute'] ?? $option['orderBy'],
'defaultDir' => $option['defaultDir'] ?? 'asc',
@@ -114,16 +86,16 @@ public function actionGetCustomizeSourcesModalData(): Response
if (isset($source['defaultSort'])) {
if (is_string($source['defaultSort'])) {
- $defaultSortOption = Collection::make($source['sortOptions'])->firstWhere('attr', $source['defaultSort']);
+ $defaultSortOption = collect($source['sortOptions'])->firstWhere('attr', $source['defaultSort']);
} elseif (is_array($source['defaultSort']) && isset($source['defaultSort'][0])) {
- $defaultSortOption = Collection::make($source['sortOptions'])->firstWhere('attr', $source['defaultSort'][0]);
+ $defaultSortOption = collect($source['sortOptions'])->firstWhere('attr', $source['defaultSort'][0]);
if ($defaultSortOption && isset($source['defaultSort'][1])) {
$defaultSortDir = $source['defaultSort'][1];
}
}
}
- if (!$defaultSortOption) {
+ if (! $defaultSortOption) {
$defaultSortOption = reset($source['sortOptions']);
}
@@ -134,14 +106,14 @@ public function actionGetCustomizeSourcesModalData(): Response
// Available custom field attributes
$source['availableTableAttributes'] = [];
- foreach ($sourcesService->getSourceTableAttributes($elementType, $source['key']) as $key => $labelInfo) {
+ foreach ($elementSources->getSourceTableAttributes($elementType, $source['key']) as $key => $labelInfo) {
$source['availableTableAttributes'][] = [$key, $labelInfo['label']];
}
// Selected table attributes
- $tableAttributes = $sourcesService->getTableAttributes($elementType, $source['key'])->all();
+ $tableAttributes = $elementSources->getTableAttributes($elementType, $source['key'])->all();
array_shift($tableAttributes);
- $source['tableAttributes'] = array_map(fn($a) => [$a[0], $a[1]['label']], $tableAttributes);
+ $source['tableAttributes'] = array_map(fn ($a) => [$a[0], $a[1]['label']], $tableAttributes);
if ($source['type'] === ElementSources::TYPE_CUSTOM) {
if (isset($source['condition'])) {
@@ -161,7 +133,7 @@ public function actionGetCustomizeSourcesModalData(): Response
if (isset($source['sites'])) {
$source['sites'] = array_values(array_filter(array_map(
- fn(int $siteId) => Sites::getSiteById($siteId)?->uid,
+ fn (int $siteId) => Sites::getSiteById($siteId)?->uid,
$source['sites'] ?: [],
)));
}
@@ -176,13 +148,13 @@ public function actionGetCustomizeSourcesModalData(): Response
}
unset($source);
- $viewModes = array_map(fn(array $viewMode) => array_merge($viewMode, [
+ $viewModes = array_map(fn (array $viewMode) => array_merge($viewMode, [
'iconSvg' => Icons::svg($viewMode['icon'] ?? 'table'),
]), $elementType::indexViewModes());
// Get the default sort options for custom sources
- $defaultSortOptions = $sourcesService->getSourceSortOptions($elementType, 'custom:x')
- ->map(fn(array $option) => [
+ $defaultSortOptions = $elementSources->getSourceSortOptions($elementType, 'custom:x')
+ ->map(fn (array $option) => [
'label' => $option['label'],
'attr' => $option['attribute'] ?? $option['orderBy'],
'defaultDir' => $option['defaultDir'] ?? 'asc',
@@ -193,14 +165,14 @@ public function actionGetCustomizeSourcesModalData(): Response
// Get the available table attributes
$availableTableAttributes = [];
- foreach ($sourcesService->getAvailableTableAttributes($elementType) as $key => $labelInfo) {
+ foreach ($elementSources->getAvailableTableAttributes($elementType) as $key => $labelInfo) {
$availableTableAttributes[] = [$key, $labelInfo['label']];
}
// Get previewable custom fields that should be available for all custom sources
$customFieldAttributes = [];
- foreach (app(Fields::class)->getLayoutsByType($elementType) as $fieldLayout) {
+ foreach ($fields->getLayoutsByType($elementType) as $fieldLayout) {
foreach ($fieldLayout->getCustomFields() as $field) {
if ($field instanceof PreviewableFieldInterface) {
$customFieldAttributes[] = ["field:$field->uid", t($field->name, category: 'site')];
@@ -220,19 +192,17 @@ public function actionGetCustomizeSourcesModalData(): Response
$conditionBuilderHtml = $condition->getBuilderHtml();
$conditionBuilderJs = HtmlStack::clearJsBuffer();
- $userGroups = UserGroups::getAllGroups()
- ->map(fn(UserGroup $group) => [
+ $userGroups = $userGroups->getAllGroups()
+ ->map(fn (UserGroup $group) => [
'label' => t($group->name, category: 'site'),
'value' => $group->uid,
])
->all();
- $pageSettings = $sourcesService->getPageSettings($elementType);
-
- return $this->asJson([
+ return new JsonResponse([
'multiPage' => $multiPage,
'sources' => $sources,
- 'pageSettings' => $pageSettings,
+ 'pageSettings' => $elementSources->getPageSettings($elementType),
'viewModes' => $viewModes,
'baseSortOptions' => $baseSortOptions,
'defaultSortOptions' => $defaultSortOptions,
@@ -247,39 +217,33 @@ public function actionGetCustomizeSourcesModalData(): Response
]);
}
- /**
- * Saves the Customize Sources modal settings.
- *
- * @return Response
- */
- public function actionSaveCustomizeSourcesModalSettings(): Response
+ public function store(ElementIndexRequest $request, ElementSources $elementSources, ProjectConfig $projectConfig)
{
- $elementType = $this->elementType();
+ $elementType = $request->elementType();
$multiPage = $elementType::multiPageSources();
// Get the old source configs
- $projectConfig = app(ProjectConfig::class);
- $oldSourceConfigs = $projectConfig->get(ProjectConfig::PATH_ELEMENT_SOURCES . ".$elementType") ?? [];
- $oldSourceConfigs = Collection::make($oldSourceConfigs)
+ $oldSourceConfigs = $projectConfig->get(ProjectConfig::PATH_ELEMENT_SOURCES.".$elementType") ?? [];
+ $oldSourceConfigs = collect($oldSourceConfigs)
->keyBy('key')
->all();
- $sourceOrder = $this->request->getBodyParam('sourceOrder', []);
- $sourceSettings = $this->request->getBodyParam('sources', []);
+ $sourceOrder = $request->array('sourceOrder');
+ $sourceSettings = $request->array('sources');
$newSourceConfigs = [];
$disabledSourceKeys = [];
if ($multiPage) {
- $sourcePages = $this->request->getBodyParam('sourcePages', []);
- $pageSettings = $this->request->getBodyParam('pageSettings', []);
+ $sourcePages = $request->array('sourcePages');
+ $pageSettings = $request->array('pageSettings');
$sourcePageIndexes = [];
}
// Normalize to the way it's stored in the DB
foreach ($sourceOrder as $key) {
$type = match (true) {
- str_starts_with($key, 'custom:') => ElementSources::TYPE_CUSTOM,
- str_starts_with($key, 'heading:') => ElementSources::TYPE_HEADING,
+ str_starts_with((string) $key, 'custom:') => ElementSources::TYPE_CUSTOM,
+ str_starts_with((string) $key, 'heading:') => ElementSources::TYPE_HEADING,
default => ElementSources::TYPE_NATIVE,
};
@@ -325,14 +289,14 @@ public function actionSaveCustomizeSourcesModalSettings(): Response
} elseif ($type === ElementSources::TYPE_HEADING) {
$sourceConfig['heading'] = $postedSettings['heading'];
} elseif (isset($postedSettings['enabled'])) {
- $sourceConfig['disabled'] = !$postedSettings['enabled'];
+ $sourceConfig['disabled'] = ! $postedSettings['enabled'];
if ($sourceConfig['disabled']) {
$disabledSourceKeys[] = $key;
}
}
} elseif (isset($oldSourceConfigs[$key])) {
$sourceConfig += $oldSourceConfigs[$key];
- if (!empty($sourceConfig['disabled'])) {
+ if (! empty($sourceConfig['disabled'])) {
$disabledSourceKeys[] = $key;
}
} elseif ($isCustom) {
@@ -352,18 +316,16 @@ public function actionSaveCustomizeSourcesModalSettings(): Response
array_multisort($sourcePageIndexes, SORT_NUMERIC, range(1, count($newSourceConfigs)), SORT_NUMERIC, $newSourceConfigs);
}
- $sourcesService = app(ElementSources::class);
- $sourcesService->saveSources($elementType, $newSourceConfigs);
+ $elementSources->saveSources($elementType, $newSourceConfigs);
+
if ($multiPage) {
- $sourcesService->savePageSettings($elementType, array_map(
- fn(array $settings) => array_filter($settings, fn($setting) => $setting !== null && $setting !== ''),
+ $elementSources->savePageSettings($elementType, array_map(
+ fn (array $settings) => array_filter($settings, fn ($setting) => $setting !== null && $setting !== ''),
$pageSettings,
));
}
- Craft::$app->getSession()->setSuccess(t('Source settings saved'));
-
- return $this->asSuccess(data: [
+ return $this->asSuccess(t('Source settings saved'), data: [
'disabledSourceKeys' => $disabledSourceKeys,
]);
}
diff --git a/src/Http/Controllers/Elements/PerformElementActionController.php b/src/Http/Controllers/Elements/PerformElementActionController.php
index fd8ad0884cf..5e39fc529de 100644
--- a/src/Http/Controllers/Elements/PerformElementActionController.php
+++ b/src/Http/Controllers/Elements/PerformElementActionController.php
@@ -4,70 +4,52 @@
namespace CraftCms\Cms\Http\Controllers\Elements;
-use Closure;
-use craft\base\ElementInterface;
-use CraftCms\Cms\Element\Contracts\ElementExporterInterface;
+use CraftCms\Cms\Element\Contracts\ElementInterface;
+use CraftCms\Cms\Element\CurrentElementIndex;
use CraftCms\Cms\Element\ElementActions;
-use CraftCms\Cms\Element\ElementExporters;
use CraftCms\Cms\Element\ElementSources;
-use CraftCms\Cms\Element\Exceptions\InvalidTypeException;
-use CraftCms\Cms\Element\Queries\Contracts\ElementQueryInterface;
use CraftCms\Cms\Http\Controllers\Elements\Concerns\InteractsWithElementIndexes;
+use CraftCms\Cms\Http\Requests\ElementIndexRequest;
+use CraftCms\Cms\Http\Resources\ElementIndexResource;
use CraftCms\Cms\Http\RespondsWithFlash;
-use CraftCms\Cms\Support\Facades\HtmlStack;
-use CraftCms\Cms\Support\Html;
use CraftCms\Cms\Translation\I18N as TranslationI18N;
-use Illuminate\Http\Request;
use Symfony\Component\HttpFoundation\Response as SymfonyResponse;
-use function CraftCms\Cms\t;
-
readonly class PerformElementActionController
{
use InteractsWithElementIndexes;
use RespondsWithFlash;
public function __construct(
- private Request $request,
+ private ElementIndexRequest $request,
private ElementActions $elementActions,
private ElementSources $elementSources,
private TranslationI18N $i18N,
- private ElementExporters $elementExporters,
) {}
- public function __invoke(): SymfonyResponse
+ public function __invoke(CurrentElementIndex $currentElementIndex): SymfonyResponse
{
$validated = $this->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'];
+ $elementType = $this->request->elementType();
$actionClass = $validated['elementAction'];
$elementIds = $validated['elementIds'];
- $context = $this->request->input('context', ElementSources::CONTEXT_INDEX);
+ $context = $this->request->context();
+
+ [$sourceKey, $source] = $this->resolveSource($elementType, $this->request->input('source'), $context);
+ $queryState = $this->buildElementQueryState($elementType, $source, $this->request->condition());
+ $elementQuery = $queryState['query'];
- [$sourceKey, $source] = $this->source($elementType, $this->request->input('source'), $context);
- $condition = $this->condition();
- $viewState = $this->viewState();
- $elementQuery = $this->elementQuery($elementType, $source, $condition);
+ $currentElementIndex->activate($elementQuery);
$actions = null;
- $exporters = null;
- if ($this->isAdministrative($context) && isset($sourceKey)) {
- $actions = $this->availableActions($elementType, $sourceKey, $elementQuery);
- $exporters = $this->availableExporters($elementType, $sourceKey);
+ if ($this->request->isAdministrative($context) && isset($sourceKey)) {
+ $actions = $this->elementActions->availableActions($elementType, $sourceKey, $elementQuery);
}
$action = $this->elementActions->resolveAction($actions ?? [], $actionClass);
@@ -102,17 +84,7 @@ function (string $attribute, mixed $value, Closure $fail): void {
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,
- );
+ $responseData = new ElementIndexResource()->toArray($this->request);
$formatter = $this->i18N->getFormatter();
@@ -128,104 +100,4 @@ function (string $attribute, mixed $value, Closure $fail): void {
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/Http/Controllers/Elements/PreviewElementController.php b/src/Http/Controllers/Elements/PreviewElementController.php
new file mode 100644
index 00000000000..134e8fe11b3
--- /dev/null
+++ b/src/Http/Controllers/Elements/PreviewElementController.php
@@ -0,0 +1,68 @@
+request->element([
+ 'id' => $id,
+ ], checkForProvisionalDraft: true);
+
+ if ($element instanceof Response) {
+ return $element;
+ }
+
+ abort_if(is_null($element), 400, 'No element was identified by the request.');
+
+ $redirectUrl = $this->request->getSigned('returnUrl', ElementHelper::postEditUrl($element));
+
+ HtmlStack::jsWithVars(fn (
+ $elementType,
+ $elementId,
+ $draftId,
+ $revisionId,
+ $siteId,
+ $redirectUrl,
+ ) => << {
+ const preview = new Craft.Preview({
+ elementType: $elementType,
+ elementId: $elementId,
+ draftId: $draftId,
+ revisionId: $revisionId,
+ siteId: $siteId,
+ standaloneMode: true,
+ redirectUrl: $redirectUrl,
+ })
+ preview.open();
+ })();
+ JS, [
+ $element::class,
+ $element->isProvisionalDraft ? $element->getCanonicalId() : $element->id,
+ ! $element->isProvisionalDraft ? $element->draftId : null,
+ $element->revisionId,
+ $element->siteId,
+ $redirectUrl,
+ ]);
+
+ [$docTitle, $title] = $this->editElementTitles($element);
+
+ return view('_layouts/base', compact('docTitle', 'title'));
+ }
+}
diff --git a/src/Http/Controllers/Elements/SaveElementController.php b/src/Http/Controllers/Elements/SaveElementController.php
new file mode 100644
index 00000000000..9760dfc7692
--- /dev/null
+++ b/src/Http/Controllers/Elements/SaveElementController.php
@@ -0,0 +1,241 @@
+request->element();
+
+ if ($element instanceof Response) {
+ return $element;
+ }
+
+ if (! $element || $element->getIsDraft() || $element->getIsRevision()) {
+ abort(400, 'No element was identified by the request.');
+ }
+
+ // Check save permissions before and after applying POST params to the element
+ // in case the request was tampered with.
+ Gate::authorize('save', $element);
+
+ $this->applyParamsToElement($element);
+
+ Gate::authorize('save', $element);
+
+ if ($element->enabled && $element->getEnabledForSite()) {
+ $element->ruleset->useScenario(ElementRules::SCENARIO_LIVE);
+ }
+
+ $isNotNew = $element->id;
+ if ($isNotNew) {
+ $mutex = Cache::lock("element:$element->id", 15);
+ if (! $mutex->get()) {
+ abort(500, 'Could not acquire a lock to save the element.');
+ }
+ }
+
+ if ($element instanceof NestedElementInterface && property_exists($element, 'updateSearchIndexForOwner')) {
+ $element->updateSearchIndexForOwner = true;
+ }
+
+ try {
+ $namespace = $this->request->header('X-Craft-Namespace');
+ // crossSiteValidate only if it's multisite, element supports drafts and we're not in a slideout
+ $success = $this->elements->saveElement(
+ $element,
+ crossSiteValidate: (
+ $namespace === null
+ && $this->sites->isMultiSite()
+ && Gate::check('createDrafts', $element)
+ ),
+ );
+ } catch (UnsupportedSiteException $e) {
+ $element->errors()->add('siteId', $e->getMessage());
+ $success = false;
+ } finally {
+ if ($isNotNew) {
+ $mutex->release();
+ }
+ }
+
+ if (! $success) {
+ return new ElementResponse()->failure($element, mb_ucfirst(t('Couldn’t save {type}.', [
+ 'type' => $element::lowerDisplayName(),
+ ])));
+ }
+
+ $this->elementActivity->trackActivity($element, ElementActivityType::Save);
+
+ // See if the user happens to have a provisional element. If so delete it.
+ $provisional = $element::find()
+ ->provisionalDrafts()
+ ->draftOf($element->id)
+ ->draftCreator($this->request->user())
+ ->siteId($element->siteId)
+ ->status(null)
+ ->one();
+
+ if ($provisional) {
+ $this->elements->deleteElement($provisional, true);
+ }
+
+ if (! $this->request->acceptsJson()) {
+ // Tell all browser windows about the element save
+ session()->broadcastToJs([
+ 'event' => 'saveElement',
+ 'id' => $element->id,
+ ]);
+ }
+
+ return new ElementResponse()->success($element, t('{type} saved.', [
+ 'type' => $element::displayName(),
+ ]), supportsAddAnother: true);
+ }
+
+ public function storeForDerivative(): Response
+ {
+ if (! $this->request->has('newOwnerId')) {
+ abort(400, 'No new owner was identified by the request.');
+ }
+
+ $element = $this->request->element();
+
+ if (
+ ! $element instanceof NestedElementInterface ||
+ ! $element->getOwnerId() ||
+ ! $element->getIsDraft() ||
+ $element->getIsCanonical()
+ ) {
+ abort(400, 'No element was identified by the request.');
+ }
+
+ // Check save permissions before and after applying POST params to the element
+ // in case the request was tampered with.
+ Gate::authorize('save', $element);
+
+ // Get the new owner and make sure it's a derivative element,
+ // and that its canonical element is the nested element's primary owner
+ $owner = $this->elements->getElementById($this->request->integer('newOwnerId'), siteId: $element->siteId);
+
+ if ($owner->getIsCanonical()) {
+ abort(400, 'The owner element must be a derivative.');
+ }
+
+ if ($owner->getCanonicalId() !== $element->getPrimaryOwnerId()) {
+ // the owner might be a derivative of another canonical element
+ $canonicalOwner = $owner->getCanonical();
+ if ($canonicalOwner->getCanonicalId() !== $element->getPrimaryOwnerId()) {
+ abort(400, 'The canonical owner element must be the primary owner of the nested element.');
+ }
+ }
+
+ Gate::authorize('save', $owner);
+
+ // Get the old sort order
+ $sortOrder = DB::table(Table::ELEMENTS_OWNERS)
+ ->where('elementId', $element->id)
+ ->where('ownerId', $element->getOwnerId())
+ ->value('sortOrder');
+
+ $element->setSortOrder($sortOrder);
+
+ DB::beginTransaction();
+
+ try {
+ // Remove existing ownership data for the element within the canonical owner,
+ // and for its canonical element within the derivative
+ DB::table(Table::ELEMENTS_OWNERS)
+ ->where('elementId', $element->id)
+ ->where('ownerId', $owner->getCanonicalId())
+ ->orWhere(fn (Builder $query) => $query
+ ->where('elementId', $element->getCanonicalId())
+ ->where('ownerId', $owner->id)
+ )
+ ->delete();
+
+ // Remove existing ownership data for the element within the canonical owner
+ DB::table(Table::ELEMENTS_OWNERS)
+ ->where('elementId', $element->id)
+ ->where('ownerId', $owner->getCanonicalId())
+ ->delete();
+
+ // Remove the draft data, but preserve the canonicalId
+ $element->setPrimaryOwner($owner);
+ $element->setOwner($owner);
+
+ $this->elements->saveElement($element);
+
+ $this->applyParamsToElement($element);
+
+ Gate::authorize('save', $element);
+
+ if ($element->enabled && $element->getEnabledForSite()) {
+ $element->ruleset->useScenario(ElementRules::SCENARIO_LIVE);
+ }
+
+ try {
+ $success = $this->elements->saveElement($element);
+ } catch (UnsupportedSiteException $e) {
+ $element->errors()->add('siteId', $e->getMessage());
+ $success = false;
+ }
+
+ if (! $success) {
+ DB::rollBack();
+
+ return new ElementResponse()->failure($element, mb_ucfirst(t('Couldn’t save {type}.', [
+ 'type' => $element::lowerDisplayName(),
+ ])));
+ }
+
+ if ($element->getIsDraft()) {
+ $this->drafts->removeDraftData($element);
+ }
+
+ DB::commit();
+ } catch (Throwable $e) {
+ DB::rollBack();
+
+ throw $e;
+ }
+
+ return new ElementResponse()->success($element, t('{type} saved.', [
+ 'type' => $element::displayName(),
+ ]));
+ }
+}
diff --git a/src/Http/Controllers/Elements/SearchController.php b/src/Http/Controllers/Elements/SearchController.php
new file mode 100644
index 00000000000..3ace859272e
--- /dev/null
+++ b/src/Http/Controllers/Elements/SearchController.php
@@ -0,0 +1,128 @@
+request->validate([
+ 'siteId' => ['nullable'],
+ 'criteria' => ['nullable', 'array'],
+ 'excludeIds' => ['nullable', 'array'],
+ 'excludeIds.*' => ['integer'],
+ 'referenceElementId' => ['nullable', 'integer'],
+ 'referenceElementOwnerId' => ['nullable', 'integer'],
+ 'referenceElementSiteId' => ['nullable', 'integer'],
+ 'search' => ['required', 'string', 'max:255'],
+ ]);
+
+ $query = $this->request->elementType()::find()
+ ->siteId($this->request->input('siteId'))
+ ->search($this->request->input('search'))
+ ->orderByDesc('score')
+ ->limit(5);
+
+ if ($criteria = $this->request->array('criteria')) {
+ // Remove unsupported criteria attributes
+ $criteria = ElementHelper::cleanseQueryCriteria($criteria);
+
+ Typecast::configure($query, $criteria);
+ }
+
+ $this->applyCondition($query);
+
+ $elements = $query->get();
+
+ if ($elements->isEmpty()) {
+ return new JsonResponse([
+ 'elements' => [],
+ 'exactMatch' => false,
+ ]);
+ }
+
+ $return = [];
+ $exactMatches = [];
+ $excludes = [];
+ $exactMatch = false;
+
+ $search = Search::normalizeKeywords($this->request->input('search', ''));
+
+ foreach ($elements as $element) {
+ $exclude = in_array($element->id, $this->request->array('excludeIds'));
+
+ $return[] = [
+ 'id' => $element->id,
+ 'title' => $element->title,
+ 'html' => app(ElementHtml::class)->chipHtml($element, [
+ 'hyperlink' => false,
+ 'class' => 'chromeless',
+ ]),
+ 'exclude' => $exclude,
+ ];
+
+ $title = $element->title ?? (string) $element;
+ $title = Search::normalizeKeywords($title);
+
+ if ($title === $search) {
+ $exactMatches[] = 1;
+ $exactMatch = true;
+ } else {
+ $exactMatches[] = 0;
+ }
+
+ $excludes[] = $exclude ? 1 : 0;
+ }
+
+ // prevent the default sort order from changing beyond $excludes + $exactMatches
+ $range = range(1, count($return));
+
+ array_multisort($excludes, SORT_ASC, $exactMatches, SORT_DESC, $range, $return);
+
+ return new JsonResponse([
+ 'elements' => $return,
+ 'exactMatch' => $exactMatch,
+ ]);
+ }
+
+ private function applyCondition(ElementQueryInterface $query): void
+ {
+ if (! $condition = $this->request->condition()) {
+ return;
+ }
+
+ if ($referenceElementId = $this->request->input('referenceElementId')) {
+ $ownerId = $this->request->input('referenceElementOwnerId');
+ $siteId = $this->request->input('referenceElementSiteId');
+ $criteria = [];
+
+ if ($ownerId) {
+ $criteria['ownerId'] = $ownerId;
+ }
+
+ $condition->referenceElement = $this->elements->getElementById(
+ (int) $referenceElementId,
+ siteId: $siteId,
+ criteria: $criteria,
+ );
+ }
+
+ $condition->modifyQuery($query);
+ }
+}
diff --git a/src/Http/Controllers/Elements/UpdateFieldLayoutController.php b/src/Http/Controllers/Elements/UpdateFieldLayoutController.php
new file mode 100644
index 00000000000..b6453f30ee2
--- /dev/null
+++ b/src/Http/Controllers/Elements/UpdateFieldLayoutController.php
@@ -0,0 +1,71 @@
+request->has('elementId') || $this->request->has('elementUid')) {
+ $element = $this->request->element();
+ } else {
+ $element = $this->createElement();
+ }
+
+ // Prevalidate?
+ if ($this->request->boolean('prevalidate') && $element->enabled && $element->getEnabledForSite()) {
+ $element->ruleset->useScenario(ElementRules::SCENARIO_LIVE);
+ $element->validate();
+ }
+
+ /**
+ * see https://github.com/craftcms/cms/issues/14635#issuecomment-2349006694 for details
+ *
+ * @var Element|Response|null $element
+ */
+ if ($element instanceof Response) {
+ return $element;
+ }
+
+ if (! $element || $element->getIsRevision()) {
+ abort(400, 'No element was identified by the request.');
+ }
+
+ Gate::authorize('view', $element);
+
+ $this->applyParamsToElement($element);
+
+ // Make sure nothing just changed that would prevent the user from saving
+ Gate::authorize('view', $element);
+
+ $data = $this->fieldLayoutData($element);
+
+ $data += [
+ 'initialDeltaValues' => $this->deltaRegistry->getInitialValues(),
+ ];
+
+ return new ElementResponse()->success($element, 'Field layout updated.', $data, true);
+ }
+}
diff --git a/src/Http/Controllers/Elements/ValidateElementController.php b/src/Http/Controllers/Elements/ValidateElementController.php
new file mode 100644
index 00000000000..b1bef5a798a
--- /dev/null
+++ b/src/Http/Controllers/Elements/ValidateElementController.php
@@ -0,0 +1,46 @@
+request->element();
+
+ // this can happen if we're creating e.g. nested entry in a matrix field (cards or element index)
+ // and we hit "create entry" before the autosave kicks in
+ if ($element instanceof Response) {
+ return $element;
+ }
+
+ if (! $element || $element->getIsRevision()) {
+ abort(400, 'No element was identified by the request.');
+ }
+
+ $element->ruleset->useScenario(ElementRules::SCENARIO_LIVE);
+
+ if (! $element->validate()) {
+ return new ElementResponse()->failure($element, t('{type} validation failed.', [
+ 'type' => $element::displayName(),
+ ]));
+ }
+
+ return new ElementResponse()->success($element, t('{type} validation successful.', [
+ 'type' => $element::displayName(),
+ ]));
+ }
+}
diff --git a/src/Http/Controllers/FieldsController.php b/src/Http/Controllers/FieldsController.php
index a5c9dc0dfe1..78ab38a37ae 100644
--- a/src/Http/Controllers/FieldsController.php
+++ b/src/Http/Controllers/FieldsController.php
@@ -4,7 +4,6 @@
namespace CraftCms\Cms\Http\Controllers;
-use craft\base\ElementInterface;
use CraftCms\Cms\Cms;
use CraftCms\Cms\Component\ComponentHelper;
use CraftCms\Cms\Component\Contracts\Chippable;
@@ -16,6 +15,7 @@
use CraftCms\Cms\Cp\FieldLayoutDesigner\FieldLayoutDesigner;
use CraftCms\Cms\Cp\Html\ContentHtml;
use CraftCms\Cms\Cp\Icons;
+use CraftCms\Cms\Element\Contracts\ElementInterface;
use CraftCms\Cms\Field\Contracts\FieldInterface;
use CraftCms\Cms\Field\Enums\TranslationMethod;
use CraftCms\Cms\Field\Field;
diff --git a/src/Http/Controllers/MatrixController.php b/src/Http/Controllers/MatrixController.php
index 22465d301cf..dfceb72ae6f 100644
--- a/src/Http/Controllers/MatrixController.php
+++ b/src/Http/Controllers/MatrixController.php
@@ -127,7 +127,6 @@ public function createEntry(Request $request): Response
]));
}
} else {
- /** @var Entry $entry */
$entry = new Entry([
...$attributes,
]);
diff --git a/src/Http/Controllers/NestedElementsController.php b/src/Http/Controllers/NestedElementsController.php
index 1937c211f06..300cadb5889 100644
--- a/src/Http/Controllers/NestedElementsController.php
+++ b/src/Http/Controllers/NestedElementsController.php
@@ -4,10 +4,10 @@
namespace CraftCms\Cms\Http\Controllers;
-use craft\base\ElementInterface;
-use craft\base\NestedElementInterface;
use CraftCms\Cms\Auth\SessionAuth;
use CraftCms\Cms\Database\Table;
+use CraftCms\Cms\Element\Contracts\ElementInterface;
+use CraftCms\Cms\Element\Contracts\NestedElementInterface;
use CraftCms\Cms\Element\ElementCaches;
use CraftCms\Cms\Element\ElementCollection;
use CraftCms\Cms\Element\Elements;
diff --git a/src/Http/Controllers/PreviewController.php b/src/Http/Controllers/PreviewController.php
index dfab9a5bdb4..d7fb8afa129 100644
--- a/src/Http/Controllers/PreviewController.php
+++ b/src/Http/Controllers/PreviewController.php
@@ -27,10 +27,12 @@
public function createToken(Request $request, RouteTokens $tokens): JsonResponse|RedirectResponse
{
- $tokenData = new RouteToken($request->all());
+ $tokenData = new RouteToken($request->post());
+
if ($token = $request->input('previewToken')) {
$tokenData->previewToken = Crypt::decrypt($token);
}
+
$tokenData->validate(throw: true);
match (true) {
diff --git a/src/Http/Controllers/RelationalFieldsController.php b/src/Http/Controllers/RelationalFieldsController.php
index f287ed331de..5aa0c2d6b45 100644
--- a/src/Http/Controllers/RelationalFieldsController.php
+++ b/src/Http/Controllers/RelationalFieldsController.php
@@ -4,7 +4,7 @@
namespace CraftCms\Cms\Http\Controllers;
-use craft\base\ElementInterface;
+use CraftCms\Cms\Element\Contracts\ElementInterface;
use CraftCms\Cms\Element\Drafts;
use CraftCms\Cms\Structure\Structures;
use CraftCms\Cms\Support\Facades\HtmlStack;
diff --git a/src/Http/Controllers/Settings/EntryTypesController.php b/src/Http/Controllers/Settings/EntryTypesController.php
index be08c15e720..87dd9613c76 100644
--- a/src/Http/Controllers/Settings/EntryTypesController.php
+++ b/src/Http/Controllers/Settings/EntryTypesController.php
@@ -20,7 +20,7 @@
use CraftCms\Cms\Field\Fields;
use CraftCms\Cms\FieldLayout\FieldLayout;
use CraftCms\Cms\FieldLayout\FieldLayoutElement;
-use CraftCms\Cms\FieldLayout\LayoutElements\entries\EntryTitleField;
+use CraftCms\Cms\FieldLayout\LayoutElements\Entries\EntryTitleField;
use CraftCms\Cms\Http\RespondsWithFlash;
use CraftCms\Cms\Http\Responses\CpScreenResponse;
use CraftCms\Cms\Section\Data\Section;
diff --git a/src/Http/Controllers/Settings/FilesystemsController.php b/src/Http/Controllers/Settings/FilesystemsController.php
index 3843f4b120c..77b2b2c399a 100644
--- a/src/Http/Controllers/Settings/FilesystemsController.php
+++ b/src/Http/Controllers/Settings/FilesystemsController.php
@@ -121,7 +121,6 @@ public function save(Request $request): Response
{
$type = $request->input('type');
- /** @var FsInterface $fs */
$fs = $this->filesystems->createFilesystem([
'type' => $type,
'name' => $request->input('name'),
diff --git a/src/Http/Controllers/StructuresController.php b/src/Http/Controllers/StructuresController.php
index 5be7d95455d..11ca284b909 100644
--- a/src/Http/Controllers/StructuresController.php
+++ b/src/Http/Controllers/StructuresController.php
@@ -4,8 +4,8 @@
namespace CraftCms\Cms\Http\Controllers;
-use craft\base\ElementInterface;
use CraftCms\Cms\Auth\Concerns\EnforcesPermissions;
+use CraftCms\Cms\Element\Contracts\ElementInterface;
use CraftCms\Cms\Element\Elements;
use CraftCms\Cms\Http\RespondsWithFlash;
use CraftCms\Cms\Structure\Data\Structure;
diff --git a/src/Http/Controllers/Users/EditUserTrait.php b/src/Http/Controllers/Users/EditUserTrait.php
index ba894f03b14..5b7d5d6f091 100644
--- a/src/Http/Controllers/Users/EditUserTrait.php
+++ b/src/Http/Controllers/Users/EditUserTrait.php
@@ -63,7 +63,7 @@ protected function editedUser(?int $userId): User
return $user;
}
- protected function asEditUserScreen(User $user, string $screen): CpScreenResponse
+ protected function asEditUserScreen(User $user, string $screen, ?CpScreenResponse $response = null): CpScreenResponse
{
$screens = [
self::SCREEN_PROFILE => ['label' => t('Profile')],
@@ -93,7 +93,7 @@ protected function asEditUserScreen(User $user, string $screen): CpScreenRespons
abort_if(! isset($screens[$screen]), 403, 'User not authorized to perform this action.');
$pageName = $screens[$screen]['label'];
- $response = new CpScreenResponse()
+ $response = ($response ?? new CpScreenResponse)
->when(
$user->getIsCurrent(),
fn (CpScreenResponse $response) => $response
diff --git a/src/Http/Controllers/Users/UsersController.php b/src/Http/Controllers/Users/UsersController.php
index 81eb56d3936..2eb25c92eaa 100644
--- a/src/Http/Controllers/Users/UsersController.php
+++ b/src/Http/Controllers/Users/UsersController.php
@@ -4,20 +4,18 @@
namespace CraftCms\Cms\Http\Controllers\Users;
-use Craft;
-use craft\web\CpScreenResponseBehavior;
use CraftCms\Cms\Auth\Concerns\EnforcesPermissions;
use CraftCms\Cms\Edition;
use CraftCms\Cms\Element\Drafts;
use CraftCms\Cms\Element\Elements;
use CraftCms\Cms\Element\Validation\ElementRules;
use CraftCms\Cms\Entry\Elements\Entry;
+use CraftCms\Cms\Http\Controllers\Elements\EditElementController;
use CraftCms\Cms\Http\RespondsWithFlash;
use CraftCms\Cms\Http\Responses\CpScreenResponse;
use CraftCms\Cms\Section\Data\Section;
use CraftCms\Cms\Section\Sections;
use CraftCms\Cms\Support\Url;
-use CraftCms\Cms\Support\Utils;
use CraftCms\Cms\User\Elements\User;
use CraftCms\Cms\User\Events\DefineUserContentSummary;
use CraftCms\Cms\User\Users;
@@ -25,8 +23,6 @@
use Illuminate\Foundation\Auth\Access\AuthorizesRequests;
use Illuminate\Http\JsonResponse;
use Illuminate\Http\Request;
-use ReflectionClass;
-use ReflectionException;
use Symfony\Component\HttpFoundation\Response;
use function CraftCms\Cms\t;
@@ -79,39 +75,20 @@ public function create(Request $request, Drafts $drafts): Response
]));
}
- public function edit(?int $userId = null): CpScreenResponse
+ public function edit(?int $userId = null): Response|CpScreenResponse
{
$user = $this->editedUser($userId);
/**
- * @TODO: Refactor away the runAction
- * let the elements/edit action do most of the work
+ * Let the elements/edit action do most of the work
*/
- Craft::$app->request->setIsCpRequest(true);
- $response = Craft::$app->runAction('elements/edit', [
- 'element' => $user,
- ]);
-
- /**
- * This transforms the old Yii CpScreen to the new
- *
- * @var CpScreenResponseBehavior $cpScreen
- */
- $cpScreen = $response->getBehavior('cp-screen');
- $response = $this->asEditUserScreen($user, self::SCREEN_PROFILE);
- $reflection = new ReflectionClass($response);
- foreach (Utils::getPublicProperties($cpScreen) as $property => $value) {
- if (isset($response->{$property})) {
- continue;
- }
+ $response = app(EditElementController::class)->setElement($user)();
- try {
- $reflection->getProperty($property)->setValue($response, $value);
- } catch (ReflectionException) {
- }
+ if (! $response instanceof CpScreenResponse) {
+ return $response;
}
- return $response
+ return $this->asEditUserScreen($user, self::SCREEN_PROFILE, $response)
->when(
$user->getIsUnpublishedDraft() && $this->showPermissionsScreen(),
function (CpScreenResponse $response) use ($user) {
diff --git a/src/Http/Mixins/SessionMixin.php b/src/Http/Mixins/SessionMixin.php
new file mode 100644
index 00000000000..f45f2daf466
--- /dev/null
+++ b/src/Http/Mixins/SessionMixin.php
@@ -0,0 +1,64 @@
+isCpRequest()) {
+ return;
+ }
+
+ /**
+ * @var SessionManager $this
+ *
+ * @phpstan-ignore-next-line
+ */
+ $this->flashJs(Json::encode($message));
+ };
+ }
+
+ public function getJs(): Closure
+ {
+ return function (bool $delete = true): array {
+ if ($delete) {
+ /**
+ * @var SessionManager $this
+ *
+ * @phpstan-ignore-next-line
+ */
+ return $this->pull('__js', []);
+ }
+
+ /**
+ * @var SessionManager $this
+ *
+ * @phpstan-ignore-next-line
+ */
+ return $this->get('__js', []);
+ };
+ }
+
+ public function flashJs(): Closure
+ {
+ return function (string $js, Position $position = Position::Head, ?string $key = null): void {
+ /**
+ * @var SessionManager $this
+ *
+ * @phpstan-ignore-next-line
+ */
+ $scripts = $this->getJs();
+ $scripts[] = [$js, $position->value, $key];
+ $this->flash('__js', $scripts);
+ };
+ }
+}
diff --git a/src/Http/Requests/ElementIndexRequest.php b/src/Http/Requests/ElementIndexRequest.php
new file mode 100644
index 00000000000..9a1346c125b
--- /dev/null
+++ b/src/Http/Requests/ElementIndexRequest.php
@@ -0,0 +1,114 @@
+
+ */
+ public function elementType(): string
+ {
+ $this->validate([
+ 'elementType' => ['required', 'string', function (string $attribute, mixed $value, Closure $fail): void {
+ if (! ComponentHelper::validateComponentClass($value, ElementInterface::class)) {
+ $fail(new InvalidTypeException((string) $value, ElementInterface::class)->getMessage());
+ }
+ }],
+ ]);
+
+ return $this->input('elementType');
+ }
+
+ /**
+ * Returns the context that this controller is being called in.
+ */
+ public function context(): string
+ {
+ return $this->input('context', ElementSources::CONTEXT_INDEX);
+ }
+
+ public function isAdministrative(?string $context = null): bool
+ {
+ return in_array($context ?? $this->context(), [ElementSources::CONTEXT_INDEX, ElementSources::CONTEXT_EMBEDDED_INDEX]);
+ }
+
+ /**
+ * Returns the condition that should be applied to the element query.
+ */
+ public function condition(): ?ElementConditionInterface
+ {
+ $this->validate([
+ 'condition' => ['nullable', function (string $attribute, mixed $value, Closure $fail): void {
+ if (! is_array($value) && ! is_string($value)) {
+ $fail(t('The {attribute} field must be a string or array.', ['attribute' => $attribute]));
+
+ return;
+ }
+
+ if (is_array($value)) {
+ $class = $value['class'] ?? null;
+
+ if (! is_string($class) || trim($class) === '') {
+ $fail(t('The {attribute} field must contain a `class` value.', ['attribute' => $attribute]));
+ }
+ }
+ }],
+ 'referenceElementId' => ['nullable', 'integer'],
+ 'referenceElementOwnerId' => ['nullable', 'integer'],
+ 'referenceElementSiteId' => ['nullable', 'integer'],
+ ]);
+
+ /** @var array{class:class-string}|null $conditionConfig */
+ $conditionConfig = $this->input('condition');
+
+ if (! $conditionConfig) {
+ return null;
+ }
+
+ $condition = Conditions::createCondition($conditionConfig);
+
+ if ($condition instanceof ElementCondition) {
+ $referenceElementId = $this->input('referenceElementId');
+
+ if ($referenceElementId) {
+ $ownerId = $this->input('referenceElementOwnerId');
+ $siteId = $this->input('referenceElementSiteId');
+ $criteria = [];
+
+ if ($ownerId) {
+ $criteria['ownerId'] = $ownerId;
+ }
+
+ $condition->referenceElement = Elements::getElementById(
+ (int) $referenceElementId,
+ siteId: $siteId,
+ criteria: $criteria,
+ );
+ }
+ }
+
+ if (! $condition instanceof ElementConditionInterface) {
+ return null;
+ }
+
+ return $condition;
+ }
+}
diff --git a/src/Http/Requests/ElementRequest.php b/src/Http/Requests/ElementRequest.php
new file mode 100644
index 00000000000..a9265637908
--- /dev/null
+++ b/src/Http/Requests/ElementRequest.php
@@ -0,0 +1,369 @@
+
+ */
+ private string $elementType;
+
+ public ?ElementInterface $element = null;
+
+ public function rules(): array
+ {
+ $fieldsLocation = $this->input('fieldsLocation', 'fields');
+
+ return [
+ '*' => [],
+ 'id' => ['missing'],
+ 'canonicalId' => ['missing'],
+
+ /**
+ * These need to be excluded from the ->validated() call
+ * which is passed to setAttributesFromRequest.
+ */
+ 'elementType' => ['exclude'],
+ 'elementId' => ['exclude'],
+ 'elementUid' => ['exclude'],
+ 'draftId' => ['exclude'],
+ 'revisionId' => ['exclude'],
+ 'fieldId' => ['exclude'],
+ 'ownerId' => ['exclude'],
+ 'newOwnerId' => ['exclude'],
+ 'siteId' => ['exclude'],
+ 'enabled' => ['exclude'],
+ 'setEnabled' => ['exclude'],
+ 'enabledForSite' => ['exclude'],
+ 'slug' => ['exclude'],
+ 'fresh' => ['exclude'],
+ 'draftName' => ['exclude'],
+ 'notes' => ['exclude'],
+ 'fieldsLocation' => ['exclude'],
+ 'provisional' => ['exclude'],
+ 'dropProvisional' => ['exclude'],
+ 'addAnother' => ['exclude'],
+ 'visibleLayoutElements' => ['exclude'],
+ 'staticLayoutElements' => ['exclude'],
+ 'selectedTab' => ['exclude'],
+ 'applyParams' => ['exclude'],
+ 'prevalidate' => ['exclude'],
+ 'asUnpublishedDraft' => ['exclude'],
+ 'deleteProvisionalDraft' => ['exclude'],
+ 'updateSearchIndexImmediately' => ['exclude'],
+ 'failMessage' => ['exclude'],
+ 'redirect' => ['exclude'],
+ 'successMessage' => ['exclude'],
+ $fieldsLocation => ['exclude'],
+ ];
+ }
+
+ public function element(array $overrides = [], bool $checkForProvisionalDraft = false, bool $strictSite = true): ElementInterface|Response|null
+ {
+ $this->overrides = $overrides;
+ $this->checkForProvisionalDraft = $checkForProvisionalDraft;
+ $this->strictSite = $strictSite;
+ $this->elementType = $this->elementType();
+
+ $this->validateElementType($this->elementType);
+
+ $elementId = Arr::get($overrides, 'id', $this->input('elementId'));
+ $elementUid = Arr::get($overrides, 'uid', $this->input('elementUid'));
+ $draftId = Arr::get($overrides, 'draftId', $this->input('draftId'));
+ $revisionId = Arr::get($overrides, 'revisionId', $this->input('revisionId'));
+
+ [$siteId, $preferSites] = $this->site();
+
+ $element = match (true) {
+ $draftId || $revisionId => $this->elementByDraftOrRevision($draftId, $revisionId),
+ ! is_null($elementId) => $this->elementById(),
+ ! is_null($elementUid) => $this->elementByUid(),
+ default => null,
+ };
+
+ if (is_null($element)) {
+ $this->element = null;
+
+ return null;
+ }
+
+ abort_unless($this->user()->can('view', $element), 403, 'User not authorized to view this element.');
+
+ if (
+ ! $this->strictSite &&
+ $element->siteId !== $siteId &&
+ ! $this->wantsJson()
+ ) {
+ return redirect($element->getCpEditUrl());
+ }
+
+ if ($element instanceof ElementInterface) {
+ $this->element = $element;
+ }
+
+ return $element;
+ }
+
+ /**
+ * @return class-string
+ */
+ public function elementType(): string
+ {
+ $elementType = Arr::get($this->overrides, 'type', $this->input('elementType'));
+ $elementId = Arr::get($this->overrides, 'id', $this->input('elementId'));
+ $elementUid = Arr::get($this->overrides, 'uid', $this->input('elementUid'));
+
+ if ($elementType) {
+ return $this->elementType = $elementType;
+ }
+
+ if ($elementId) {
+ abort_if(
+ is_null($elementType = Elements::getElementTypeById($elementId)),
+ 400,
+ "Invalid element ID: $elementId",
+ );
+
+ return $this->elementType = $elementType;
+ }
+
+ if ($elementUid) {
+ abort_if(
+ is_null($elementType = Elements::getElementTypeByUid($elementUid)),
+ 400,
+ "Invalid element UUID: $elementUid",
+ );
+
+ return $this->elementType = $elementType;
+ }
+
+ abort(400, 'Request missing required param.');
+ }
+
+ private function elementQuery(): ElementQueryInterface
+ {
+ $query = $this->elementType::find();
+
+ if ($query instanceof NestedElementQueryInterface) {
+ $fieldId = Arr::get($this->overrides, 'fieldId', $this->input('fieldId'));
+ $ownerId = Arr::get($this->overrides, 'ownerId', $this->input('ownerId'));
+
+ $query
+ ->fieldId($fieldId)
+ ->ownerId($ownerId);
+ }
+
+ return $query;
+ }
+
+ public function validateElementType(string $elementType): void
+ {
+ if (ComponentHelper::validateComponentClass($elementType, ElementInterface::class)) {
+ return;
+ }
+
+ abort(400, new InvalidTypeException($elementType, ElementInterface::class)->getMessage());
+ }
+
+ /**
+ * @return array{0: int|int[]|null, 1: int[]|null}
+ */
+ public function site(): array
+ {
+ if (! $this->elementType::isLocalized()) {
+ return [null, null];
+ }
+
+ $siteId = Arr::get($this->overrides, 'siteId', $this->input('siteId'));
+
+ if ($siteId) {
+ $site = Sites::getSiteById($siteId, true);
+
+ abort_if(is_null($site), 400, "Invalid site ID: $siteId");
+
+ if (Sites::isMultiSite() && ! $this->user()->can("editSite:$site->uid")) {
+ abort(403, 'User not authorized to edit content for this site.');
+ }
+ } else {
+ $site = app(RequestedSite::class)->get();
+
+ abort_if(is_null($site), 400, 'User not authorized to edit content in any sites.');
+ }
+
+ if ($this->strictSite) {
+ return [$site->id, null];
+ }
+
+ return [
+ Sites::getEditableSiteIds()->all(),
+ [$site->id],
+ ];
+ }
+
+ private function elementByDraftOrRevision(mixed $draftId, mixed $revisionId): ElementInterface|Response
+ {
+ $hasExplicitProvisional = Arr::has($this->overrides, 'isProvisionalDraft') || $this->has('provisional');
+ $provisional = Arr::get($this->overrides, 'isProvisionalDraft', $this->input('provisional'));
+ [$siteId, $preferSites] = $this->site();
+
+ $query = $this->elementQuery()
+ ->draftId($draftId ? (int) $draftId : null)
+ ->revisionId($revisionId ? (int) $revisionId : null)
+ ->provisionalDrafts($hasExplicitProvisional ? (bool) $provisional : null)
+ ->siteId($siteId)
+ ->preferSites($preferSites)
+ ->unique()
+ ->status(null);
+
+ if ($revisionId) {
+ $query->trashed(null);
+ }
+
+ $element = $query->first();
+
+ if (! $element) {
+ // check for the canonical element as a fallback
+ $element = $this->elementById() ?? $this->elementByUid();
+
+ if ($element && $this->user()->can('view', $element)) {
+ if (! $this->wantsJson()) {
+ return redirect($element->getCpEditUrl());
+ }
+
+ return $element;
+ }
+ }
+
+ if ($element) {
+ return $element;
+ }
+
+ abort(400, $draftId ? "Invalid draft ID: $draftId" : "Invalid revision ID: $revisionId");
+ }
+
+ private function elementById(): ?ElementInterface
+ {
+ $elementId = Arr::get($this->overrides, 'id', $this->input('elementId'));
+
+ if (! $elementId) {
+ return null;
+ }
+
+ [$siteId, $preferSites] = $this->site();
+
+ // First check for a provisional draft, if we're open to it
+ if ($this->checkForProvisionalDraft) {
+ $element = $this->elementQuery()
+ ->provisionalDrafts()
+ ->draftOf($elementId)
+ ->draftCreator($this->user())
+ ->siteId($siteId)
+ ->preferSites($preferSites)
+ ->unique()
+ ->status(null)
+ ->one();
+
+ if ($element && $this->canSave($element, $this->user())) {
+ return $element;
+ }
+ }
+
+ $element = $this->elementQuery()
+ ->id($elementId)
+ ->siteId($siteId)
+ ->preferSites($preferSites)
+ ->unique()
+ ->drafts(null)
+ ->provisionalDrafts(null)
+ ->revisions(null)
+ ->status(null)
+ ->one();
+
+ if ($element) {
+ return $element;
+ }
+
+ // finally, check for an unpublished draft
+ // (see https://github.com/craftcms/cms/issues/14199)
+ return $this->elementQuery()
+ ->id($elementId)
+ ->siteId($siteId)
+ ->preferSites($preferSites)
+ ->unique()
+ ->draftOf(false)
+ ->status(null)
+ ->one();
+ }
+
+ private function elementByUid(): ?ElementInterface
+ {
+ $elementUid = Arr::get($this->overrides, 'uid', $this->input('elementUid'));
+
+ if (! $elementUid) {
+ return null;
+ }
+
+ [$siteId, $preferSites] = $this->site();
+
+ $element = $this->elementQuery()
+ ->uid($elementUid)
+ ->siteId($siteId)
+ ->preferSites($preferSites)
+ ->unique()
+ ->status(null)
+ ->one();
+
+ if ($element) {
+ return $element;
+ }
+
+ // check for an unpublished draft if we got this far
+ // (e.g. newly added matrix "block" or where autosaveDrafts is off)
+ // https://github.com/craftcms/cms/issues/15985
+ return $this->elementQuery()
+ ->uid($elementUid)
+ ->siteId($siteId)
+ ->preferSites($preferSites)
+ ->unique()
+ ->status(null)
+ ->draftOf(false)
+ ->one();
+ }
+
+ private function canSave(ElementInterface $element, User $user): bool
+ {
+ if ($element->getIsRevision()) {
+ return false;
+ }
+
+ if ($element->isProvisionalDraft) {
+ $element = $element->getCanonical(true);
+ }
+
+ return $user->can('save', $element);
+ }
+}
diff --git a/src/Http/Resources/ElementIndexResource.php b/src/Http/Resources/ElementIndexResource.php
new file mode 100644
index 00000000000..267dd46bff4
--- /dev/null
+++ b/src/Http/Resources/ElementIndexResource.php
@@ -0,0 +1,99 @@
+elementType();
+ [$sourceKey, $source] = $this->resolveSource($elementType, $request->input('source'), $request->context());
+ $elementQuery = $this->buildElementQueryState(
+ elementType: $elementType,
+ source: $source,
+ condition: $request->condition(),
+ )['query'];
+
+ app(CurrentElementIndex::class)->activate($elementQuery);
+
+ $viewState = $this->resolveViewState();
+
+ $responseData = [];
+ $actions = null;
+ $exporters = null;
+
+ if ($this->includeActions) {
+ if ($request->isAdministrative() && isset($sourceKey)) {
+ $actions = ElementActions::availableActions($elementType, $sourceKey, $elementQuery);
+ $exporters = $this->availableExporters($elementType, $sourceKey);
+ }
+
+ $responseData['actions'] = match (true) {
+ ($viewState['static'] ?? false) === true => [],
+ empty($actions) => null,
+ default => ElementActions::serializeActions($actions),
+ };
+
+ $responseData['actionsHeadHtml'] = HtmlStack::headHtml();
+ $responseData['actionsBodyHtml'] = HtmlStack::bodyHtml();
+ $responseData['exporters'] = empty($exporters) ? null : ElementExporters::serializeExporters($exporters);
+ }
+
+ if (! $sourceKey) {
+ $responseData['html'] = Html::tag('div', t('Nothing yet.'), [
+ 'class' => ['zilch', 'small'],
+ ]);
+
+ return $responseData;
+ }
+
+ $responseData['html'] = $elementType::indexHtml(
+ elementQuery: $elementQuery,
+ disabledElementIds: $request->array('disabledElementIds'),
+ viewState: [...$viewState, 'fieldLayouts' => $this->resolveFieldLayouts()],
+ sourceKey: $sourceKey,
+ context: $request->context(),
+ includeContainer: $this->includeContainer,
+ selectable: (
+ ((! empty($actions)) || request()->boolean('selectable')) &&
+ empty($viewState['inlineEditing'])
+ ),
+ sortable: $request->isAdministrative() && $request->boolean('sortable'),
+ );
+
+ $responseData['headHtml'] = HtmlStack::headHtml();
+ $responseData['bodyHtml'] = HtmlStack::bodyHtml();
+
+ return $responseData;
+ }
+}
diff --git a/src/Http/RespondsWithFlash.php b/src/Http/RespondsWithFlash.php
index 5d18d613907..e1cec899717 100644
--- a/src/Http/RespondsWithFlash.php
+++ b/src/Http/RespondsWithFlash.php
@@ -41,13 +41,13 @@ public function asJsonFailure(?string $message = null, array $data = []): JsonRe
]), 400);
}
- public function asSuccess(?string $message = null, array $data = [], ?string $redirect = null): Response
+ public function asSuccess(?string $message = null, array $data = [], ?string $redirect = null, array $notificationSettings = []): Response
{
if (request()->expectsJson()) {
return $this->asJsonSuccess($message, $data);
}
- Flash::success($message);
+ Flash::success($message, $notificationSettings);
$redirect ??= $this->getPostedRedirectUrl();
diff --git a/src/Http/Responses/ElementResponse.php b/src/Http/Responses/ElementResponse.php
new file mode 100644
index 00000000000..871344eb4fb
--- /dev/null
+++ b/src/Http/Responses/ElementResponse.php
@@ -0,0 +1,224 @@
+ 'element',
+ 'element' => $element->toArray($element->attributes()),
+ ];
+
+ $response = $this->asSuccess($message, $data, $this->getPostedRedirectUrl($element), [
+ 'details' => ! $element->dateDeleted
+ ? app(ElementHtml::class)->elementChipHtml($element, ['hyperlink' => true])
+ : null,
+ ]);
+
+ if ($supportsAddAnother && request()->boolean('addAnother')) {
+ $user = request()->user();
+ $newElement = $element->createAnother();
+
+ if (! $newElement || ! Gate::check('save', $newElement)) {
+ abort(500, 'Unable to create a new element.');
+ }
+
+ if (! $newElement->slug) {
+ $newElement->slug = ElementHelper::tempSlug();
+ }
+
+ $newElement->ruleset->useScenario(ElementRules::SCENARIO_ESSENTIALS);
+
+ if (! Drafts::saveElementAsDraft($newElement, $user->id, null, null, false)) {
+ abort(500, sprintf('Unable to create a new element: %s', implode(', ', $element->errors()->all())));
+ }
+
+ $url = $newElement->getCpEditUrl();
+
+ if ($url) {
+ $url = Url::urlWithParams($url, ['fresh' => 1]);
+ } else {
+ $url = Url::actionUrl('elements/edit', [
+ 'draftId' => $newElement->draftId,
+ 'siteId' => $newElement->siteId,
+ 'fresh' => 1,
+ ]);
+ }
+
+ return redirect($url);
+ }
+
+ return $response;
+ }
+
+ public function failure(ElementInterface $element, string $message): Response
+ {
+ $data = [
+ 'modelName' => 'element',
+ 'element' => $element->toArray($element->attributes()),
+ 'errors' => $element->errors()->getMessages(),
+ 'errorSummary' => $this->errorSummary($element),
+ 'invalidNestedElementIds' => $element->getInvalidNestedElementIds(),
+ ];
+
+ return $this->asFailure($message, $data);
+ }
+
+ public function applyDraftFailure(ElementInterface $element): Response
+ {
+ $message = match (true) {
+ $element->getIsUnpublishedDraft() => mb_ucfirst(t('Couldn’t create {type}.', [
+ 'type' => $element::lowerDisplayName(),
+ ])),
+ $element->isProvisionalDraft => mb_ucfirst(t('Couldn’t save {type}.', [
+ 'type' => $element::lowerDisplayName(),
+ ])),
+ default => t('Couldn’t apply draft.'),
+ };
+
+ return $this->failure($element, $message);
+ }
+
+ public function errorSummary(ElementInterface $element): string
+ {
+ $html = '';
+
+ if ($element->errors()->isNotEmpty()) {
+ $allErrors = $element->errors()->getMessages();
+ $allKeys = array_keys($allErrors);
+
+ // only show "top-level" errors
+ // if you e.g. have an assets field which is set to validate related assets,
+ // you should only see the top-level "Fix validation errors on the related asset" error
+ // and not the details of what's wrong with the selected asset;
+ foreach ($allKeys as $key) {
+ $dotPos = strrpos((string) $key, '.');
+ $bracketPos = strrpos((string) $key, '[');
+
+ if ($dotPos === false && $bracketPos === false) {
+ continue;
+ }
+
+ $lastNestedKey = $key;
+
+ if ($dotPos !== false) {
+ $lastNestedKey = substr_replace($lastNestedKey, '', $dotPos);
+ }
+
+ $bracketPos = strrpos((string) $lastNestedKey, '[');
+
+ if ($bracketPos !== false) {
+ $lastNestedKey = substr_replace($lastNestedKey, '', $bracketPos);
+ }
+
+ if (! empty($lastNestedKey) && in_array($lastNestedKey, $allKeys)) {
+ unset($allErrors[$key]);
+ }
+ }
+ $errorsList = [];
+ $tabs = $element->getFieldLayout()->getTabs();
+ foreach ($allErrors as $key => $errors) {
+ foreach ($errors as $error) {
+ // this is true in case of e.g. cross site validation error
+ if (preg_match('/^\s?\getElements() as $layoutElement) {
+ if ($layoutElement instanceof BaseField && $layoutElement->attribute() === $fieldKey) {
+ $tabUid = $tab->uid;
+ break 2;
+ }
+ }
+ }
+
+ // If the error is for a recursively-nested Matrix field,
+ // manipulate the key to only reference the nested Matrix field, entry and inner field
+ // Before: foo[].bar[].baz
+ // After: bar[].baz
+ if (substr_count((string) $key, '.') > 1) {
+ $keyParts = explode('.', (string) $key);
+ if (preg_match(sprintf('/\[%s\]$/', Str::uuidPattern()), $keyParts[count($keyParts) - 3])) {
+ $key = implode('.', array_slice($keyParts, -2));
+ }
+ }
+
+ $errorItem = null;
+ if ($error !== null) {
+ $error = Markdown::parseParagraph(htmlspecialchars($error));
+ $errorItem = Html::beginTag('li');
+ $errorItem .= Html::a(t($error), '#', [
+ 'data' => [
+ 'field-error-key' => $key,
+ 'layout-tab' => $tabUid,
+ ],
+ ]);
+ $errorItem .= Html::endTag('li');
+ }
+ }
+
+ if ($errorItem !== null) {
+ $errorsList[] = $errorItem;
+ }
+ }
+ }
+
+ if (! empty($errorsList)) {
+ $heading = t('Found {num, number} {num, plural, =1{error} other{errors}}', [
+ 'num' => count($errorsList),
+ ]);
+
+ $html = Html::beginTag('div', [
+ 'class' => ['error-summary'],
+ 'tabindex' => '-1',
+ ]).
+ Html::beginTag('div').
+ Html::tag('span', '', [
+ 'class' => 'notification-icon',
+ 'data-icon' => 'alert',
+ 'aria-label' => t('Error'),
+ 'role' => 'img',
+ ]).
+ Html::tag('h2', $heading).
+ Html::endTag('div').
+ Html::beginTag('ul', [
+ 'class' => ['errors'],
+ ]).
+ implode('', $errorsList).
+ Html::endTag('ul').
+ Html::endTag('div');
+ }
+ }
+
+ return $html;
+ }
+}
diff --git a/src/Image/ImageTransformHelper.php b/src/Image/ImageTransformHelper.php
index ba2cf888e41..d54c53c1ebb 100644
--- a/src/Image/ImageTransformHelper.php
+++ b/src/Image/ImageTransformHelper.php
@@ -6,6 +6,7 @@
use CraftCms\Cms\Asset\AssetsHelper;
use CraftCms\Cms\Asset\Elements\Asset;
+use CraftCms\Cms\Asset\Enums\FileKind;
use CraftCms\Cms\Asset\Exceptions\AssetException;
use CraftCms\Cms\Asset\Exceptions\AssetOperationException;
use CraftCms\Cms\Asset\Exceptions\ImageException;
@@ -83,7 +84,7 @@ public static function detectTransformFormat(Asset $asset): string
return $ext;
}
- if ($asset->kind !== Asset::KIND_IMAGE) {
+ if ($asset->kind !== FileKind::Image->value) {
throw new AssetOperationException(t('Tried to detect the appropriate image format for a non-image!'));
}
diff --git a/src/Image/ImageTransformer.php b/src/Image/ImageTransformer.php
index 9430091947d..7f6c1ef1b25 100644
--- a/src/Image/ImageTransformer.php
+++ b/src/Image/ImageTransformer.php
@@ -20,6 +20,7 @@
use CraftCms\Cms\Image\Events\DeletingTransformedImage;
use CraftCms\Cms\Image\Events\TransformingImage;
use CraftCms\Cms\Image\Jobs\GenerateImageTransform;
+use CraftCms\Cms\Shared\Exceptions\NotSupportedException;
use CraftCms\Cms\Support\Arr;
use CraftCms\Cms\Support\DateTimeHelper;
use CraftCms\Cms\Support\Facades\I18N;
@@ -32,9 +33,8 @@
use Illuminate\Filesystem\LocalFilesystemAdapter;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Sleep;
+use RuntimeException;
use Throwable;
-use yii\base\InvalidConfigException;
-use yii\base\NotSupportedException;
use function CraftCms\Cms\maxPowerCaptain;
use function CraftCms\Cms\t;
@@ -184,7 +184,7 @@ public function deleteImageTransformFile(Asset $asset, ImageTransformIndex $tran
try {
$asset->getVolume()->transformDisk()->delete($path);
- } catch (InvalidConfigException|NotSupportedException) {
+ } catch (RuntimeException|NotSupportedException) {
// NBD
}
}
diff --git a/src/ProjectConfig/ProjectConfig.php b/src/ProjectConfig/ProjectConfig.php
index 5db76ebc849..296274d622f 100644
--- a/src/ProjectConfig/ProjectConfig.php
+++ b/src/ProjectConfig/ProjectConfig.php
@@ -6,12 +6,12 @@
use Craft;
use craft\helpers\App;
-use craft\services\ElementSources;
use CraftCms\Cms\Address\Elements\Address;
use CraftCms\Cms\Asset\Data\Volume;
use CraftCms\Cms\Cms;
use CraftCms\Cms\Config\GeneralConfig;
use CraftCms\Cms\Database\Table;
+use CraftCms\Cms\Element\ElementSources;
use CraftCms\Cms\Entry\Data\EntryType;
use CraftCms\Cms\Field\Contracts\FieldInterface;
use CraftCms\Cms\Filesystem\Contracts\FsInterface;
@@ -31,6 +31,7 @@
use CraftCms\Cms\ProjectConfig\Exceptions\ReadonlyException;
use CraftCms\Cms\ProjectConfig\Exceptions\StaleResourceException;
use CraftCms\Cms\Section\Data\Section;
+use CraftCms\Cms\Shared\Exceptions\NotSupportedException;
use CraftCms\Cms\Shared\Exceptions\OperationAbortedException;
use CraftCms\Cms\Shared\Models\Info;
use CraftCms\Cms\Site\Data\Site;
@@ -60,6 +61,7 @@
use Illuminate\Support\Facades\Event;
use Illuminate\Support\Facades\Log;
use InvalidArgumentException;
+use RuntimeException;
use SplFileInfo;
use Symfony\Component\Finder\Finder;
use Symfony\Component\Yaml\Yaml;
@@ -67,8 +69,6 @@
use yii\base\Application;
use yii\base\ErrorException;
use yii\base\Exception;
-use yii\base\InvalidConfigException;
-use yii\base\NotSupportedException;
use yii\web\ServerErrorHttpException;
use function Illuminate\Filesystem\join_paths;
@@ -446,7 +446,7 @@ private function findInternal(array $config, callable $callback, ?string $path,
* @throws Exception
* @throws NotSupportedException if the service is set to read-only mode
* @throws ServerErrorHttpException
- * @throws InvalidConfigException
+ * @throws RuntimeException
* @throws BusyResourceException if a lock could not be acquired
* @throws StaleResourceException if the loaded project config is out-of-date
*/
@@ -1759,7 +1759,7 @@ private function _getElementSourceData(array $sourceConfigs): array
if ($config['type'] === ElementSources::TYPE_CUSTOM && isset($config['condition'])) {
try {
$config['condition'] = Conditions::createCondition($config['condition'])->getConfig();
- } catch (InvalidArgumentException|InvalidConfigException) {
+ } catch (InvalidArgumentException|RuntimeException) {
// Ignore it
}
}
diff --git a/src/ProjectConfig/ProjectConfigHelper.php b/src/ProjectConfig/ProjectConfigHelper.php
index 9ed403a59c7..0d51b925d52 100644
--- a/src/ProjectConfig/ProjectConfigHelper.php
+++ b/src/ProjectConfig/ProjectConfigHelper.php
@@ -18,8 +18,8 @@
use CraftCms\DependencyAwareCache\Facades\DependencyCache;
use Illuminate\Support\Facades\Log;
use InvalidArgumentException;
+use RuntimeException;
use StdClass;
-use yii\base\InvalidConfigException;
class ProjectConfigHelper
{
@@ -259,7 +259,7 @@ public static function reset(): void
*
* @param array $config Config array to clean
*
- * @throws InvalidConfigException if config contains unexpected data.
+ * @throws RuntimeException if config contains unexpected data.
*/
public static function cleanupConfig(array $config): array
{
@@ -282,7 +282,7 @@ public static function cleanupConfig(array $config): array
/**
* Cleans a config value.
*
- * @throws InvalidConfigException
+ * @throws RuntimeException
*/
private static function _cleanupConfigValue(mixed $value): mixed
{
@@ -293,7 +293,7 @@ private static function _cleanupConfigValue(mixed $value): mixed
if (! empty($value) && ! is_scalar($value) && ! is_array($value)) {
Log::info('Unexpected data encountered in config data - '.print_r($value, true));
- throw new InvalidConfigException('Unexpected data encountered in config data');
+ throw new RuntimeException('Unexpected data encountered in config data');
}
if (is_array($value)) {
diff --git a/src/Providers/AppServiceProvider.php b/src/Providers/AppServiceProvider.php
index b076009ab43..b6d2709cd3c 100644
--- a/src/Providers/AppServiceProvider.php
+++ b/src/Providers/AppServiceProvider.php
@@ -9,6 +9,7 @@
use CraftCms\Cms\Edition;
use CraftCms\Cms\GarbageCollection\GarbageCollection;
use CraftCms\Cms\Http\Mixins\RequestMixin;
+use CraftCms\Cms\Http\Mixins\SessionMixin;
use CraftCms\Cms\ProjectConfig\ProjectConfig;
use CraftCms\Cms\Support\Env;
use CraftCms\Cms\Support\Facades\Path;
@@ -29,6 +30,7 @@
use Illuminate\Support\Facades\Event;
use Illuminate\Support\Facades\Gate;
use Illuminate\Support\Facades\Redirect;
+use Illuminate\Support\Facades\Session;
use Illuminate\Support\Facades\URL;
use Illuminate\Support\ServiceProvider;
use Illuminate\Validation\Rules\Password;
@@ -123,6 +125,7 @@ private function registerMacros(): void
});
Request::mixin(new RequestMixin);
+ Session::mixin(new SessionMixin);
Response::macro('setNoCacheHeaders', function (bool $replace = true) {
$this->header('Expires', '0', $replace);
diff --git a/src/Queue/BatchedElementJob.php b/src/Queue/BatchedElementJob.php
index cf81ca15575..873ec76d3a4 100644
--- a/src/Queue/BatchedElementJob.php
+++ b/src/Queue/BatchedElementJob.php
@@ -4,7 +4,7 @@
namespace CraftCms\Cms\Queue;
-use craft\base\ElementInterface;
+use CraftCms\Cms\Element\Contracts\ElementInterface;
use CraftCms\Cms\Element\Queries\ElementQuery;
use CraftCms\Cms\Support\Facades\BulkOps;
use CraftCms\Cms\Support\Typecast;
diff --git a/src/Queue/BatchedJob.php b/src/Queue/BatchedJob.php
index 9c50d7b2d92..75bc27bbb2e 100644
--- a/src/Queue/BatchedJob.php
+++ b/src/Queue/BatchedJob.php
@@ -7,6 +7,7 @@
use CraftCms\Cms\Support\Facades\I18N;
use CraftCms\Cms\Support\PHP;
use Illuminate\Contracts\Database\Query\Builder;
+use Illuminate\Queue\Jobs\SyncJob;
use Override;
use function CraftCms\Cms\t;
@@ -127,6 +128,13 @@ protected function spawnNextBatch(): void
{
$nextJob = clone $this;
$nextJob->batchIndex++;
+
+ if ($nextJob->job instanceof SyncJob) {
+ $nextJob->handle();
+
+ return;
+ }
+
dispatch($nextJob);
}
diff --git a/src/Queue/Job.php b/src/Queue/Job.php
index 42d7219d0a3..ea493eb9553 100644
--- a/src/Queue/Job.php
+++ b/src/Queue/Job.php
@@ -89,6 +89,14 @@ public function middleware(): array
*/
public function shouldStillRun(): bool
{
+ if (
+ $this->job !== null &&
+ method_exists($this->job, 'getConnectionName') &&
+ $this->job->getConnectionName() === 'sync'
+ ) {
+ return true;
+ }
+
$uuid = $this->job?->uuid();
if ($uuid === null) {
diff --git a/src/Route/MatchedElement.php b/src/Route/MatchedElement.php
index 0a5c1cc0022..a34300a3ec4 100644
--- a/src/Route/MatchedElement.php
+++ b/src/Route/MatchedElement.php
@@ -4,7 +4,7 @@
namespace CraftCms\Cms\Route;
-use craft\base\ElementInterface;
+use CraftCms\Cms\Element\Contracts\ElementInterface;
use Illuminate\Support\Facades\Context;
/**
diff --git a/src/RouteToken/Data/RouteToken.php b/src/RouteToken/Data/RouteToken.php
index ba5bc554884..e000ed192ac 100644
--- a/src/RouteToken/Data/RouteToken.php
+++ b/src/RouteToken/Data/RouteToken.php
@@ -4,9 +4,9 @@
namespace CraftCms\Cms\RouteToken\Data;
-use craft\base\ElementInterface;
use CraftCms\Cms\Component\Component;
use CraftCms\Cms\Database\Table;
+use CraftCms\Cms\Element\Contracts\ElementInterface;
use Illuminate\Validation\Rule;
class RouteToken extends Component
diff --git a/src/Search/Events/BeforeIndexKeywords.php b/src/Search/Events/BeforeIndexKeywords.php
index 5036094ce8c..cbb19d49b8f 100644
--- a/src/Search/Events/BeforeIndexKeywords.php
+++ b/src/Search/Events/BeforeIndexKeywords.php
@@ -4,7 +4,7 @@
namespace CraftCms\Cms\Search\Events;
-use craft\base\ElementInterface;
+use CraftCms\Cms\Element\Contracts\ElementInterface;
use CraftCms\Cms\Shared\Concerns\ValidatableEvent;
/**
diff --git a/src/Search/Jobs/UpdateSearchIndex.php b/src/Search/Jobs/UpdateSearchIndex.php
index cb69b9cc86b..9ecf5959ba9 100644
--- a/src/Search/Jobs/UpdateSearchIndex.php
+++ b/src/Search/Jobs/UpdateSearchIndex.php
@@ -4,7 +4,7 @@
namespace CraftCms\Cms\Search\Jobs;
-use craft\base\ElementInterface;
+use CraftCms\Cms\Element\Contracts\ElementInterface;
use CraftCms\Cms\Queue\Job;
use CraftCms\Cms\Support\Facades\I18N;
use CraftCms\Cms\Support\Facades\Search;
diff --git a/src/Search/Search.php b/src/Search/Search.php
index 7756fd1acba..2a006ca572e 100644
--- a/src/Search/Search.php
+++ b/src/Search/Search.php
@@ -4,9 +4,9 @@
namespace CraftCms\Cms\Search;
-use craft\base\ElementInterface;
use CraftCms\Cms\Cms;
use CraftCms\Cms\Database\Table;
+use CraftCms\Cms\Element\Contracts\ElementInterface;
use CraftCms\Cms\Element\Queries\Contracts\ElementQueryInterface;
use CraftCms\Cms\Element\Queries\ElementQuery;
use CraftCms\Cms\Field\Contracts\FieldInterface;
@@ -166,7 +166,7 @@ public function queueIndexElement(ElementInterface $element, array $fieldHandles
elementId: $element->id,
siteId: $element->siteId,
queued: true,
- ))->onQueue(Cms::config()->lowPriorityQueueName);
+ ))->onQueue(Cms::config()->lowPriorityQueueName)->afterCommit();
}
/**
@@ -324,8 +324,8 @@ public function searchElements(ElementQueryInterface $elementQuery): array
if ($elementQuery instanceof ElementQuery) {
$elementQuery->reorder();
$elementQuery->select('elements.id as id');
- $elementQuery->getQuery()->offset = null;
- $elementQuery->getQuery()->limit = null;
+ $elementQuery->offset(null);
+ $elementQuery->limit(null);
$ids = $elementQuery->pluck('id')->all();
} else {
$ids = $elementQuery;
diff --git a/src/Section/Sections.php b/src/Section/Sections.php
index 408dc31f184..502c0213317 100644
--- a/src/Section/Sections.php
+++ b/src/Section/Sections.php
@@ -4,7 +4,6 @@
namespace CraftCms\Cms\Section;
-use Craft;
use CraftCms\Cms\Cms;
use CraftCms\Cms\Database\Table;
use CraftCms\Cms\Element\Element;
@@ -54,9 +53,9 @@
use Illuminate\Support\Facades\Auth;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Facades\Log;
+use RuntimeException;
use Throwable;
use Tpetry\QueryExpressions\Language\Alias;
-use yii\base\InvalidConfigException;
#[Scoped]
class Sections
@@ -752,7 +751,7 @@ public function handleChangedSection(ConfigEvent $event): void
});
$this->elements->restoreElements($typeEntries);
- } catch (InvalidConfigException) {
+ } catch (RuntimeException) {
// the entry type probably wasn't restored
}
}
diff --git a/src/Shared/Exceptions/NotSupportedException.php b/src/Shared/Exceptions/NotSupportedException.php
new file mode 100644
index 00000000000..2a77074af40
--- /dev/null
+++ b/src/Shared/Exceptions/NotSupportedException.php
@@ -0,0 +1,9 @@
+ getEditableDrafts(\craft\base\ElementInterface $element, string|null $permission = null)
- * @method static \craft\base\ElementInterface createDraft(\craft\base\ElementInterface $canonical, int|null $creatorId = null, string|null $name = null, string|null $notes = null, array $newAttributes = [], bool $provisional = false)
+ * @method static \Illuminate\Support\Collection<\CraftCms\Cms\Element\Contracts\ElementInterface> getEditableDrafts(\CraftCms\Cms\Element\Contracts\ElementInterface $element, string|null $permission = null)
+ * @method static \CraftCms\Cms\Element\Contracts\ElementInterface createDraft(\CraftCms\Cms\Element\Contracts\ElementInterface $canonical, int|null $creatorId = null, string|null $name = null, string|null $notes = null, array $newAttributes = [], bool $provisional = false)
* @method static string generateDraftName(int $canonicalId)
- * @method static bool saveElementAsDraft(\craft\base\ElementInterface $element, int|null $creatorId = null, string|null $name = null, string|null $notes = null, bool $markAsSaved = true)
- * @method static \craft\base\ElementInterface applyDraft(\craft\base\ElementInterface $draft, array $newAttributes = [])
- * @method static void removeDraftData(\craft\base\ElementInterface $draft)
+ * @method static bool saveElementAsDraft(\CraftCms\Cms\Element\Contracts\ElementInterface $element, int|null $creatorId = null, string|null $name = null, string|null $notes = null, bool $markAsSaved = true)
+ * @method static \CraftCms\Cms\Element\Contracts\ElementInterface applyDraft(\CraftCms\Cms\Element\Contracts\ElementInterface $draft, array $newAttributes = [])
+ * @method static void removeDraftData(\CraftCms\Cms\Element\Contracts\ElementInterface $draft)
* @method static void purgeUnsavedDrafts()
* @method static int insertDraftRow(string|null $name, string|null $notes = null, int|null $creatorId = null, int|null $canonicalId = null, bool $trackChanges = false, bool $provisional = false)
- * @method static \craft\base\ElementInterface[] withProvisionalDrafts(\craft\base\ElementInterface[] $elements, \CraftCms\Cms\User\Elements\User|null $user = null)
- * @method static void loadProvisionalChanges(\craft\base\ElementInterface[] $elements, \CraftCms\Cms\User\Elements\User|null $user = null)
+ * @method static \CraftCms\Cms\Element\Contracts\ElementInterface[] withProvisionalDrafts(\CraftCms\Cms\Element\Contracts\ElementInterface[] $elements, \CraftCms\Cms\User\Elements\User|null $user = null)
+ * @method static void loadProvisionalChanges(\CraftCms\Cms\Element\Contracts\ElementInterface[] $elements, \CraftCms\Cms\User\Elements\User|null $user = null)
*
* @see \CraftCms\Cms\Element\Drafts
*/
diff --git a/src/Support/Facades/ElementActions.php b/src/Support/Facades/ElementActions.php
index d9f7629a7d2..5403b33d602 100644
--- a/src/Support/Facades/ElementActions.php
+++ b/src/Support/Facades/ElementActions.php
@@ -8,8 +8,8 @@
use Override;
/**
- * @method static \CraftCms\Cms\Element\Contracts\ElementActionInterface[] availableActions(class-string<\craft\base\ElementInterface> $elementType, string $sourceKey, \CraftCms\Cms\Element\Queries\Contracts\ElementQueryInterface $elementQuery)
- * @method static \CraftCms\Cms\Element\Contracts\ElementActionInterface createAction(\CraftCms\Cms\Element\Contracts\ElementActionInterface|class-string<\CraftCms\Cms\Element\Contracts\ElementActionInterface>|array $action, class-string<\craft\base\ElementInterface> $elementType)
+ * @method static \CraftCms\Cms\Element\Contracts\ElementActionInterface[] availableActions(class-string<\CraftCms\Cms\Element\Contracts\ElementInterface> $elementType, string $sourceKey, \CraftCms\Cms\Element\Queries\Contracts\ElementQueryInterface $elementQuery)
+ * @method static \CraftCms\Cms\Element\Contracts\ElementActionInterface createAction(\CraftCms\Cms\Element\Contracts\ElementActionInterface|class-string<\CraftCms\Cms\Element\Contracts\ElementActionInterface>|array $action, class-string<\CraftCms\Cms\Element\Contracts\ElementInterface> $elementType)
* @method static array serializeActions(iterable<\CraftCms\Cms\Element\Contracts\ElementActionInterface> $actions)
* @method static \CraftCms\Cms\Element\Contracts\ElementActionInterface|null resolveAction(iterable<\CraftCms\Cms\Element\Contracts\ElementActionInterface> $actions, string $actionClass)
* @method static array invoke(\CraftCms\Cms\Element\Contracts\ElementActionInterface $action, \CraftCms\Cms\Element\Queries\Contracts\ElementQueryInterface $query)
diff --git a/src/Support/Facades/ElementActivity.php b/src/Support/Facades/ElementActivity.php
index c455628f8f5..32ab2ff8b24 100644
--- a/src/Support/Facades/ElementActivity.php
+++ b/src/Support/Facades/ElementActivity.php
@@ -8,8 +8,8 @@
use Override;
/**
- * @method static \Illuminate\Support\Collection getRecentActivity(\craft\base\ElementInterface $element, int|null $excludeUserId = null)
- * @method static void trackActivity(\craft\base\ElementInterface $element, \CraftCms\Cms\Element\Enums\ElementActivityType $type, \CraftCms\Cms\User\Elements\User|null $user = null)
+ * @method static \Illuminate\Support\Collection getRecentActivity(\CraftCms\Cms\Element\Contracts\ElementInterface $element, int|null $excludeUserId = null)
+ * @method static void trackActivity(\CraftCms\Cms\Element\Contracts\ElementInterface $element, \CraftCms\Cms\Element\Enums\ElementActivityType $type, \CraftCms\Cms\User\Elements\User|null $user = null)
*
* @see \CraftCms\Cms\Element\ElementActivity
*/
diff --git a/src/Support/Facades/ElementCaches.php b/src/Support/Facades/ElementCaches.php
index ab81f72ba69..9cab07f71d9 100644
--- a/src/Support/Facades/ElementCaches.php
+++ b/src/Support/Facades/ElementCaches.php
@@ -12,11 +12,11 @@
* @method static void startCollectingCacheInfo()
* @method static void collectCacheTags(array $tags)
* @method static void setCacheExpiryDate(\DateTime $expiryDate)
- * @method static void collectCacheInfoForElement(\craft\base\ElementInterface $element)
+ * @method static void collectCacheInfoForElement(\CraftCms\Cms\Element\Contracts\ElementInterface $element)
* @method static array stopCollectingCacheInfo()
* @method static array invalidateAll()
- * @method static array invalidateForElementType(class-string<\craft\base\ElementInterface> $elementType)
- * @method static array invalidateForElement(\craft\base\ElementInterface $element)
+ * @method static array invalidateForElementType(class-string<\CraftCms\Cms\Element\Contracts\ElementInterface> $elementType)
+ * @method static array invalidateForElement(\CraftCms\Cms\Element\Contracts\ElementInterface $element)
*
* @see \CraftCms\Cms\Element\ElementCaches
*/
diff --git a/src/Support/Facades/ElementExporters.php b/src/Support/Facades/ElementExporters.php
index 8c6c6e8a7ab..099e8220c1b 100644
--- a/src/Support/Facades/ElementExporters.php
+++ b/src/Support/Facades/ElementExporters.php
@@ -8,8 +8,8 @@
use Override;
/**
- * @method static \CraftCms\Cms\Element\Contracts\ElementExporterInterface[] availableExporters(class-string<\craft\base\ElementInterface> $elementType, string $sourceKey)
- * @method static \CraftCms\Cms\Element\Contracts\ElementExporterInterface createExporter(\CraftCms\Cms\Element\Contracts\ElementExporterInterface|class-string<\CraftCms\Cms\Element\Contracts\ElementExporterInterface>|array $exporter, class-string<\craft\base\ElementInterface> $elementType)
+ * @method static \CraftCms\Cms\Element\Contracts\ElementExporterInterface[] availableExporters(class-string<\CraftCms\Cms\Element\Contracts\ElementInterface> $elementType, string $sourceKey)
+ * @method static \CraftCms\Cms\Element\Contracts\ElementExporterInterface createExporter(\CraftCms\Cms\Element\Contracts\ElementExporterInterface|class-string<\CraftCms\Cms\Element\Contracts\ElementExporterInterface>|array $exporter, class-string<\CraftCms\Cms\Element\Contracts\ElementInterface> $elementType)
* @method static array serializeExporters(iterable<\CraftCms\Cms\Element\Contracts\ElementExporterInterface> $exporters)
* @method static \CraftCms\Cms\Element\Contracts\ElementExporterInterface|null resolveExporter(iterable<\CraftCms\Cms\Element\Contracts\ElementExporterInterface> $exporters, string $exporterClass)
* @method static \Symfony\Component\HttpFoundation\Response export(\CraftCms\Cms\Element\Contracts\ElementExporterInterface $exporter, \CraftCms\Cms\Element\Queries\Contracts\ElementQueryInterface $query, string $format = 'csv')
diff --git a/src/Support/Facades/ElementSources.php b/src/Support/Facades/ElementSources.php
index 4e62166d0b1..4fca2c67083 100644
--- a/src/Support/Facades/ElementSources.php
+++ b/src/Support/Facades/ElementSources.php
@@ -9,23 +9,23 @@
/**
* @method static \Illuminate\Support\Collection filterExtraHeadings(array[]|\Illuminate\Support\Collection $sources)
- * @method static \Illuminate\Support\Collection getSources(class-string<\craft\base\ElementInterface> $elementType, string $context = 'index', bool $withDisabled = false, string|null $page = null)
- * @method static bool sourceExists(class-string<\craft\base\ElementInterface> $elementType, string $sourceKey, string $context = 'index', bool $withDisabled = false, string|null $page = null)
- * @method static array|null findSource(class-string<\craft\base\ElementInterface> $elementType, string $sourceKey, string $context = 'index', bool $withDisabled = false, string|null $page = null)
- * @method static \Illuminate\Support\Collection getPages(class-string<\craft\base\ElementInterface> $elementType)
- * @method static string|null getFirstPage(class-string<\craft\base\ElementInterface> $elementType, string $context = 'index', bool $withDisabled = false)
- * @method static bool pageExists(class-string<\craft\base\ElementInterface> $elementType, string $page, string $context = 'index', bool $withDisabled = false)
+ * @method static \Illuminate\Support\Collection getSources(class-string<\CraftCms\Cms\Element\Contracts\ElementInterface> $elementType, string $context = 'index', bool $withDisabled = false, string|null $page = null)
+ * @method static bool sourceExists(class-string<\CraftCms\Cms\Element\Contracts\ElementInterface> $elementType, string $sourceKey, string $context = 'index', bool $withDisabled = false, string|null $page = null)
+ * @method static array|null findSource(class-string<\CraftCms\Cms\Element\Contracts\ElementInterface> $elementType, string $sourceKey, string $context = 'index', bool $withDisabled = false, string|null $page = null)
+ * @method static \Illuminate\Support\Collection getPages(class-string<\CraftCms\Cms\Element\Contracts\ElementInterface> $elementType)
+ * @method static string|null getFirstPage(class-string<\CraftCms\Cms\Element\Contracts\ElementInterface> $elementType, string $context = 'index', bool $withDisabled = false)
+ * @method static bool pageExists(class-string<\CraftCms\Cms\Element\Contracts\ElementInterface> $elementType, string $page, string $context = 'index', bool $withDisabled = false)
* @method static string pageNameId(string $page)
- * @method static void saveSources(class-string<\craft\base\ElementInterface> $elementType, array $sources)
- * @method static \Illuminate\Support\Collection getAvailableTableAttributes(class-string<\craft\base\ElementInterface> $elementType)
- * @method static \Illuminate\Support\Collection getTableAttributes(class-string<\craft\base\ElementInterface> $elementType, string $sourceKey, string[]|null $customAttributes = null, \CraftCms\Cms\FieldLayout\FieldLayout[]|null $fieldLayouts = null)
- * @method static \Illuminate\Support\Collection<\CraftCms\Cms\FieldLayout\FieldLayout> getFieldLayoutsForSource(class-string<\craft\base\ElementInterface> $elementType, string $sourceKey)
- * @method static \Illuminate\Support\Collection getSourceSortOptions(class-string<\craft\base\ElementInterface> $elementType, string $sourceKey)
+ * @method static void saveSources(class-string<\CraftCms\Cms\Element\Contracts\ElementInterface> $elementType, array $sources)
+ * @method static \Illuminate\Support\Collection getAvailableTableAttributes(class-string<\CraftCms\Cms\Element\Contracts\ElementInterface> $elementType)
+ * @method static \Illuminate\Support\Collection getTableAttributes(class-string<\CraftCms\Cms\Element\Contracts\ElementInterface> $elementType, string $sourceKey, string[]|null $customAttributes = null, \CraftCms\Cms\FieldLayout\FieldLayout[]|null $fieldLayouts = null)
+ * @method static \Illuminate\Support\Collection<\CraftCms\Cms\FieldLayout\FieldLayout> getFieldLayoutsForSource(class-string<\CraftCms\Cms\Element\Contracts\ElementInterface> $elementType, string $sourceKey)
+ * @method static \Illuminate\Support\Collection getSourceSortOptions(class-string<\CraftCms\Cms\Element\Contracts\ElementInterface> $elementType, string $sourceKey)
* @method static \Illuminate\Support\Collection getSortOptionsForFieldLayouts(\CraftCms\Cms\FieldLayout\FieldLayout[]|\Illuminate\Support\Collection<\CraftCms\Cms\FieldLayout\FieldLayout> $fieldLayouts)
- * @method static \Illuminate\Support\Collection getSourceTableAttributes(class-string<\craft\base\ElementInterface> $elementType, string $sourceKey)
+ * @method static \Illuminate\Support\Collection getSourceTableAttributes(class-string<\CraftCms\Cms\Element\Contracts\ElementInterface> $elementType, string $sourceKey)
* @method static \Illuminate\Support\Collection getTableAttributesForFieldLayouts(\CraftCms\Cms\FieldLayout\FieldLayout[]|\Illuminate\Support\Collection<\CraftCms\Cms\FieldLayout\FieldLayout> $fieldLayouts)
- * @method static array getPageSettings(class-string<\craft\base\ElementInterface> $elementType)
- * @method static void savePageSettings(class-string<\craft\base\ElementInterface> $elementType, array $pageSettings)
+ * @method static array getPageSettings(class-string<\CraftCms\Cms\Element\Contracts\ElementInterface> $elementType)
+ * @method static void savePageSettings(class-string<\CraftCms\Cms\Element\Contracts\ElementInterface> $elementType, array $pageSettings)
*
* @see \CraftCms\Cms\Element\ElementSources
*/
diff --git a/src/Support/Facades/Elements.php b/src/Support/Facades/Elements.php
index ef514e24c37..541d2e5e871 100644
--- a/src/Support/Facades/Elements.php
+++ b/src/Support/Facades/Elements.php
@@ -8,44 +8,44 @@
use Override;
/**
- * @method static class-string<\craft\base\ElementInterface>|null getElementTypeById(int $elementId)
+ * @method static class-string<\CraftCms\Cms\Element\Contracts\ElementInterface>|null getElementTypeById(int $elementId)
* @method static string|null getElementTypeByUid(string $uid)
* @method static string|null getElementTypeByKey(string $property, int|string $elementId)
* @method static string[] getElementTypesByIds(int[] $elementIds)
- * @method static class-string<\craft\base\ElementInterface>[] getAllElementTypes()
+ * @method static class-string<\CraftCms\Cms\Element\Contracts\ElementInterface>[] getAllElementTypes()
* @method static string|null getElementTypeByRefHandle(string $refHandle)
- * @method static \craft\base\ElementInterface createElement(class-string<\craft\base\ElementInterface>|array $config)
- * @method static \CraftCms\Cms\Element\Queries\Contracts\ElementQueryInterface createElementQuery(class-string<\craft\base\ElementInterface> $elementType)
- * @method static \craft\base\ElementInterface|null getElementById(int $elementId, class-string<\craft\base\ElementInterface>|null $elementType = null, int|string|int[]|null $siteId = null, array $criteria = [])
- * @method static \craft\base\ElementInterface|null getElementByUid(string $uid, class-string<\craft\base\ElementInterface>|null $elementType = null, int|string|int[]|null $siteId = null, array $criteria = [])
- * @method static \craft\base\ElementInterface|null getElementByUri(string $uri, int|null $siteId = null, bool $enabledOnly = false)
+ * @method static \CraftCms\Cms\Element\Contracts\ElementInterface createElement(class-string<\CraftCms\Cms\Element\Contracts\ElementInterface>|array $config)
+ * @method static \CraftCms\Cms\Element\Queries\Contracts\ElementQueryInterface createElementQuery(class-string<\CraftCms\Cms\Element\Contracts\ElementInterface> $elementType)
+ * @method static \CraftCms\Cms\Element\Contracts\ElementInterface|null getElementById(int $elementId, class-string<\CraftCms\Cms\Element\Contracts\ElementInterface>|null $elementType = null, int|string|int[]|null $siteId = null, array $criteria = [])
+ * @method static \CraftCms\Cms\Element\Contracts\ElementInterface|null getElementByUid(string $uid, class-string<\CraftCms\Cms\Element\Contracts\ElementInterface>|null $elementType = null, int|string|int[]|null $siteId = null, array $criteria = [])
+ * @method static \CraftCms\Cms\Element\Contracts\ElementInterface|null getElementByUri(string $uri, int|null $siteId = null, bool $enabledOnly = false)
* @method static string|null getElementUriForSite(int $elementId, int $siteId)
* @method static int[] getEnabledSiteIdsForElement(int $elementId)
- * @method static bool saveElement(\craft\base\ElementInterface $element, bool $runValidation = true, bool $propagate = true, bool|null $updateSearchIndex = null, bool $forceTouch = false, bool|null $crossSiteValidate = false, bool $saveContent = false)
- * @method static void setElementUri(\craft\base\ElementInterface $element)
- * @method static void mergeCanonicalChanges(\craft\base\ElementInterface $element)
- * @method static \craft\base\ElementInterface updateCanonicalElement(\craft\base\ElementInterface $element, array $newAttributes = [])
+ * @method static bool saveElement(\CraftCms\Cms\Element\Contracts\ElementInterface $element, bool $runValidation = true, bool $propagate = true, bool|null $updateSearchIndex = null, bool $forceTouch = false, bool|null $crossSiteValidate = false, bool $saveContent = false)
+ * @method static void setElementUri(\CraftCms\Cms\Element\Contracts\ElementInterface $element)
+ * @method static void mergeCanonicalChanges(\CraftCms\Cms\Element\Contracts\ElementInterface $element)
+ * @method static \CraftCms\Cms\Element\Contracts\ElementInterface updateCanonicalElement(\CraftCms\Cms\Element\Contracts\ElementInterface $element, array $newAttributes = [])
* @method static void resaveElements(\CraftCms\Cms\Element\Queries\Contracts\ElementQueryInterface $query, bool $continueOnError = false, bool $skipRevisions = true, bool|null $updateSearchIndex = null, bool $touch = false)
* @method static void propagateElements(\CraftCms\Cms\Element\Queries\Contracts\ElementQueryInterface $query, int|int[]|null $siteIds = null, bool $continueOnError = false)
- * @method static \craft\base\ElementInterface propagateElement(\craft\base\ElementInterface $element, int $siteId, \craft\base\ElementInterface|false|null $siteElement = null)
- * @method static \craft\base\ElementInterface duplicateElement(\craft\base\ElementInterface $element, array $newAttributes = [], bool $placeInStructure = true, bool $asUnpublishedDraft = false, bool $checkAuthorization = false, bool $copyModifiedFields = false)
- * @method static void updateElementSlugAndUri(\craft\base\ElementInterface $element, bool $updateOtherSites = true, bool $updateDescendants = true, bool $queue = false)
- * @method static void updateElementSlugAndUriInOtherSites(\craft\base\ElementInterface $element)
- * @method static void updateDescendantSlugsAndUris(\craft\base\ElementInterface $element, bool $updateOtherSites = true, bool $queue = false)
+ * @method static \CraftCms\Cms\Element\Contracts\ElementInterface propagateElement(\CraftCms\Cms\Element\Contracts\ElementInterface $element, int $siteId, \CraftCms\Cms\Element\Contracts\ElementInterface|false|null $siteElement = null)
+ * @method static \CraftCms\Cms\Element\Contracts\ElementInterface duplicateElement(\CraftCms\Cms\Element\Contracts\ElementInterface $element, array $newAttributes = [], bool $placeInStructure = true, bool $asUnpublishedDraft = false, bool $checkAuthorization = false, bool $copyModifiedFields = false)
+ * @method static void updateElementSlugAndUri(\CraftCms\Cms\Element\Contracts\ElementInterface $element, bool $updateOtherSites = true, bool $updateDescendants = true, bool $queue = false)
+ * @method static void updateElementSlugAndUriInOtherSites(\CraftCms\Cms\Element\Contracts\ElementInterface $element)
+ * @method static void updateDescendantSlugsAndUris(\CraftCms\Cms\Element\Contracts\ElementInterface $element, bool $updateOtherSites = true, bool $queue = false)
* @method static bool mergeElementsByIds(int $mergedElementId, int $prevailingElementId)
- * @method static bool mergeElements(\craft\base\ElementInterface $mergedElement, \craft\base\ElementInterface $prevailingElement)
- * @method static bool deleteElementById(int $elementId, class-string<\craft\base\ElementInterface>|null $elementType = null, int|null $siteId = null, bool $hardDelete = false)
- * @method static bool deleteElement(\craft\base\ElementInterface $element, bool $hardDelete = false)
- * @method static void deleteElementForSite(\craft\base\ElementInterface $element)
- * @method static void deleteElementsForSite(\craft\base\ElementInterface[] $elements)
- * @method static bool restoreElement(\craft\base\ElementInterface $element)
- * @method static bool restoreElements(\craft\base\ElementInterface[] $elements)
+ * @method static bool mergeElements(\CraftCms\Cms\Element\Contracts\ElementInterface $mergedElement, \CraftCms\Cms\Element\Contracts\ElementInterface $prevailingElement)
+ * @method static bool deleteElementById(int $elementId, class-string<\CraftCms\Cms\Element\Contracts\ElementInterface>|null $elementType = null, int|null $siteId = null, bool $hardDelete = false)
+ * @method static bool deleteElement(\CraftCms\Cms\Element\Contracts\ElementInterface $element, bool $hardDelete = false)
+ * @method static void deleteElementForSite(\CraftCms\Cms\Element\Contracts\ElementInterface $element)
+ * @method static void deleteElementsForSite(\CraftCms\Cms\Element\Contracts\ElementInterface[] $elements)
+ * @method static bool restoreElement(\CraftCms\Cms\Element\Contracts\ElementInterface $element)
+ * @method static bool restoreElements(\CraftCms\Cms\Element\Contracts\ElementInterface[] $elements)
* @method static string parseRefs(string $str, int|null $defaultSiteId = null)
- * @method static void setPlaceholderElement(\craft\base\ElementInterface $element)
- * @method static \craft\base\ElementInterface[] getPlaceholderElements()
- * @method static \craft\base\ElementInterface|null getPlaceholderElement(int $sourceId, int $siteId)
+ * @method static void setPlaceholderElement(\CraftCms\Cms\Element\Contracts\ElementInterface $element)
+ * @method static \CraftCms\Cms\Element\Contracts\ElementInterface[] getPlaceholderElements()
+ * @method static \CraftCms\Cms\Element\Contracts\ElementInterface|null getPlaceholderElement(int $sourceId, int $siteId)
* @method static \CraftCms\Cms\Element\Data\EagerLoadPlan[] createEagerLoadingPlans(array|string $with)
- * @method static void eagerLoadElements(class-string<\craft\base\ElementInterface> $elementType, \craft\base\ElementInterface[] $elements, array|string|\CraftCms\Cms\Element\Data\EagerLoadPlan[] $with)
+ * @method static void eagerLoadElements(class-string<\CraftCms\Cms\Element\Contracts\ElementInterface> $elementType, \CraftCms\Cms\Element\Contracts\ElementInterface[] $elements, array|string|\CraftCms\Cms\Element\Data\EagerLoadPlan[] $with)
*
* @see \CraftCms\Cms\Element\Elements
*/
diff --git a/src/Support/Facades/Fields.php b/src/Support/Facades/Fields.php
index 87e49b5172d..99836895e91 100644
--- a/src/Support/Facades/Fields.php
+++ b/src/Support/Facades/Fields.php
@@ -45,15 +45,15 @@
* @method static \CraftCms\Cms\FieldLayout\FieldLayout|null getLayoutById(int $layoutId, bool $withTrashed = false)
* @method static \CraftCms\Cms\FieldLayout\FieldLayout|null getLayoutByUid(string $uid)
* @method static \Illuminate\Support\Collection<\CraftCms\Cms\FieldLayout\FieldLayout> getLayoutsByIds(int[] $layoutIds)
- * @method static \CraftCms\Cms\FieldLayout\FieldLayout|null getLayoutByType(class-string<\craft\base\ElementInterface> $type, bool $create = true)
- * @method static \Illuminate\Support\Collection<\CraftCms\Cms\FieldLayout\FieldLayout> getLayoutsByType(class-string<\craft\base\ElementInterface> $type)
+ * @method static \CraftCms\Cms\FieldLayout\FieldLayout|null getLayoutByType(class-string<\CraftCms\Cms\Element\Contracts\ElementInterface> $type, bool $create = true)
+ * @method static \Illuminate\Support\Collection<\CraftCms\Cms\FieldLayout\FieldLayout> getLayoutsByType(class-string<\CraftCms\Cms\Element\Contracts\ElementInterface> $type)
* @method static \CraftCms\Cms\FieldLayout\FieldLayout createLayout(array $config)
* @method static \CraftCms\Cms\FieldLayout\FieldLayoutElement createLayoutElement(array $config)
* @method static \CraftCms\Cms\FieldLayout\FieldLayout assembleLayoutFromPost(string|null $namespace = null)
* @method static bool saveLayout(\CraftCms\Cms\FieldLayout\FieldLayout $layout, bool $runValidation = true)
* @method static bool deleteLayoutById(int|int[] $layoutId, bool $hardDelete = false)
* @method static bool deleteLayout(\CraftCms\Cms\FieldLayout\FieldLayout $layout, bool $hardDelete = false)
- * @method static bool deleteLayoutsByType(class-string<\craft\base\ElementInterface> $type)
+ * @method static bool deleteLayoutsByType(class-string<\CraftCms\Cms\Element\Contracts\ElementInterface> $type)
* @method static bool restoreLayoutById(int $id)
* @method static void applyFieldSave(string $fieldUid, array $data, string $context)
*
diff --git a/src/Support/Facades/Gql.php b/src/Support/Facades/Gql.php
index 2e8b072088b..f431956a140 100644
--- a/src/Support/Facades/Gql.php
+++ b/src/Support/Facades/Gql.php
@@ -37,12 +37,12 @@
* @method static \CraftCms\Cms\Gql\Data\GqlSchema|null getSchemaById(int $id)
* @method static \CraftCms\Cms\Gql\Data\GqlSchema|null getSchemaByUid(string $uid)
* @method static \CraftCms\Cms\Gql\Data\GqlSchema[] getSchemas()
- * @method static array getOrSetContentArguments(class-string<\craft\base\ElementInterface> $elementType, callable $setter)
+ * @method static array getOrSetContentArguments(class-string<\CraftCms\Cms\Element\Contracts\ElementInterface> $elementType, callable $setter)
* @method static array getFieldLayoutArguments(\CraftCms\Cms\FieldLayout\FieldLayout $fieldLayout)
- * @method static array defineContentArgumentsForFieldLayouts(class-string<\craft\base\ElementInterface> $elementType, \CraftCms\Cms\FieldLayout\FieldLayout[] $fieldLayouts)
- * @method static array defineContentArgumentsForFields(class-string<\craft\base\ElementInterface> $elementType, \CraftCms\Cms\Field\Contracts\FieldInterface[] $fields)
- * @method static array defineContentArgumentsForGeneratedFields(class-string<\craft\base\ElementInterface> $elementType, array $fields)
- * @method static array getContentArguments(array $contexts, class-string<\craft\base\ElementInterface> $elementType)
+ * @method static array defineContentArgumentsForFieldLayouts(class-string<\CraftCms\Cms\Element\Contracts\ElementInterface> $elementType, \CraftCms\Cms\FieldLayout\FieldLayout[] $fieldLayouts)
+ * @method static array defineContentArgumentsForFields(class-string<\CraftCms\Cms\Element\Contracts\ElementInterface> $elementType, \CraftCms\Cms\Field\Contracts\FieldInterface[] $fields)
+ * @method static array defineContentArgumentsForGeneratedFields(class-string<\CraftCms\Cms\Element\Contracts\ElementInterface> $elementType, array $fields)
+ * @method static array getContentArguments(array $contexts, class-string<\CraftCms\Cms\Element\Contracts\ElementInterface> $elementType)
* @method static \GraphQL\Error\Error[] handleQueryErrors(\GraphQL\Error\Error[] $errors, callable $formatter)
* @method static array prepareFieldDefinitions(array $fields, string $typeName)
*
diff --git a/src/Support/Facades/Revisions.php b/src/Support/Facades/Revisions.php
index 25e7f9efcc1..f433e3be002 100644
--- a/src/Support/Facades/Revisions.php
+++ b/src/Support/Facades/Revisions.php
@@ -8,8 +8,8 @@
use Override;
/**
- * @method static int createRevision(\craft\base\ElementInterface $canonical, int|null $creatorId = null, string|null $notes = null, array $newAttributes = [], bool $force = false)
- * @method static \craft\base\ElementInterface revertToRevision(\craft\base\ElementInterface $revision, int $creatorId)
+ * @method static int createRevision(\CraftCms\Cms\Element\Contracts\ElementInterface $canonical, int|null $creatorId = null, string|null $notes = null, array $newAttributes = [], bool $force = false)
+ * @method static \CraftCms\Cms\Element\Contracts\ElementInterface revertToRevision(\CraftCms\Cms\Element\Contracts\ElementInterface $revision, int $creatorId)
*
* @see \CraftCms\Cms\Element\Revisions
*/
diff --git a/src/Support/Facades/Search.php b/src/Support/Facades/Search.php
index 1a4b536781c..5c0a1a3c17d 100644
--- a/src/Support/Facades/Search.php
+++ b/src/Support/Facades/Search.php
@@ -8,9 +8,9 @@
use Override;
/**
- * @method static bool indexElementAttributes(\craft\base\ElementInterface $element, array|null $fieldHandles = null)
- * @method static void queueIndexElement(\craft\base\ElementInterface $element, string[] $fieldHandles)
- * @method static void indexElementIfQueued(int $elementId, int $siteId, class-string<\craft\base\ElementInterface>|null $elementType = null)
+ * @method static bool indexElementAttributes(\CraftCms\Cms\Element\Contracts\ElementInterface $element, array|null $fieldHandles = null)
+ * @method static void queueIndexElement(\CraftCms\Cms\Element\Contracts\ElementInterface $element, string[] $fieldHandles)
+ * @method static void indexElementIfQueued(int $elementId, int $siteId, class-string<\CraftCms\Cms\Element\Contracts\ElementInterface>|null $elementType = null)
* @method static bool shouldCallSearchElements(\CraftCms\Cms\Element\Queries\Contracts\ElementQueryInterface $elementQuery)
* @method static array searchElements(\CraftCms\Cms\Element\Queries\Contracts\ElementQueryInterface $elementQuery)
* @method static \Illuminate\Database\Query\Builder|false createDbQuery(\CraftCms\Cms\Search\SearchQuery|array|string $searchQuery, \CraftCms\Cms\Element\Queries\Contracts\ElementQueryInterface $elementQuery)
diff --git a/src/Support/Facades/Structures.php b/src/Support/Facades/Structures.php
index a67bb13159b..e77dada3c56 100644
--- a/src/Support/Facades/Structures.php
+++ b/src/Support/Facades/Structures.php
@@ -9,18 +9,18 @@
/**
* @method static \CraftCms\Cms\Structure\Data\Structure|null getStructureById(int $structureId, bool $withTrashed = false)
* @method static \CraftCms\Cms\Structure\Data\Structure|null getStructureByUid(string $structureUid, bool $withTrashed = false)
- * @method static void fillGapsInElements(\craft\base\ElementInterface[] $elements)
- * @method static void applyBranchLimitToElements(\craft\base\ElementInterface[] $elements, int $branchLimit)
+ * @method static void fillGapsInElements(\CraftCms\Cms\Element\Contracts\ElementInterface[] $elements)
+ * @method static void applyBranchLimitToElements(\CraftCms\Cms\Element\Contracts\ElementInterface[] $elements, int $branchLimit)
* @method static bool saveStructure(\CraftCms\Cms\Structure\Data\Structure $structure)
* @method static bool deleteStructureById(int $structureId)
- * @method static int getElementLevelDelta(int $structureId, \craft\base\ElementInterface $element)
- * @method static bool prepend(int $structureId, \craft\base\ElementInterface $element, \craft\base\ElementInterface|int $parentElement, \CraftCms\Cms\Structure\Enums\Mode $mode = 'auto')
- * @method static bool append(int $structureId, \craft\base\ElementInterface $element, \craft\base\ElementInterface|int $parentElement, \CraftCms\Cms\Structure\Enums\Mode $mode = 'auto')
- * @method static bool prependToRoot(int $structureId, \craft\base\ElementInterface $element, \CraftCms\Cms\Structure\Enums\Mode $mode = 'auto')
- * @method static bool appendToRoot(int $structureId, \craft\base\ElementInterface $element, \CraftCms\Cms\Structure\Enums\Mode $mode = 'auto')
- * @method static bool moveBefore(int $structureId, \craft\base\ElementInterface $element, \craft\base\ElementInterface|int $nextElement, \CraftCms\Cms\Structure\Enums\Mode $mode = 'auto')
- * @method static bool moveAfter(int $structureId, \craft\base\ElementInterface $element, \craft\base\ElementInterface|int $prevElement, \CraftCms\Cms\Structure\Enums\Mode $mode = 'auto')
- * @method static bool remove(int $structureId, \craft\base\ElementInterface $element)
+ * @method static int getElementLevelDelta(int $structureId, \CraftCms\Cms\Element\Contracts\ElementInterface $element)
+ * @method static bool prepend(int $structureId, \CraftCms\Cms\Element\Contracts\ElementInterface $element, \CraftCms\Cms\Element\Contracts\ElementInterface|int $parentElement, \CraftCms\Cms\Structure\Enums\Mode $mode = 'auto')
+ * @method static bool append(int $structureId, \CraftCms\Cms\Element\Contracts\ElementInterface $element, \CraftCms\Cms\Element\Contracts\ElementInterface|int $parentElement, \CraftCms\Cms\Structure\Enums\Mode $mode = 'auto')
+ * @method static bool prependToRoot(int $structureId, \CraftCms\Cms\Element\Contracts\ElementInterface $element, \CraftCms\Cms\Structure\Enums\Mode $mode = 'auto')
+ * @method static bool appendToRoot(int $structureId, \CraftCms\Cms\Element\Contracts\ElementInterface $element, \CraftCms\Cms\Structure\Enums\Mode $mode = 'auto')
+ * @method static bool moveBefore(int $structureId, \CraftCms\Cms\Element\Contracts\ElementInterface $element, \CraftCms\Cms\Element\Contracts\ElementInterface|int $nextElement, \CraftCms\Cms\Structure\Enums\Mode $mode = 'auto')
+ * @method static bool moveAfter(int $structureId, \CraftCms\Cms\Element\Contracts\ElementInterface $element, \CraftCms\Cms\Element\Contracts\ElementInterface|int $prevElement, \CraftCms\Cms\Structure\Enums\Mode $mode = 'auto')
+ * @method static bool remove(int $structureId, \CraftCms\Cms\Element\Contracts\ElementInterface $element)
*
* @see \CraftCms\Cms\Structure\Structures
*/
diff --git a/src/Support/Flash.php b/src/Support/Flash.php
index 323dd42632b..261decb8a34 100644
--- a/src/Support/Flash.php
+++ b/src/Support/Flash.php
@@ -12,31 +12,39 @@ public static function success(?string $default = null, array $settings = []): v
{
$message = request('successMessage', $default);
- if ($message !== null) {
- if (request()->isCpRequest()) {
- session()->flash('cp-notification-success', [$message, $settings + [
- 'icon' => 'check',
- 'iconLabel' => t('Success'),
- ]]);
- } else {
- session()->flash('success', $message);
- }
+ if (is_null($message)) {
+ return;
}
+
+ if (! request()->isCpRequest()) {
+ session()->flash('success', $message);
+
+ return;
+ }
+
+ session()->flash('cp-notification-success', [$message, $settings + [
+ 'icon' => 'check',
+ 'iconLabel' => t('Success'),
+ ]]);
}
public static function fail(?string $default = null, array $settings = []): void
{
$message = request('failMessage', $default);
- if ($message !== null) {
- if (request()->isCpRequest()) {
- session()->flash('cp-notification-error', [$message, $settings + [
- 'icon' => 'alert',
- 'iconLabel' => t('Error'),
- ]]);
- } else {
- session()->flash('error', $message);
- }
+ if ($message === null) {
+ return;
}
+
+ if (! request()->isCpRequest()) {
+ session()->flash('error', $message);
+
+ return;
+ }
+
+ session()->flash('cp-notification-error', [$message, $settings + [
+ 'icon' => 'alert',
+ 'iconLabel' => t('Error'),
+ ]]);
}
}
diff --git a/src/Support/Html.php b/src/Support/Html.php
index 7da09780bdc..fc776209771 100644
--- a/src/Support/Html.php
+++ b/src/Support/Html.php
@@ -20,10 +20,10 @@
use Illuminate\Support\Facades\Crypt;
use Illuminate\Support\Facades\Log;
use InvalidArgumentException;
+use RuntimeException;
use Stringable;
use Symfony\Component\DomCrawler\Crawler;
use Throwable;
-use yii\base\InvalidConfigException;
use Yiisoft\Html\Html as YiiHtml;
use Yiisoft\Html\NoEncode;
use Yiisoft\Html\Tag\Button;
@@ -224,7 +224,7 @@ public static function actionInput(string $route, array $options = []): string
* @return string The generated hidden input tag
*
* @throws \yii\base\Exception if the validation key could not be written
- * @throws InvalidConfigException when HMAC generation fails
+ * @throws RuntimeException when HMAC generation fails
*/
public static function redirectInput(string $url, array $options = []): string
{
@@ -242,7 +242,7 @@ public static function redirectInput(string $url, array $options = []): string
* @return string The generated hidden input tag
*
* @throws Exception if the validation key could not be written
- * @throws InvalidConfigException when HMAC generation fails
+ * @throws RuntimeException when HMAC generation fails
*/
public static function failMessageInput(string $message, array $options = []): string
{
@@ -260,7 +260,7 @@ public static function failMessageInput(string $message, array $options = []): s
* @return string The generated hidden input tag
*
* @throws Exception if the validation key could not be written
- * @throws InvalidConfigException when HMAC generation fails
+ * @throws RuntimeException when HMAC generation fails
*/
public static function successMessageInput(string $message, array $options = []): string
{
diff --git a/src/Support/Str.php b/src/Support/Str.php
index 9d5c4e9cddb..7c27e15c15c 100644
--- a/src/Support/Str.php
+++ b/src/Support/Str.php
@@ -13,9 +13,9 @@
use Override;
use Ramsey\Uuid\Validator\GenericValidator;
use ReflectionClass;
+use RuntimeException;
use voku\helper\ASCII;
use yii\base\Exception;
-use yii\base\InvalidConfigException;
class Str extends \Illuminate\Support\Str
{
@@ -136,7 +136,7 @@ public static function convertLineBreaks(string $str): string
*
* @param string $str The string.
*
- * @throws InvalidConfigException on OpenSSL not loaded
+ * @throws RuntimeException on OpenSSL not loaded
* @throws Exception on OpenSSL error
*/
public static function decdec(string $str): string
@@ -175,7 +175,7 @@ public static function emojiToShortcodes(string $str): string
*
* @param string $str the string
*
- * @throws InvalidConfigException on OpenSSL not loaded
+ * @throws RuntimeException on OpenSSL not loaded
* @throws Exception on OpenSSL error
*
* @see decdec()
diff --git a/src/Support/Template.php b/src/Support/Template.php
index 920855ab344..69980c86991 100644
--- a/src/Support/Template.php
+++ b/src/Support/Template.php
@@ -4,8 +4,8 @@
namespace CraftCms\Cms\Support;
-use craft\base\ElementInterface;
use CraftCms\Cms\Cms;
+use CraftCms\Cms\Element\Contracts\ElementInterface;
use CraftCms\Cms\Shared\BaseModel;
use CraftCms\Cms\Support\Facades\ElementCaches;
use CraftCms\Cms\Support\Facades\Entries;
@@ -14,6 +14,7 @@
use CraftCms\Cms\Twig\Variables\Paginate;
use CraftCms\Cms\View\Enums\Position;
use Illuminate\Database\Query\Builder;
+use RuntimeException;
use Stringable;
use Twig\Environment;
use Twig\Error\RuntimeError;
@@ -24,7 +25,6 @@
use Twig\Template as TwigTemplate;
use Twig\TemplateWrapper;
use yii\base\BaseObject;
-use yii\base\InvalidConfigException;
use yii\base\UnknownPropertyException;
class Template
@@ -145,7 +145,7 @@ public static function html(string $html, int|Position $position = Position::Bod
HtmlStack::html($html, $position);
}
- /** @throws InvalidConfigException */
+ /** @throws RuntimeException */
public static function js(string $js, array $options = [], ?string $key = null): void
{
if (preg_match('/^[^\r\n]+\.js(\.gz)?$/i', $js) || Url::isAbsoluteUrl($js)) {
diff --git a/src/Twig/Extensions/ArrayTwigExtension.php b/src/Twig/Extensions/ArrayTwigExtension.php
index e51c7c0fc15..800dcba6053 100644
--- a/src/Twig/Extensions/ArrayTwigExtension.php
+++ b/src/Twig/Extensions/ArrayTwigExtension.php
@@ -4,7 +4,7 @@
namespace CraftCms\Cms\Twig\Extensions;
-use craft\base\ElementInterface;
+use CraftCms\Cms\Element\Contracts\ElementInterface;
use CraftCms\Cms\Element\ElementCollection;
use CraftCms\Cms\Support\Arr;
use Illuminate\Support\Collection;
diff --git a/src/Twig/Extensions/CoreTwigExtension.php b/src/Twig/Extensions/CoreTwigExtension.php
index c084788563b..e3ac4803db6 100644
--- a/src/Twig/Extensions/CoreTwigExtension.php
+++ b/src/Twig/Extensions/CoreTwigExtension.php
@@ -6,11 +6,11 @@
use CommerceGuys\Addressing\Formatter\FormatterInterface;
use Craft;
-use craft\base\ElementInterface;
use CraftCms\Aliases\Aliases;
use CraftCms\Cms\Address\Addresses;
use CraftCms\Cms\Address\Elements\Address;
use CraftCms\Cms\Component\Contracts\MissingComponentInterface;
+use CraftCms\Cms\Element\Contracts\ElementInterface;
use CraftCms\Cms\Element\Element;
use CraftCms\Cms\Element\Queries\AddressQuery;
use CraftCms\Cms\Element\Queries\AssetQuery;
diff --git a/src/Twig/Extensions/HtmlTwigExtension.php b/src/Twig/Extensions/HtmlTwigExtension.php
index c3069652053..2bee49bd0fb 100644
--- a/src/Twig/Extensions/HtmlTwigExtension.php
+++ b/src/Twig/Extensions/HtmlTwigExtension.php
@@ -17,11 +17,11 @@
use Illuminate\Support\Facades\Log;
use InvalidArgumentException;
use Override;
+use RuntimeException;
use Symfony\Component\HtmlSanitizer\HtmlSanitizerInterface;
use Twig\Extension\AbstractExtension;
use Twig\TwigFilter;
use Twig\TwigFunction;
-use yii\base\InvalidConfigException;
class HtmlTwigExtension extends AbstractExtension
{
@@ -170,7 +170,7 @@ public function markdownFilter(
}
/**
- * @throws InvalidConfigException
+ * @throws RuntimeException
* @throws AssetException
*/
public function dataUrlFunction(Asset|string $file, ?string $mimeType = null): string
diff --git a/src/User/Commands/CreateCommand.php b/src/User/Commands/CreateCommand.php
index a6309811cc5..314aea53278 100644
--- a/src/User/Commands/CreateCommand.php
+++ b/src/User/Commands/CreateCommand.php
@@ -64,7 +64,7 @@ public function handle(Elements $elements, GeneralConfig $generalConfig, Users $
if (! empty($attributes) && ! $user->validate(array_keys($attributes))) {
$this->error('Invalid arguments:');
- $this->error(implode(PHP_EOL, $user->getErrorSummary(true)));
+ $this->error(implode(PHP_EOL, $user->errors()->all()));
return self::FAILURE;
}
@@ -126,7 +126,7 @@ public function handle(Elements $elements, GeneralConfig $generalConfig, Users $
if ($failed) {
$this->components->error('Failed to save the user.');
- $this->components->error(implode(PHP_EOL, $user->getErrorSummary(true)));
+ $this->components->error(implode(PHP_EOL, $user->errors()->all()));
return self::FAILURE;
}
diff --git a/src/User/Conditions/AdminConditionRule.php b/src/User/Conditions/AdminConditionRule.php
index b306589ba39..204f045a264 100644
--- a/src/User/Conditions/AdminConditionRule.php
+++ b/src/User/Conditions/AdminConditionRule.php
@@ -4,11 +4,11 @@
namespace CraftCms\Cms\User\Conditions;
-use craft\base\ElementInterface;
use craft\elements\db\UserQuery;
use CraftCms\Cms\Condition\BaseLightswitchConditionRule;
use CraftCms\Cms\Edition;
use CraftCms\Cms\Element\Conditions\Contracts\ElementConditionRuleInterface;
+use CraftCms\Cms\Element\Contracts\ElementInterface;
use CraftCms\Cms\Element\Queries\Contracts\ElementQueryInterface;
use CraftCms\Cms\User\Elements\User;
diff --git a/src/User/Conditions/AffiliatedSiteConditionRule.php b/src/User/Conditions/AffiliatedSiteConditionRule.php
index 91a4e8ec443..a600d0938eb 100644
--- a/src/User/Conditions/AffiliatedSiteConditionRule.php
+++ b/src/User/Conditions/AffiliatedSiteConditionRule.php
@@ -4,10 +4,10 @@
namespace CraftCms\Cms\User\Conditions;
-use craft\base\ElementInterface;
use craft\elements\db\UserQuery;
use CraftCms\Cms\Condition\BaseMultiSelectConditionRule;
use CraftCms\Cms\Element\Conditions\Contracts\ElementConditionRuleInterface;
+use CraftCms\Cms\Element\Contracts\ElementInterface;
use CraftCms\Cms\Element\Queries\Contracts\ElementQueryInterface;
use CraftCms\Cms\Site\Data\Site;
use CraftCms\Cms\Support\Facades\Sites;
diff --git a/src/User/Conditions/CredentialedConditionRule.php b/src/User/Conditions/CredentialedConditionRule.php
index 88a75254484..53d9b4b1a2c 100644
--- a/src/User/Conditions/CredentialedConditionRule.php
+++ b/src/User/Conditions/CredentialedConditionRule.php
@@ -4,10 +4,10 @@
namespace CraftCms\Cms\User\Conditions;
-use craft\base\ElementInterface;
use craft\elements\db\UserQuery;
use CraftCms\Cms\Condition\BaseLightswitchConditionRule;
use CraftCms\Cms\Element\Conditions\Contracts\ElementConditionRuleInterface;
+use CraftCms\Cms\Element\Contracts\ElementInterface;
use CraftCms\Cms\Element\Queries\Contracts\ElementQueryInterface;
use CraftCms\Cms\User\Elements\User;
diff --git a/src/User/Conditions/EmailConditionRule.php b/src/User/Conditions/EmailConditionRule.php
index eb4e3ddc958..bb1f057f432 100644
--- a/src/User/Conditions/EmailConditionRule.php
+++ b/src/User/Conditions/EmailConditionRule.php
@@ -4,10 +4,10 @@
namespace CraftCms\Cms\User\Conditions;
-use craft\base\ElementInterface;
use craft\elements\db\UserQuery;
use CraftCms\Cms\Condition\BaseTextConditionRule;
use CraftCms\Cms\Element\Conditions\Contracts\ElementConditionRuleInterface;
+use CraftCms\Cms\Element\Contracts\ElementInterface;
use CraftCms\Cms\Element\Queries\Contracts\ElementQueryInterface;
use CraftCms\Cms\User\Elements\User;
diff --git a/src/User/Conditions/FirstNameConditionRule.php b/src/User/Conditions/FirstNameConditionRule.php
index 0dae8b0b03b..23ec1664892 100644
--- a/src/User/Conditions/FirstNameConditionRule.php
+++ b/src/User/Conditions/FirstNameConditionRule.php
@@ -4,10 +4,10 @@
namespace CraftCms\Cms\User\Conditions;
-use craft\base\ElementInterface;
use craft\elements\db\UserQuery;
use CraftCms\Cms\Condition\BaseTextConditionRule;
use CraftCms\Cms\Element\Conditions\Contracts\ElementConditionRuleInterface;
+use CraftCms\Cms\Element\Contracts\ElementInterface;
use CraftCms\Cms\Element\Queries\Contracts\ElementQueryInterface;
use CraftCms\Cms\User\Elements\User;
diff --git a/src/User/Conditions/GroupConditionRule.php b/src/User/Conditions/GroupConditionRule.php
index 9fe66804a76..ec6bb6dd811 100644
--- a/src/User/Conditions/GroupConditionRule.php
+++ b/src/User/Conditions/GroupConditionRule.php
@@ -4,15 +4,15 @@
namespace CraftCms\Cms\User\Conditions;
-use craft\base\ElementInterface;
use craft\elements\db\UserQuery;
use CraftCms\Cms\Condition\BaseMultiSelectConditionRule;
use CraftCms\Cms\Element\Conditions\Contracts\ElementConditionRuleInterface;
+use CraftCms\Cms\Element\Contracts\ElementInterface;
use CraftCms\Cms\Element\Queries\Contracts\ElementQueryInterface;
use CraftCms\Cms\Support\Arr;
use CraftCms\Cms\Support\Facades\UserGroups;
use CraftCms\Cms\User\Elements\User;
-use yii\base\InvalidConfigException;
+use RuntimeException;
use function CraftCms\Cms\t;
@@ -46,7 +46,7 @@ public function modifyQuery(ElementQueryInterface $query): void
}
/**
- * @throws InvalidConfigException
+ * @throws RuntimeException
*/
public function matchElement(ElementInterface $element): bool
{
diff --git a/src/User/Conditions/LastLoginDateConditionRule.php b/src/User/Conditions/LastLoginDateConditionRule.php
index e0a8df63b6f..5cbb9896b6a 100644
--- a/src/User/Conditions/LastLoginDateConditionRule.php
+++ b/src/User/Conditions/LastLoginDateConditionRule.php
@@ -4,10 +4,10 @@
namespace CraftCms\Cms\User\Conditions;
-use craft\base\ElementInterface;
use craft\elements\db\UserQuery;
use CraftCms\Cms\Condition\BaseDateRangeConditionRule;
use CraftCms\Cms\Element\Conditions\Contracts\ElementConditionRuleInterface;
+use CraftCms\Cms\Element\Contracts\ElementInterface;
use CraftCms\Cms\Element\Queries\Contracts\ElementQueryInterface;
use CraftCms\Cms\User\Elements\User;
diff --git a/src/User/Conditions/LastNameConditionRule.php b/src/User/Conditions/LastNameConditionRule.php
index c8eca4e5e3d..4dd3fa12e45 100644
--- a/src/User/Conditions/LastNameConditionRule.php
+++ b/src/User/Conditions/LastNameConditionRule.php
@@ -4,10 +4,10 @@
namespace CraftCms\Cms\User\Conditions;
-use craft\base\ElementInterface;
use craft\elements\db\UserQuery;
use CraftCms\Cms\Condition\BaseTextConditionRule;
use CraftCms\Cms\Element\Conditions\Contracts\ElementConditionRuleInterface;
+use CraftCms\Cms\Element\Contracts\ElementInterface;
use CraftCms\Cms\Element\Queries\Contracts\ElementQueryInterface;
use CraftCms\Cms\User\Elements\User;
diff --git a/src/User/Conditions/UsernameConditionRule.php b/src/User/Conditions/UsernameConditionRule.php
index 1a559ad34d5..1f5304629bc 100644
--- a/src/User/Conditions/UsernameConditionRule.php
+++ b/src/User/Conditions/UsernameConditionRule.php
@@ -4,10 +4,10 @@
namespace CraftCms\Cms\User\Conditions;
-use craft\base\ElementInterface;
use craft\elements\db\UserQuery;
use CraftCms\Cms\Condition\BaseTextConditionRule;
use CraftCms\Cms\Element\Conditions\Contracts\ElementConditionRuleInterface;
+use CraftCms\Cms\Element\Contracts\ElementInterface;
use CraftCms\Cms\Element\Queries\Contracts\ElementQueryInterface;
use CraftCms\Cms\User\Elements\User;
diff --git a/src/User/Elements/User.php b/src/User/Elements/User.php
index e7f3d1ce37a..40e41b188f1 100644
--- a/src/User/Elements/User.php
+++ b/src/User/Elements/User.php
@@ -4,8 +4,6 @@
namespace CraftCms\Cms\User\Elements;
-use craft\base\ElementInterface;
-use craft\elements\conditions\users\UserCondition;
use CraftCms\Cms\Address\Elements\Address;
use CraftCms\Cms\Asset\Elements\Asset;
use CraftCms\Cms\Auth\Concerns\ConfirmsPasswords;
@@ -16,7 +14,7 @@
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\Contracts\ElementInterface;
use CraftCms\Cms\Element\Data\EagerLoadPlan;
use CraftCms\Cms\Element\Element;
use CraftCms\Cms\Element\ElementCollection;
@@ -29,7 +27,6 @@
use CraftCms\Cms\Entry\Elements\Entry;
use CraftCms\Cms\Field\Fields;
use CraftCms\Cms\FieldLayout\FieldLayout;
-use CraftCms\Cms\ProjectConfig\ProjectConfig;
use CraftCms\Cms\Shared\Concerns\HasNames;
use CraftCms\Cms\Shared\Enums\Color;
use CraftCms\Cms\Site\Data\Site;
@@ -41,6 +38,7 @@
use CraftCms\Cms\Support\Facades\HtmlStack;
use CraftCms\Cms\Support\Facades\I18N;
use CraftCms\Cms\Support\Facades\InputNamespace;
+use CraftCms\Cms\Support\Facades\ProjectConfig;
use CraftCms\Cms\Support\Facades\Sites;
use CraftCms\Cms\Support\Facades\UserGroups;
use CraftCms\Cms\Support\Facades\Users;
@@ -53,6 +51,7 @@
use CraftCms\Cms\User\Actions\DeleteUsers;
use CraftCms\Cms\User\Actions\SuspendUsers;
use CraftCms\Cms\User\Actions\UnsuspendUsers;
+use CraftCms\Cms\User\Conditions\UserCondition;
use CraftCms\Cms\User\Data\UserGroup;
use CraftCms\Cms\User\Events\DefineFriendlyName;
use CraftCms\Cms\User\Events\DefineName;
@@ -64,7 +63,7 @@
use DateInterval;
use DateTime;
use DateTimeZone;
-use Deprecated;
+use Exception;
use Illuminate\Auth\Authenticatable;
use Illuminate\Auth\Passwords\CanResetPassword;
use Illuminate\Auth\Passwords\PasswordBroker;
@@ -74,7 +73,6 @@
use Illuminate\Contracts\Auth\MustVerifyEmail as MustVerifyEmailContract;
use Illuminate\Foundation\Auth\Access\Authorizable;
use Illuminate\Notifications\Notifiable;
-use Illuminate\Support\Collection;
use Illuminate\Support\Facades\Auth;
use Illuminate\Support\Facades\Crypt;
use Illuminate\Support\Facades\DB as DbFacade;
@@ -85,9 +83,6 @@
use Override;
use Stringable;
use Throwable;
-use yii\base\Exception;
-use yii\base\InvalidConfigException;
-use yii\web\BadRequestHttpException;
use function CraftCms\Cms\t;
@@ -118,9 +113,6 @@ class User extends Element implements AuthenticatableContract, AuthorizableContr
use Macroable;
use Notifiable;
- /**
- * @since 5.0.0
- */
public const string GQL_TYPE_NAME = 'User';
private static array $photoColors = [
@@ -146,9 +138,6 @@ class User extends Element implements AuthenticatableContract, AuthorizableContr
// User statuses
// -------------------------------------------------------------------------
- /**
- * @since 4.0.0
- */
public const string STATUS_INACTIVE = 'inactive';
public const string STATUS_ACTIVE = 'active';
@@ -159,6 +148,203 @@ class User extends Element implements AuthenticatableContract, AuthorizableContr
public const string STATUS_LOCKED = 'locked';
+ /**
+ * @var int|null Photo asset ID
+ */
+ #[AllowedInSandbox]
+ public ?int $photoId = null;
+
+ /**
+ * @var bool Active
+ */
+ #[AllowedInSandbox]
+ public bool $active = false;
+
+ /**
+ * @var bool Pending
+ */
+ #[AllowedInSandbox]
+ public bool $pending = false;
+
+ /**
+ * @var bool Locked
+ */
+ #[AllowedInSandbox]
+ public bool $locked = false;
+
+ /**
+ * @var bool Suspended
+ */
+ #[AllowedInSandbox]
+ public bool $suspended = false;
+
+ /**
+ * @var bool Admin
+ */
+ #[AllowedInSandbox]
+ public bool $admin = false;
+
+ /**
+ * @var string|null Username
+ */
+ #[AllowedInSandbox]
+ public ?string $username = null;
+
+ /**
+ * @var string|null Email
+ */
+ #[AllowedInSandbox]
+ public ?string $email = null;
+
+ /**
+ * @var string|null Password
+ */
+ public ?string $password = null;
+
+ /**
+ * @var int|null Affiliated site ID
+ */
+ #[AllowedInSandbox]
+ public ?int $affiliatedSiteId = null;
+
+ /**
+ * @var DateTime|null Last login date
+ */
+ #[AllowedInSandbox]
+ public ?DateTime $lastLoginDate = null;
+
+ /**
+ * @var int|null Invalid login count
+ */
+ public ?int $invalidLoginCount = null;
+
+ /**
+ * @var DateTime|null Last invalid login date
+ */
+ public ?DateTime $lastInvalidLoginDate = null;
+
+ /**
+ * @var DateTime|null Lockout date
+ */
+ public ?DateTime $lockoutDate = null;
+
+ /**
+ * @var bool Whether the user has a dashboard
+ */
+ public bool $hasDashboard = false;
+
+ /**
+ * @var bool Password reset required
+ */
+ public bool $passwordResetRequired = false;
+
+ /**
+ * @var DateTime|null Last password change date
+ */
+ public ?DateTime $lastPasswordChangeDate = null;
+
+ /**
+ * @var string|null Unverified email
+ */
+ public ?string $unverifiedEmail = null;
+
+ /**
+ * @var string|null New password
+ */
+ public ?string $newPassword = null;
+
+ /**
+ * @var string|null Current password
+ */
+ public ?string $currentPassword = null;
+
+ /**
+ * @var string|null Last login attempt IP address.
+ */
+ public ?string $lastLoginAttemptIp = null;
+
+ /**
+ * @var string|null Session remember token
+ */
+ public ?string $remember_token = null;
+
+ /**
+ * @var self|null The user who should take over the user’s content if the user is deleted.
+ */
+ public ?User $inheritorOnDelete = null;
+
+ /**
+ * @var ElementCollection Addresses
+ *
+ * @see getAddresses()
+ */
+ private ElementCollection $_addresses;
+
+ /**
+ * @see getAddressManager()
+ */
+ private NestedElementManager $_addressManager;
+
+ /**
+ * @see getName()
+ * @see setName()
+ */
+ private ?string $_name = null;
+
+ /**
+ * @see getFriendlyName()
+ * @see setFriendlyName()
+ */
+ private string|bool|null $_friendlyName = null;
+
+ /**
+ * @var Asset|false|null user photo
+ */
+ private Asset|null|false $_photo = null;
+
+ /**
+ * @var UserGroup[]|null The cached list of groups the user belongs to. Set by [[getGroups()]].
+ */
+ private ?array $_groups = null;
+
+ /**
+ * @see setAttributesFromRequest()
+ * @see afterSave()
+ */
+ private bool $sendVerificationEmailAfterRequest = false;
+
+ public function __construct($config = [])
+ {
+ parent::__construct($config);
+
+ // Is this user in cooldown mode, and are they past their window?
+ if (
+ $this->locked &&
+ Cms::config()->cooldownDuration &&
+ ! $this->getRemainingCooldownTime()
+ ) {
+ Users::unlockUser($this);
+ }
+
+ // Convert IDNA ASCII to Unicode
+ if ($this->username) {
+ $this->username = Str::idnToUtf8Email($this->username);
+ }
+ if ($this->email) {
+ $this->email = Str::idnToUtf8Email($this->email);
+ }
+
+ if (empty($this->username) && Cms::config()->useEmailAsUsername) {
+ $this->username = $this->email;
+ }
+
+ if ($this->password === '') {
+ $this->password = null;
+ }
+
+ $this->normalizeNames();
+ }
+
public function getAuthIdentifierName(): string
{
return 'id';
@@ -247,11 +433,8 @@ public static function find(): UserQuery
return new UserQuery;
}
- /**
- * @return UserCondition
- */
#[Override]
- public static function createCondition(): ElementConditionInterface
+ public static function createCondition(): UserCondition
{
return new UserCondition(self::class);
}
@@ -333,25 +516,11 @@ protected static function defineSources(string $context): array
#[Override]
protected static function defineActions(string $source): array
{
- $actions = [];
-
- if (Gate::check('moderateUsers')) {
- // Suspend
- $actions[] = SuspendUsers::class;
-
- // Unsuspend
- $actions[] = UnsuspendUsers::class;
- }
-
- if (Gate::check('deleteUsers')) {
- // Delete
- $actions[] = DeleteUsers::class;
- }
-
- // Restore
- $actions[] = Restore::class;
-
- return $actions;
+ return collect()
+ ->when(Gate::check('moderateUsers'), fn ($actions) => $actions->push(SuspendUsers::class, UnsuspendUsers::class))
+ ->when(Gate::check('deleteUsers'), fn ($actions) => $actions->push(DeleteUsers::class))
+ ->push(Restore::class)
+ ->all();
}
#[Override]
@@ -561,177 +730,6 @@ public static function eagerLoadingMap(array $sourceElements, string $handle): a
return parent::eagerLoadingMap($sourceElements, $handle);
}
- /**
- * @var int|null Photo asset ID
- */
- #[AllowedInSandbox]
- public ?int $photoId = null;
-
- /**
- * @var bool Active
- *
- * @since 4.0.0
- */
- #[AllowedInSandbox]
- public bool $active = false;
-
- /**
- * @var bool Pending
- */
- #[AllowedInSandbox]
- public bool $pending = false;
-
- /**
- * @var bool Locked
- */
- #[AllowedInSandbox]
- public bool $locked = false;
-
- /**
- * @var bool Suspended
- */
- #[AllowedInSandbox]
- public bool $suspended = false;
-
- /**
- * @var bool Admin
- */
- #[AllowedInSandbox]
- public bool $admin = false;
-
- /**
- * @var string|null Username
- */
- #[AllowedInSandbox]
- public ?string $username = null;
-
- /**
- * @var string|null Email
- */
- #[AllowedInSandbox]
- public ?string $email = null;
-
- /**
- * @var string|null Password
- */
- public ?string $password = null;
-
- /**
- * @var int|null Affiliated site ID
- *
- * @since 5.6.0
- */
- #[AllowedInSandbox]
- public ?int $affiliatedSiteId = null;
-
- /**
- * @var DateTime|null Last login date
- */
- #[AllowedInSandbox]
- public ?DateTime $lastLoginDate = null;
-
- /**
- * @var int|null Invalid login count
- */
- public ?int $invalidLoginCount = null;
-
- /**
- * @var DateTime|null Last invalid login date
- */
- public ?DateTime $lastInvalidLoginDate = null;
-
- /**
- * @var DateTime|null Lockout date
- */
- public ?DateTime $lockoutDate = null;
-
- /**
- * @var bool Whether the user has a dashboard
- *
- * @since 3.0.4
- */
- public bool $hasDashboard = false;
-
- /**
- * @var bool Password reset required
- */
- public bool $passwordResetRequired = false;
-
- /**
- * @var DateTime|null Last password change date
- */
- public ?DateTime $lastPasswordChangeDate = null;
-
- /**
- * @var string|null Unverified email
- */
- public ?string $unverifiedEmail = null;
-
- /**
- * @var string|null New password
- */
- public ?string $newPassword = null;
-
- /**
- * @var string|null Current password
- */
- public ?string $currentPassword = null;
-
- /**
- * @var string|null Last login attempt IP address.
- */
- public ?string $lastLoginAttemptIp = null;
-
- /**
- * @var string|null Session remember token
- */
- public ?string $remember_token = null;
-
- /**
- * @var self|null The user who should take over the user’s content if the user is deleted.
- */
- public ?User $inheritorOnDelete = null;
-
- /**
- * @var ElementCollection Addresses
- *
- * @see getAddresses()
- */
- private ElementCollection $_addresses;
-
- /**
- * @see getAddressManager()
- */
- private NestedElementManager $_addressManager;
-
- /**
- * @see getName()
- * @see setName()
- */
- private ?string $_name = null;
-
- /**
- * @see getFriendlyName()
- * @see setFriendlyName()
- */
- private string|bool|null $_friendlyName = null;
-
- /**
- * @var Asset|false|null user photo
- */
- private Asset|null|false $_photo = null;
-
- /**
- * @var UserGroup[]|null The cached list of groups the user belongs to. Set by [[getGroups()]].
- */
- private ?array $_groups = null;
-
- /**
- * @see setAttributesFromRequest()
- * @see afterSave()
- */
- private bool $sendVerificationEmailAfterRequest = false;
-
public function sendPasswordResetNotification($token): void
{
$this->notify(new ResetPasswordNotification($token));
@@ -777,39 +775,6 @@ public function getEmailForVerification(): string
return $this->unverifiedEmail ?? $this->email;
}
- #[Override]
- public function init(): void
- {
- parent::init();
-
- // Is this user in cooldown mode, and are they past their window?
- if (
- $this->locked &&
- Cms::config()->cooldownDuration &&
- ! $this->getRemainingCooldownTime()
- ) {
- Users::unlockUser($this);
- }
-
- // Convert IDNA ASCII to Unicode
- if ($this->username) {
- $this->username = Str::idnToUtf8Email($this->username);
- }
- if ($this->email) {
- $this->email = Str::idnToUtf8Email($this->email);
- }
-
- if (empty($this->username) && Cms::config()->useEmailAsUsername) {
- $this->username = $this->email;
- }
-
- if ($this->password === '') {
- $this->password = null;
- }
-
- $this->normalizeNames();
- }
-
/**
* Use the full name or username as the string representation.
*/
@@ -817,6 +782,7 @@ public function init(): void
public function __toString(): string
{
$name = $this->getName();
+
if ($name !== '') {
return $name;
}
@@ -921,7 +887,7 @@ public function setAttributesFromRequest($values): void
if (isset($values['email'])) {
// make sure they have an elevated session
if (! $this->isPasswordConfirmed()) {
- throw new BadRequestHttpException(t('An elevated session is required to change a user’s email.'));
+ abort(400, t('An elevated session is required to change a user’s email.'));
}
if ($this->email !== null) {
@@ -929,7 +895,7 @@ public function setAttributesFromRequest($values): void
if ($this->getIsCurrent() || Gate::check('administrateUsers')) {
if (
Edition::get()->value >= Edition::Pro->value &&
- app(ProjectConfig::class)->get('users.requireEmailVerification') &&
+ ProjectConfig::get('users.requireEmailVerification') &&
! Gate::check('administrateUsers')
) {
// set it as the unverified email instead, and
@@ -946,7 +912,7 @@ public function setAttributesFromRequest($values): void
}
#[Override]
- public function setAttributes($values, $safeOnly = true): void
+ public function setAttributes($values): void
{
if (array_key_exists('firstName', $values) || array_key_exists('lastName', $values)) {
// Unset fullName so NameTrait::prepareNamesForSave() can set it
@@ -956,13 +922,11 @@ public function setAttributes($values, $safeOnly = true): void
$this->firstName = $this->lastName = null;
}
- parent::setAttributes($values, $safeOnly);
+ parent::setAttributes($values);
}
/**
* Returns whether the user account can be logged into.
- *
- * @since 4.0.0
*/
public function getIsCredentialed(): bool
{
@@ -971,8 +935,6 @@ public function getIsCredentialed(): bool
/**
* Returns whether the user has a password.
- *
- * @since 5.6.0
*/
#[AllowedInSandbox]
public function getHasPassword(): bool
@@ -988,8 +950,6 @@ public function getHasPassword(): bool
/**
* Returns whether the user has an associated SSO identity.
- *
- * @since 5.7.8
*/
#[AllowedInSandbox]
public function getHasSsoIdentity(): bool
@@ -1004,7 +964,6 @@ public function getHasSsoIdentity(): bool
#[Override]
public function getFieldLayout(): ?FieldLayout
{
- // @TODO: Field layout for non-legacy
return app(Fields::class)->getLayoutByType(User::class);
}
@@ -1012,51 +971,43 @@ public function getFieldLayout(): ?FieldLayout
* Gets the user’s addresses.
*
* @return ElementCollection
- *
- * @since 4.0.0
*/
#[AllowedInSandbox]
public function getAddresses(): ElementCollection
{
- if (! isset($this->_addresses)) {
- if (! $this->id) {
- /** @var ElementCollection */
- return ElementCollection::make();
- }
+ if (isset($this->_addresses)) {
+ return $this->_addresses;
+ }
- $this->_addresses = $this->createAddressQuery()
- ->whereNull('fieldId')
- ->get();
+ if (! $this->id) {
+ return new ElementCollection;
}
- return $this->_addresses;
+ return $this->_addresses = $this->createAddressQuery()
+ ->whereNull('fieldId')
+ ->get();
}
/**
* Returns a nested element manager for the user’s addresses.
- *
- * @since 5.0.0
*/
public function getAddressManager(): NestedElementManager
{
- if (! isset($this->_addressManager)) {
- $this->_addressManager = new NestedElementManager(
- Address::class,
- fn () => $this->createAddressQuery(),
- [
- 'attribute' => 'addresses',
- 'propagationMethod' => PropagationMethod::None,
- ],
- );
- }
-
- return $this->_addressManager;
+ return $this->_addressManager ??= new NestedElementManager(
+ Address::class,
+ fn () => $this->createAddressQuery(),
+ [
+ 'attribute' => 'addresses',
+ 'propagationMethod' => PropagationMethod::None,
+ ],
+ );
}
#[Override]
public function afterRestore(): void
{
$this->getAddressManager()->restoreNestedElements($this);
+
parent::afterRestore();
}
@@ -1064,7 +1015,7 @@ private function createAddressQuery(): AddressQuery
{
return Address::find()
->owner($this)
- ->orderBy(['id' => SORT_ASC]);
+ ->orderBy('id');
}
/**
@@ -1097,11 +1048,11 @@ public function getGroups(): array
/**
* Sets an array of user groups on the user.
*
- * @param UserGroup[]|UserGroup[] $groups An array of UserGroup objects.
+ * @param UserGroup[] $groups An array of UserGroup objects.
*/
public function setGroups(array $groups): void
{
- if (Edition::get()->value >= Edition::Pro->value) {
+ if (Edition::isAtLeast(Edition::Pro)) {
$this->_groups = $groups;
}
}
@@ -1123,11 +1074,11 @@ public function isInGroup(UserGroup|int|string $group): bool
}
if (is_numeric($group)) {
- return Collection::make($this->getGroups())->contains('id', $group);
+ return collect($this->getGroups())->contains('id', $group);
}
/** @phpstan-ignore argument.type */
- return Collection::make($this->getGroups())->containsStrict('handle', $group);
+ return collect($this->getGroups())->containsStrict('handle', $group);
}
/**
@@ -1138,8 +1089,6 @@ public function isInGroup(UserGroup|int|string $group): bool
*
* @param array $groups The user groups, handles, or IDs
* @param bool $all Whether to only return `true` if the user is in *all* of the provided groups
- *
- * @since 5.9.0
*/
#[AllowedInSandbox]
public function isInGroups(array $groups, bool $all = false): bool
@@ -1151,27 +1100,13 @@ public function isInGroups(array $groups, bool $all = false): bool
return array_all($groups, fn ($group) => $this->isInGroup($group));
}
- /**
- * Returns the user’s full name.
- */
- #[Deprecated(message: 'in 4.0.0. [[fullName]] should be used instead.')]
- #[AllowedInSandbox]
- public function getFullName(): ?string
- {
- return $this->fullName;
- }
-
/**
* Returns the user’s full name or username.
*/
#[AllowedInSandbox]
public function getName(): string
{
- if (! isset($this->_name)) {
- $this->_name = $this->_defineName();
- }
-
- return $this->_name;
+ return $this->_name ??= $this->_defineName();
}
private function _defineName(): string
@@ -1183,8 +1118,6 @@ private function _defineName(): string
/**
* Sets the user’s name.
- *
- * @since 3.7.0
*/
public function setName(string $name): void
{
@@ -1213,8 +1146,6 @@ private function _defineFriendlyName(): ?string
/**
* Sets the user’s friendly name.
- *
- * @since 3.7.0
*/
public function setFriendlyName(string $friendlyName): void
{
@@ -1223,8 +1154,6 @@ public function setFriendlyName(string $friendlyName): void
/**
* Returns the user’s affiliated site, if they have one.
- *
- * @since 5.6.0
*/
#[AllowedInSandbox]
public function getAffiliatedSite(): ?Site
@@ -1237,38 +1166,24 @@ public function getAffiliatedSite(): ?Site
}
#[Override]
- public function getStatus(): ?string
+ public function getStatus(): string
{
// If they're disabled or archived, go with that
$status = parent::getStatus();
- if ($status !== self::STATUS_ENABLED) {
- return $status;
- }
-
- if ($this->suspended) {
- return self::STATUS_SUSPENDED;
- }
-
- if ($this->archived) {
- return self::STATUS_ARCHIVED;
- }
-
- if ($this->pending) {
- return self::STATUS_PENDING;
- }
- if ($this->active) {
- return self::STATUS_ACTIVE;
- }
-
- return self::STATUS_INACTIVE;
+ return match (true) {
+ $status !== self::STATUS_ENABLED => $status,
+ $this->suspended => self::STATUS_SUSPENDED,
+ $this->archived => self::STATUS_ARCHIVED,
+ $this->pending => self::STATUS_PENDING,
+ $this->active => self::STATUS_ACTIVE,
+ default => self::STATUS_INACTIVE,
+ };
}
protected function thumbUrl(int $size): ?string
{
- $photo = $this->getPhoto();
-
- if ($photo) {
+ if ($photo = $this->getPhoto()) {
return AssetsService::getThumbUrl($photo, $size, iconFallback: false);
}
@@ -1348,24 +1263,18 @@ public function isAdmin(): bool
/**
* Returns whether the user can register additional users.
- *
- * @since 5.0.0
*/
final public function canRegisterUsers(): bool
{
- return
- $this->can('registerUsers') &&
- Users::canCreateUsers();
+ return $this->can('registerUsers') && Users::canCreateUsers();
}
/**
* Returns whether the user is authorized to assign any user groups to users.
- *
- * @since 4.0.0
*/
public function canAssignUserGroups(): bool
{
- if (Edition::get()->value < Edition::Pro->value) {
+ if (! Edition::isAtLeast(Edition::Pro)) {
return false;
}
@@ -1799,8 +1708,6 @@ public function getPreferredLanguage(): ?string
* If the user doesn’t have a preferred locale, their preferred language will be used instead.
*
* @return string|null The preferred locale
- *
- * @since 3.5.0
*/
public function getPreferredLocale(): ?string
{
@@ -1809,8 +1716,6 @@ public function getPreferredLocale(): ?string
/**
* Returns whether the user prefers to have form fields autofocused on page load.
- *
- * @since 5.0.0
*/
public function getAutofocusPreferred(): bool
{
@@ -2002,9 +1907,6 @@ protected function metadata(): array
];
}
- /**
- * @since 3.3.0
- */
#[Override]
public function getGqlTypeName(): string
{
@@ -2031,10 +1933,6 @@ final public function beforeSave(bool $isNew): bool
return parent::beforeSave($isNew);
}
- /**
- * @throws InvalidConfigException
- * @throws Exception
- */
#[Override]
public function afterSave(bool $isNew): void
{
diff --git a/src/User/Models/User.php b/src/User/Models/User.php
index 69ff2cfce4c..4389aab5e85 100644
--- a/src/User/Models/User.php
+++ b/src/User/Models/User.php
@@ -13,6 +13,7 @@
use CraftCms\Cms\Support\Arr;
use CraftCms\Cms\Support\Facades\UserGroups;
use Illuminate\Auth\MustVerifyEmail;
+use Illuminate\Database\Eloquent\Attributes\Hidden;
use Illuminate\Database\Eloquent\Factories\HasFactory;
use Illuminate\Database\Eloquent\Relations\BelongsTo;
use Illuminate\Database\Eloquent\Relations\BelongsToMany;
@@ -22,6 +23,10 @@
use Illuminate\Support\Facades\DB;
use Override;
+#[Hidden([
+ 'password',
+ 'rememberToken',
+])]
class User extends BaseModel
{
use HasFactory;
@@ -30,17 +35,6 @@ class User extends BaseModel
#[Override]
public $incrementing = false;
- /**
- * The attributes that should be hidden for serialization.
- *
- * @var list
- */
- #[Override]
- protected $hidden = [
- 'password',
- 'rememberToken',
- ];
-
private ?Collection $userGroupData = null;
#[Override]
diff --git a/src/User/Validation/UserRules.php b/src/User/Validation/UserRules.php
index b612301db1f..303dfe8f683 100644
--- a/src/User/Validation/UserRules.php
+++ b/src/User/Validation/UserRules.php
@@ -7,7 +7,7 @@
use CraftCms\Cms\Cms;
use CraftCms\Cms\Database\Table;
use CraftCms\Cms\Element\Validation\ElementRules;
-use CraftCms\Cms\FieldLayout\LayoutElements\users\FullNameField;
+use CraftCms\Cms\FieldLayout\LayoutElements\Users\FullNameField;
use CraftCms\Cms\Support\Arr;
use CraftCms\Cms\User\Elements\User;
use CraftCms\Cms\User\Validation\Rules\UsernameRule;
diff --git a/src/Validation/Concerns/Validates.php b/src/Validation/Concerns/Validates.php
index 99aa90d5f2e..41ee64898f7 100644
--- a/src/Validation/Concerns/Validates.php
+++ b/src/Validation/Concerns/Validates.php
@@ -5,7 +5,9 @@
namespace CraftCms\Cms\Validation\Concerns;
use CraftCms\Cms\Support\Arr;
+use CraftCms\Cms\Support\Str;
use CraftCms\Cms\Support\Utils;
+use CraftCms\Cms\Validation\Contracts\Validatable;
use CraftCms\RulesetValidation\Concerns\HasRuleset;
use Illuminate\Support\MessageBag;
use Illuminate\Validation\Validator;
@@ -26,6 +28,30 @@ public function errors(): MessageBag
return $this->errors ??= new MessageBag;
}
+ public function clearErrors(?string $attribute = null): void
+ {
+ if ($attribute) {
+ $this->errors()->forget($attribute);
+
+ return;
+ }
+
+ $this->errors = new MessageBag;
+ }
+
+ public function addModelErrors(Validatable $model, string $attrPrefix = ''): void
+ {
+ if ($attrPrefix !== '') {
+ $attrPrefix = rtrim($attrPrefix, '.').'.';
+ }
+
+ foreach ($model->errors()->getMessages() as $attribute => $errors) {
+ foreach ($errors as $error) {
+ $this->errors()->add($attrPrefix.$attribute, $error);
+ }
+ }
+ }
+
/**
* TODO: Add types to method signature once components no longer rely
* on craft/base/Model
@@ -36,7 +62,7 @@ public function errors(): MessageBag
public function validate($attributeNames = null, $clearErrors = true, bool $throw = false): bool
{
if ($clearErrors) {
- $this->errors = new MessageBag;
+ $this->clearErrors();
}
if (is_string($attributeNames)) {
@@ -75,6 +101,24 @@ public function attributeLabels(): array
return [];
}
+ public function getAttributeLabel(string $attribute): string
+ {
+ $labels = $this->attributeLabels();
+
+ return $labels[$attribute] ?? $this->generateAttributeLabel($attribute);
+ }
+
+ /**
+ * Generates a user friendly attribute label based on the give attribute name.
+ * This is done by replacing underscores, dashes and dots with blanks and
+ * changing the first letter of each word to upper case.
+ * For example, 'department_name' or 'DepartmentName' will generate 'Department Name'.
+ */
+ public function generateAttributeLabel(string $name): string
+ {
+ return Str::camel2words($name);
+ }
+
public function prepareForValidation(): void {}
public function passedValidation(): void {}
diff --git a/src/Validation/Contracts/Validatable.php b/src/Validation/Contracts/Validatable.php
index 926b9c23ca4..997cfb42cd6 100644
--- a/src/Validation/Contracts/Validatable.php
+++ b/src/Validation/Contracts/Validatable.php
@@ -34,6 +34,20 @@ public function getMessages(): array;
*/
public function attributeLabels(): array;
+ /**
+ * Returns the text label for the specified attribute.
+ */
+ public function getAttributeLabel(string $attribute): string;
+
+ /**
+ * Generates a user-friendly attribute label based on the give attribute name.
+ * This is done by replacing underscores, dashes and dots with blanks and
+ * changing the first letter of each word to uppercase.
+ *
+ * For example, 'department_name' or 'DepartmentName' will generate 'Department Name'.
+ */
+ public function generateAttributeLabel(string $name): string;
+
/**
* Sets attribute values.
*
@@ -77,4 +91,9 @@ public function getFirstErrors(): array;
* Returns the validation error messages.
*/
public function errors(): MessageBag;
+
+ /**
+ * Clears validation errors for the specified attribute or all attributes.
+ */
+ public function clearErrors(?string $attribute = null): void;
}
diff --git a/src/Validation/Rules/MoneyRule.php b/src/Validation/Rules/MoneyRule.php
index 73de4efeb32..ab224bd1e14 100644
--- a/src/Validation/Rules/MoneyRule.php
+++ b/src/Validation/Rules/MoneyRule.php
@@ -5,7 +5,7 @@
namespace CraftCms\Cms\Validation\Rules;
use Closure;
-use craft\base\ElementInterface;
+use CraftCms\Cms\Element\Contracts\ElementInterface;
use CraftCms\Cms\Support\Facades\I18N;
use Illuminate\Contracts\Validation\ValidationRule;
use Money\Currencies\ISOCurrencies;
diff --git a/src/View/CacheCollectors/DependencyCollector.php b/src/View/CacheCollectors/DependencyCollector.php
index d143f50924a..10856dec5e1 100644
--- a/src/View/CacheCollectors/DependencyCollector.php
+++ b/src/View/CacheCollectors/DependencyCollector.php
@@ -4,7 +4,7 @@
namespace CraftCms\Cms\View\CacheCollectors;
-use craft\base\ElementInterface;
+use CraftCms\Cms\Element\Contracts\ElementInterface;
use CraftCms\Cms\Element\Contracts\ExpirableElementInterface;
use CraftCms\Cms\Support\DateTimeHelper;
use CraftCms\Cms\View\Contracts\CacheCollectorInterface;
diff --git a/src/View/Hooks/PrepareElementIndexVariables.php b/src/View/Hooks/PrepareElementIndexVariables.php
index 55e3c0e6f1c..6a29b7058fd 100644
--- a/src/View/Hooks/PrepareElementIndexVariables.php
+++ b/src/View/Hooks/PrepareElementIndexVariables.php
@@ -4,7 +4,7 @@
namespace CraftCms\Cms\View\Hooks;
-use craft\base\ElementInterface;
+use CraftCms\Cms\Element\Contracts\ElementInterface;
use CraftCms\Cms\Element\ElementSources;
use CraftCms\Cms\Site\Sites;
diff --git a/src/View/Hooks/PrepareElementSourcesVariables.php b/src/View/Hooks/PrepareElementSourcesVariables.php
index 4889f994eff..febd6e602cc 100644
--- a/src/View/Hooks/PrepareElementSourcesVariables.php
+++ b/src/View/Hooks/PrepareElementSourcesVariables.php
@@ -4,7 +4,7 @@
namespace CraftCms\Cms\View\Hooks;
-use craft\base\ElementInterface;
+use CraftCms\Cms\Element\Contracts\ElementInterface;
use CraftCms\Cms\Element\ElementSources;
readonly class PrepareElementSourcesVariables
diff --git a/src/View/Hooks/PrepareElementToolbarVariables.php b/src/View/Hooks/PrepareElementToolbarVariables.php
index 711c5064273..c5cfdfc27c5 100644
--- a/src/View/Hooks/PrepareElementToolbarVariables.php
+++ b/src/View/Hooks/PrepareElementToolbarVariables.php
@@ -4,7 +4,7 @@
namespace CraftCms\Cms\View\Hooks;
-use craft\base\ElementInterface;
+use CraftCms\Cms\Element\Contracts\ElementInterface;
use CraftCms\Cms\Site\Sites;
readonly class PrepareElementToolbarVariables
diff --git a/src/View/TemplateGlobals.php b/src/View/TemplateGlobals.php
index 08fbfe705f9..eacb2710301 100644
--- a/src/View/TemplateGlobals.php
+++ b/src/View/TemplateGlobals.php
@@ -64,6 +64,7 @@ public function resolve(): array
'craft' => new CraftVariable,
'sessionErrors' => $errors,
'request' => $this->request,
+ 'session' => $this->request->hasSession() ? $this->request->session() : null,
'pluginAssets' => $this->plugins->getAssetsHtml(),
'currentSite' => $currentSite,
'currentUser' => $currentUser,
diff --git a/tests/ArchTest.php b/tests/Arch/ArchTest.php
similarity index 100%
rename from tests/ArchTest.php
rename to tests/Arch/ArchTest.php
diff --git a/tests/Feature/Asset/AssetsTest.php b/tests/Feature/Asset/AssetsTest.php
index 5ec8a75221b..e795936d188 100644
--- a/tests/Feature/Asset/AssetsTest.php
+++ b/tests/Feature/Asset/AssetsTest.php
@@ -5,6 +5,7 @@
use craft\assetpreviews\Text;
use CraftCms\Cms\Asset\Assets;
use CraftCms\Cms\Asset\Elements\Asset;
+use CraftCms\Cms\Asset\Enums\FileKind;
use CraftCms\Cms\Asset\Events\BeforeReplaceAsset;
use CraftCms\Cms\Asset\Events\DefineThumbUrl;
use CraftCms\Cms\Asset\Events\RegisterPreviewHandler;
@@ -124,7 +125,7 @@
'volumeId' => $volume->id,
'folderId' => $folder->id,
'filename' => 'test.txt',
- 'kind' => Asset::KIND_TEXT,
+ 'kind' => FileKind::Text->value,
]);
$handler = $this->assets->getAssetPreviewHandler($textAsset);
diff --git a/tests/Feature/Asset/Elements/AssetValidationTest.php b/tests/Feature/Asset/Elements/AssetValidationTest.php
index 56a15382b6e..2ac015ba5f2 100644
--- a/tests/Feature/Asset/Elements/AssetValidationTest.php
+++ b/tests/Feature/Asset/Elements/AssetValidationTest.php
@@ -2,7 +2,7 @@
declare(strict_types=1);
-use CraftCms\Cms\Asset\Elements\Asset;
+use CraftCms\Cms\Asset\Enums\FileKind;
use CraftCms\Cms\Asset\Models\Asset as AssetModel;
use CraftCms\Cms\Asset\Validation\AssetRules;
@@ -29,7 +29,7 @@
})->with([
'null is invalid' => [null, true],
'empty string is invalid' => ['', true],
- 'KIND_IMAGE is valid' => [Asset::KIND_IMAGE, false],
+ 'KIND_IMAGE is valid' => [FileKind::Image->value, false],
]);
});
@@ -110,7 +110,7 @@
$asset = AssetModel::factory()->createElement();
$asset->ruleset->useScenario(AssetRules::SCENARIO_INDEX);
- $activeAttributes = $asset->activeAttributes();
+ $activeAttributes = array_keys($asset->getRules());
expect($activeAttributes)->toBe([]);
});
@@ -157,7 +157,7 @@
expect($asset->errors()->has('kind'))->toBeTrue();
});
- test('all valid asset kinds are accepted', function (string $kind) {
+ test('valid asset kinds are accepted', function (string $kind) {
$asset = AssetModel::factory()->createElement();
$asset->kind = $kind;
@@ -165,25 +165,6 @@
expect($asset->errors()->has('kind'))->toBeFalse();
})->with([
- 'KIND_ACCESS' => [Asset::KIND_ACCESS],
- 'KIND_AUDIO' => [Asset::KIND_AUDIO],
- 'KIND_CAPTIONS_SUBTITLES' => [Asset::KIND_CAPTIONS_SUBTITLES],
- 'KIND_COMPRESSED' => [Asset::KIND_COMPRESSED],
- 'KIND_EXCEL' => [Asset::KIND_EXCEL],
- 'KIND_FLASH' => [Asset::KIND_FLASH],
- 'KIND_HTML' => [Asset::KIND_HTML],
- 'KIND_ILLUSTRATOR' => [Asset::KIND_ILLUSTRATOR],
- 'KIND_IMAGE' => [Asset::KIND_IMAGE],
- 'KIND_JAVASCRIPT' => [Asset::KIND_JAVASCRIPT],
- 'KIND_JSON' => [Asset::KIND_JSON],
- 'KIND_PDF' => [Asset::KIND_PDF],
- 'KIND_PHOTOSHOP' => [Asset::KIND_PHOTOSHOP],
- 'KIND_PHP' => [Asset::KIND_PHP],
- 'KIND_POWERPOINT' => [Asset::KIND_POWERPOINT],
- 'KIND_TEXT' => [Asset::KIND_TEXT],
- 'KIND_VIDEO' => [Asset::KIND_VIDEO],
- 'KIND_WORD' => [Asset::KIND_WORD],
- 'KIND_XML' => [Asset::KIND_XML],
- 'KIND_UNKNOWN' => [Asset::KIND_UNKNOWN],
+ 'KIND_IMAGE' => [FileKind::Image->value],
]);
});
diff --git a/tests/Feature/Asset/VolumeFilesystemResolutionTest.php b/tests/Feature/Asset/VolumeFilesystemResolutionTest.php
index f830c955224..f32e74afcb3 100644
--- a/tests/Feature/Asset/VolumeFilesystemResolutionTest.php
+++ b/tests/Feature/Asset/VolumeFilesystemResolutionTest.php
@@ -13,7 +13,6 @@
use CraftCms\Cms\Support\Facades\Filesystems;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Facades\Storage;
-use yii\base\InvalidConfigException;
it('resolves explicit disk targets to Laravel disk wrappers', function () {
config()->set('filesystems.disks.explicit-disk', [
@@ -363,7 +362,7 @@
]);
$volume->getFs();
-})->throws(InvalidConfigException::class, 'Volume is missing its filesystem handle.');
+})->throws(RuntimeException::class, 'Volume is missing its filesystem handle.');
it('returns MissingFs when the filesystem handle cannot be resolved', function () {
$volume = new Volume([
diff --git a/tests/Feature/Condition/ConditionRuleHtmlTest.php b/tests/Feature/Condition/ConditionRuleHtmlTest.php
index ece82a28379..ebbbb858865 100644
--- a/tests/Feature/Condition/ConditionRuleHtmlTest.php
+++ b/tests/Feature/Condition/ConditionRuleHtmlTest.php
@@ -2,7 +2,6 @@
declare(strict_types=1);
-use craft\base\ElementInterface;
use CraftCms\Cms\Condition\BaseSelectConditionRule;
use CraftCms\Cms\Element\Conditions\Contracts\ElementConditionRuleInterface;
use CraftCms\Cms\Element\Conditions\DateCreatedConditionRule;
@@ -11,6 +10,7 @@
use CraftCms\Cms\Element\Conditions\IdConditionRule;
use CraftCms\Cms\Element\Conditions\StatusConditionRule;
use CraftCms\Cms\Element\Conditions\TitleConditionRule;
+use CraftCms\Cms\Element\Contracts\ElementInterface;
use CraftCms\Cms\Element\Queries\Contracts\ElementQueryInterface;
use CraftCms\Cms\Entry\Elements\Entry;
use CraftCms\Cms\Shared\Enums\DateRangeType;
diff --git a/tests/Feature/Element/Concerns/HasCanonicalTest.php b/tests/Feature/Element/Concerns/HasCanonicalTest.php
index 2b03380bdf2..628e80108b7 100644
--- a/tests/Feature/Element/Concerns/HasCanonicalTest.php
+++ b/tests/Feature/Element/Concerns/HasCanonicalTest.php
@@ -5,8 +5,8 @@
use CraftCms\Cms\Element\Element;
use CraftCms\Cms\Entry\Elements\Entry;
use CraftCms\Cms\Entry\Models\Entry as EntryModel;
+use CraftCms\Cms\Shared\Exceptions\NotSupportedException;
use CraftCms\Cms\User\Elements\User;
-use yii\base\NotSupportedException;
use function Pest\Laravel\actingAs;
diff --git a/tests/Feature/Element/Concerns/HasCustomFieldsTest.php b/tests/Feature/Element/Concerns/HasCustomFieldsTest.php
index 10269ce83ab..18252f8bb75 100644
--- a/tests/Feature/Element/Concerns/HasCustomFieldsTest.php
+++ b/tests/Feature/Element/Concerns/HasCustomFieldsTest.php
@@ -3,8 +3,8 @@
declare(strict_types=1);
use Carbon\Carbon;
-use craft\base\ElementInterface;
use CraftCms\Cms\Database\Table;
+use CraftCms\Cms\Element\Contracts\ElementInterface;
use CraftCms\Cms\Element\Drafts;
use CraftCms\Cms\Entry\Models\Entry;
use CraftCms\Cms\Field\Models\Field;
diff --git a/tests/Feature/Element/Concerns/HasRoutesAndUrlsTest.php b/tests/Feature/Element/Concerns/HasRoutesAndUrlsTest.php
index be743d7b992..35c8429dbbe 100644
--- a/tests/Feature/Element/Concerns/HasRoutesAndUrlsTest.php
+++ b/tests/Feature/Element/Concerns/HasRoutesAndUrlsTest.php
@@ -11,7 +11,7 @@
use CraftCms\Cms\Support\Facades\Sites;
use CraftCms\Cms\User\Elements\User;
use Illuminate\Support\Facades\Event;
-use Twig\Markup;
+use Illuminate\Support\HtmlString;
use function Pest\Laravel\actingAs;
@@ -187,7 +187,7 @@ protected function route(): array|string|null
$link = $element->getLink();
- expect($link)->toBeInstanceOf(Markup::class);
+ expect($link)->toBeInstanceOf(HtmlString::class);
expect((string) $link)->toContain('toContain('test-path');
});
diff --git a/tests/Feature/Element/Concerns/LocalizableTest.php b/tests/Feature/Element/Concerns/LocalizableTest.php
index a880c05ebd5..de78306b0f6 100644
--- a/tests/Feature/Element/Concerns/LocalizableTest.php
+++ b/tests/Feature/Element/Concerns/LocalizableTest.php
@@ -2,14 +2,13 @@
declare(strict_types=1);
-use craft\base\ElementInterface;
-use craft\base\NestedElementInterface;
+use CraftCms\Cms\Element\Contracts\ElementInterface;
+use CraftCms\Cms\Element\Contracts\NestedElementInterface;
use CraftCms\Cms\Element\Element;
use CraftCms\Cms\Element\ElementCollection;
use CraftCms\Cms\Element\Queries\ElementQuery;
use CraftCms\Cms\Field\Contracts\ElementContainerFieldInterface;
use CraftCms\Cms\Support\Facades\Sites;
-use yii\base\InvalidConfigException;
class TestLocalizableElement extends Element
{
@@ -160,7 +159,7 @@ public function setSaveOwnership(bool $saveOwnership): void
$element = new TestLocalizableElement;
$element->siteId = 99999;
- expect(fn () => $element->getSite())->toThrow(InvalidConfigException::class);
+ expect(fn () => $element->getSite())->toThrow(RuntimeException::class);
});
});
diff --git a/tests/Feature/Element/Concerns/RenderableTest.php b/tests/Feature/Element/Concerns/RenderableTest.php
index 4eaf7f4f643..8603636bc29 100644
--- a/tests/Feature/Element/Concerns/RenderableTest.php
+++ b/tests/Feature/Element/Concerns/RenderableTest.php
@@ -7,7 +7,7 @@
use CraftCms\Cms\Entry\Models\Entry as EntryModel;
use CraftCms\Cms\User\Elements\User;
use Illuminate\Support\Facades\Event;
-use Twig\Markup;
+use Illuminate\Support\HtmlString;
use function Pest\Laravel\actingAs;
@@ -25,7 +25,7 @@
test('returns markup', function () {
$markup = $this->entry->render();
- expect($markup)->toBeInstanceOf(Markup::class);
+ expect($markup)->toBeInstanceOf(HtmlString::class);
});
test('Render event allows setting custom output', function () {
diff --git a/tests/Feature/Element/DraftsTest.php b/tests/Feature/Element/DraftsTest.php
index d36e0877e7e..52075886df9 100644
--- a/tests/Feature/Element/DraftsTest.php
+++ b/tests/Feature/Element/DraftsTest.php
@@ -2,8 +2,8 @@
declare(strict_types=1);
-use craft\base\ElementInterface;
use CraftCms\Cms\Database\Table;
+use CraftCms\Cms\Element\Contracts\ElementInterface;
use CraftCms\Cms\Element\Drafts;
use CraftCms\Cms\Element\Events\ApplyingDraft;
use CraftCms\Cms\Element\Events\CreatingDraft;
diff --git a/tests/Feature/Element/ElementActivityTest.php b/tests/Feature/Element/ElementActivityTest.php
index e941940cfad..09c7fbfb498 100644
--- a/tests/Feature/Element/ElementActivityTest.php
+++ b/tests/Feature/Element/ElementActivityTest.php
@@ -2,8 +2,8 @@
declare(strict_types=1);
-use craft\base\ElementInterface;
use CraftCms\Cms\Database\Table;
+use CraftCms\Cms\Element\Contracts\ElementInterface;
use CraftCms\Cms\Element\Data\ElementActivity as ElementActivityData;
use CraftCms\Cms\Element\Drafts;
use CraftCms\Cms\Element\ElementActivity as ElementActivityService;
diff --git a/tests/Feature/Element/ElementCachesTest.php b/tests/Feature/Element/ElementCachesTest.php
index d2f1f9c0264..478fbc0485f 100644
--- a/tests/Feature/Element/ElementCachesTest.php
+++ b/tests/Feature/Element/ElementCachesTest.php
@@ -2,9 +2,9 @@
declare(strict_types=1);
-use craft\base\ElementInterface;
-use craft\base\NestedElementInterface;
+use CraftCms\Cms\Element\Contracts\ElementInterface;
use CraftCms\Cms\Element\Contracts\ExpirableElementInterface;
+use CraftCms\Cms\Element\Contracts\NestedElementInterface;
use CraftCms\Cms\Element\Element;
use CraftCms\Cms\Element\ElementCaches;
use CraftCms\Cms\Element\Events\InvalidateElementCaches;
diff --git a/tests/Feature/Element/ElementCanonicalChanges/UpdateCanonicalElementTest.php b/tests/Feature/Element/ElementCanonicalChanges/UpdateCanonicalElementTest.php
index f890ed9eef8..374d1c05b29 100644
--- a/tests/Feature/Element/ElementCanonicalChanges/UpdateCanonicalElementTest.php
+++ b/tests/Feature/Element/ElementCanonicalChanges/UpdateCanonicalElementTest.php
@@ -2,9 +2,9 @@
declare(strict_types=1);
-use craft\base\ElementInterface;
use CraftCms\Cms\Database\Table;
use CraftCms\Cms\Element\BulkOp\BulkOps;
+use CraftCms\Cms\Element\Contracts\ElementInterface;
use CraftCms\Cms\Element\Drafts;
use CraftCms\Cms\Element\Operations\ElementCanonicalChanges;
use CraftCms\Cms\Element\Operations\ElementDuplicates;
diff --git a/tests/Feature/Element/ElementDeletions/CascadeDeleteDraftsAndRevisionsTest.php b/tests/Feature/Element/ElementDeletions/CascadeDeleteDraftsAndRevisionsTest.php
index c89d84dd8a2..fc0629f02c6 100644
--- a/tests/Feature/Element/ElementDeletions/CascadeDeleteDraftsAndRevisionsTest.php
+++ b/tests/Feature/Element/ElementDeletions/CascadeDeleteDraftsAndRevisionsTest.php
@@ -2,8 +2,8 @@
declare(strict_types=1);
-use craft\base\ElementInterface;
use CraftCms\Cms\Database\Table;
+use CraftCms\Cms\Element\Contracts\ElementInterface;
use CraftCms\Cms\Element\Drafts;
use CraftCms\Cms\Element\Operations\ElementDeletions;
use CraftCms\Cms\Element\Revisions;
diff --git a/tests/Feature/Element/ElementDeletions/RestoreElementsTest.php b/tests/Feature/Element/ElementDeletions/RestoreElementsTest.php
index 32876fa9881..0f03389f415 100644
--- a/tests/Feature/Element/ElementDeletions/RestoreElementsTest.php
+++ b/tests/Feature/Element/ElementDeletions/RestoreElementsTest.php
@@ -2,8 +2,8 @@
declare(strict_types=1);
-use craft\base\ElementInterface;
use CraftCms\Cms\Database\Table;
+use CraftCms\Cms\Element\Contracts\ElementInterface;
use CraftCms\Cms\Element\Element;
use CraftCms\Cms\Element\ElementCaches;
use CraftCms\Cms\Element\Elements;
diff --git a/tests/Feature/Element/ElementDuplicates/DuplicateElementTest.php b/tests/Feature/Element/ElementDuplicates/DuplicateElementTest.php
index 2d7a2f8160c..1c654b7502b 100644
--- a/tests/Feature/Element/ElementDuplicates/DuplicateElementTest.php
+++ b/tests/Feature/Element/ElementDuplicates/DuplicateElementTest.php
@@ -2,8 +2,8 @@
declare(strict_types=1);
-use craft\base\ElementInterface;
use CraftCms\Cms\Database\Table;
+use CraftCms\Cms\Element\Contracts\ElementInterface;
use CraftCms\Cms\Element\Drafts;
use CraftCms\Cms\Element\Events\AfterPropagate;
use CraftCms\Cms\Element\Exceptions\InvalidElementException;
diff --git a/tests/Feature/Element/ElementEagerLoaderTest.php b/tests/Feature/Element/ElementEagerLoaderTest.php
index 71e58dce17e..34eff461ae0 100644
--- a/tests/Feature/Element/ElementEagerLoaderTest.php
+++ b/tests/Feature/Element/ElementEagerLoaderTest.php
@@ -2,8 +2,8 @@
declare(strict_types=1);
-use craft\base\ElementInterface;
use CraftCms\Cms\Database\Table;
+use CraftCms\Cms\Element\Contracts\ElementInterface;
use CraftCms\Cms\Element\Data\EagerLoadPlan;
use CraftCms\Cms\Element\ElementCaches;
use CraftCms\Cms\Element\Elements;
diff --git a/tests/Feature/Element/ElementWrites/SaveElementTest.php b/tests/Feature/Element/ElementWrites/SaveElementTest.php
index 231cb348d83..206300b557e 100644
--- a/tests/Feature/Element/ElementWrites/SaveElementTest.php
+++ b/tests/Feature/Element/ElementWrites/SaveElementTest.php
@@ -21,6 +21,7 @@
use CraftCms\Cms\Support\Facades\Sites;
use CraftCms\Cms\Tests\TestClasses\TestEntryWithAfterValidate;
use CraftCms\Cms\User\Elements\User;
+use Illuminate\Support\Facades\Config;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Facades\Event;
@@ -307,6 +308,8 @@ function createEntryWithPlainTextField(array $entryAttributes = []): array
}
});
+ Config::set('queue.default', 'null');
+
expect($this->writes->saveElement($entry))->toBeTrue()
->and($beforeUpdateTriggered)->toBeTrue()
->and(DB::table(Table::SEARCHINDEXQUEUE)
diff --git a/tests/Feature/Element/NestedElementManagerTest.php b/tests/Feature/Element/NestedElementManagerTest.php
index 78c12282741..21c108cc1b3 100644
--- a/tests/Feature/Element/NestedElementManagerTest.php
+++ b/tests/Feature/Element/NestedElementManagerTest.php
@@ -2,10 +2,10 @@
declare(strict_types=1);
-use craft\base\ElementInterface;
use CraftCms\Cms\Address\Elements\Address as AddressElement;
use CraftCms\Cms\Address\Models\Address as AddressModel;
use CraftCms\Cms\Database\Table;
+use CraftCms\Cms\Element\Contracts\ElementInterface;
use CraftCms\Cms\Element\NestedElementManager;
use CraftCms\Cms\Element\Queries\EntryQuery;
use CraftCms\Cms\Entry\Elements\Entry as EntryElement;
diff --git a/tests/Feature/Element/Policies/ElementPolicyTest.php b/tests/Feature/Element/Policies/ElementPolicyTest.php
index a1b4d7c9ace..16bd7e9d213 100644
--- a/tests/Feature/Element/Policies/ElementPolicyTest.php
+++ b/tests/Feature/Element/Policies/ElementPolicyTest.php
@@ -2,9 +2,9 @@
declare(strict_types=1);
-use craft\base\ElementInterface;
-use craft\base\NestedElementInterface;
use CraftCms\Cms\Auth\Events\AuthorizingElement;
+use CraftCms\Cms\Element\Contracts\ElementInterface;
+use CraftCms\Cms\Element\Contracts\NestedElementInterface;
use CraftCms\Cms\Element\Element;
use CraftCms\Cms\Element\Policies\ElementPolicy;
use CraftCms\Cms\Field\Contracts\ElementContainerFieldInterface;
diff --git a/tests/Feature/Element/Queries/Concerns/QueriesUniqueElementsTest.php b/tests/Feature/Element/Queries/Concerns/QueriesUniqueElementsTest.php
index 10d3423ed79..c46dfa9ea91 100644
--- a/tests/Feature/Element/Queries/Concerns/QueriesUniqueElementsTest.php
+++ b/tests/Feature/Element/Queries/Concerns/QueriesUniqueElementsTest.php
@@ -1,8 +1,15 @@
site('*')->preferSites([$site2->id, $site1->id])->unique()->first()->siteId)->toBe($site2->id);
expect(entryQuery()->site('*')->preferSites([$site2->handle, $site1->handle])->unique()->first()->siteId)->toBe($site2->id);
});
+
+test('unique still deduplicates when siteId changes after the site filter is applied', function () {
+ $site1 = Site::firstOrFail();
+ $site2 = Site::factory()->create();
+
+ $field = Field::factory()->create([
+ 'type' => ContentBlockField::class,
+ ]);
+
+ $owner = Entry::factory()->create();
+ $owner->element->siteSettings()->create([
+ 'siteId' => $site2->id,
+ ]);
+ $owner->section->siteSettings()->create([
+ 'siteId' => $site2->id,
+ ]);
+
+ $contentBlock = ElementModel::factory()->create([
+ 'type' => ContentBlockElement::class,
+ ]);
+ $contentBlock->siteSettings()->create([
+ 'siteId' => $site2->id,
+ ]);
+
+ DB::table(Table::CONTENTBLOCKS)->insert([
+ 'id' => $contentBlock->id,
+ 'fieldId' => $field->id,
+ 'primaryOwnerId' => $owner->id,
+ ]);
+
+ DB::table(Table::ELEMENTS_OWNERS)->insert([
+ 'elementId' => $contentBlock->id,
+ 'ownerId' => $owner->id,
+ 'sortOrder' => 1,
+ ]);
+
+ $contentBlockQuery = fn () => tap(
+ ContentBlockElement::find()
+ ->fieldId($field->id)
+ ->siteId([$site1->id, $site2->id])
+ ->preferSites([$site1->id])
+ ->status(null),
+ fn (ContentBlockQuery $query) => $query->beforeQuery(
+ fn (ContentBlockQuery $query) => $query->owner(entryQuery()->id($owner->id)->one())
+ ),
+ );
+
+ expect($contentBlockQuery()->count())->toBe(2);
+ expect($contentBlockQuery()->unique()->count())->toBe(1);
+ expect($contentBlockQuery()->unique()->first()->siteId)->toBe($site1->id);
+});
diff --git a/tests/Feature/Element/Queries/Concerns/SearchesElementsTest.php b/tests/Feature/Element/Queries/Concerns/SearchesElementsTest.php
index 8d046bc7892..8059ef9dc44 100644
--- a/tests/Feature/Element/Queries/Concerns/SearchesElementsTest.php
+++ b/tests/Feature/Element/Queries/Concerns/SearchesElementsTest.php
@@ -1,48 +1,18 @@
create();
- $entry1->element->siteSettings->first()->update([
- 'title' => 'Foo',
- ]);
-
- $entry2 = EntryModel::factory()->create();
- $entry2->element->siteSettings->first()->update([
- 'title' => 'Bar',
- ]);
-
- $element1 = Elements::getElementById($entry1->id);
- $element2 = Elements::getElementById($entry2->id);
-
- Search::indexElementAttributes($element1);
- Search::indexElementAttributes($element2);
+ $entry1 = EntryModel::factory()->title('Foo')->indexed()->create();
+ $entry2 = EntryModel::factory()->title('Bar')->indexed()->create();
expect(entryQuery()->count())->toBe(2);
expect(entryQuery()->search('Foo')->count())->toBe(1);
});
test('search with score', function () {
- $entry1 = EntryModel::factory()->create();
- $entry1->element->siteSettings->first()->update([
- 'title' => 'Foo',
- 'content' => '',
- ]);
-
- $entry2 = EntryModel::factory()->create();
- $entry2->element->siteSettings->first()->update([
- 'title' => 'Bar',
- 'slug' => 'Foo',
- ]);
-
- $element1 = Elements::getElementById($entry1->id);
- $element2 = Elements::getElementById($entry2->id);
-
- Search::indexElementAttributes($element1);
- Search::indexElementAttributes($element2);
+ $entry1 = EntryModel::factory()->title('Foo')->indexed()->create();
+ $entry2 = EntryModel::factory()->title('Bar')->slug('Foo')->indexed()->create();
expect(entryQuery()->orderBy('score')->count())->toBe(2);
expect(entryQuery()->search('Foo')->orderBy('score')->count())->toBe(2);
diff --git a/tests/Feature/Element/Queries/ContentBlockQueryTest.php b/tests/Feature/Element/Queries/ContentBlockQueryTest.php
index 7abaddd220e..7fefa8df29a 100644
--- a/tests/Feature/Element/Queries/ContentBlockQueryTest.php
+++ b/tests/Feature/Element/Queries/ContentBlockQueryTest.php
@@ -1,17 +1,27 @@
create();
+ $field = Field::factory()->create([
+ 'type' => ContentBlockField::class,
+ ]);
+ $owner = Entry::factory()->create();
+ $contentBlock = ElementModel::factory()->create([
+ 'type' => ContentBlock::class,
+ ]);
DB::table(Table::CONTENTBLOCKS)
->insert([
- 'primaryOwnerId' => 1,
+ 'id' => $contentBlock->id,
+ 'primaryOwnerId' => $owner->id,
'fieldId' => $field->id,
]);
diff --git a/tests/Feature/Http/Controllers/Elements/CopyElementValuesControllerTest.php b/tests/Feature/Http/Controllers/Elements/CopyElementValuesControllerTest.php
new file mode 100644
index 00000000000..96b9ff95b27
--- /dev/null
+++ b/tests/Feature/Http/Controllers/Elements/CopyElementValuesControllerTest.php
@@ -0,0 +1,244 @@
+secondarySite = Site::factory()->create([
+ 'handle' => 'secondary',
+ ]);
+
+ $this->field = Field::factory()->create([
+ 'name' => 'Copy Field',
+ 'handle' => 'copyField',
+ 'type' => PlainText::class,
+ 'translationMethod' => 'site',
+ ]);
+ $this->entryType = EntryType::factory()->withField($this->field)->create();
+ $this->section = Section::factory()
+ ->withSites($this->secondarySite)
+ ->withEntryTypes($this->entryType)
+ ->create([
+ 'handle' => 'blog',
+ ]);
+});
+
+it('requires authentication', function () {
+ Auth::logout();
+
+ postJson(action(CopyElementValuesController::class), [
+ 'elementType' => Entry::class,
+ ])->assertUnauthorized();
+});
+
+it('returns responses resolved by the element request', function () {
+ $entry = EntryModel::factory()
+ ->forSection($this->section)
+ ->forEntryType($this->entryType)
+ ->createElement([
+ 'title' => 'Canonical Title',
+ 'slug' => 'canonical-title',
+ ]);
+
+ post(action(CopyElementValuesController::class), [
+ 'elementType' => Entry::class,
+ 'elementId' => $entry->id,
+ 'draftId' => 999999,
+ 'siteId' => $entry->siteId,
+ ])->assertRedirect($entry->getCpEditUrl());
+});
+
+it('returns 400 when no element is identified by the request', function () {
+ postJson(action(CopyElementValuesController::class), [
+ 'elementType' => Entry::class,
+ 'elementId' => 999999,
+ 'siteId' => Sites::getPrimarySite()->id,
+ ])->assertBadRequest();
+});
+
+it('returns 400 for revisions', function () {
+ $entry = EntryModel::factory()
+ ->forSection($this->section)
+ ->forEntryType($this->entryType)
+ ->createElement([
+ 'title' => 'Canonical Title',
+ 'slug' => 'canonical-title',
+ ]);
+ $revisionId = app(Revisions::class)->createRevision($entry, auth()->id());
+
+ postJson(action(CopyElementValuesController::class), [
+ 'elementType' => Entry::class,
+ 'revisionId' => $revisionId,
+ 'siteId' => $entry->siteId,
+ ])->assertBadRequest();
+});
+
+it('validates the request payload', function () {
+ $entry = EntryModel::factory()
+ ->forSection($this->section)
+ ->forEntryType($this->entryType)
+ ->createElement([
+ 'title' => 'Canonical Title',
+ 'slug' => 'canonical-title',
+ ]);
+
+ postJson(action(CopyElementValuesController::class), [
+ 'elementType' => Entry::class,
+ 'elementId' => $entry->id,
+ 'siteId' => $entry->siteId,
+ ])->assertUnprocessable()
+ ->assertJsonValidationErrors(['fromSiteId', 'layoutElementUid']);
+});
+
+it('returns 400 for invalid source site ids', function () {
+ $entry = EntryModel::factory()
+ ->forSection($this->section)
+ ->forEntryType($this->entryType)
+ ->createElement([
+ 'title' => 'Canonical Title',
+ 'slug' => 'canonical-title',
+ ]);
+
+ postJson(action(CopyElementValuesController::class), [
+ 'elementType' => Entry::class,
+ 'elementId' => $entry->id,
+ 'siteId' => $entry->siteId,
+ 'fromSiteId' => 999999,
+ 'layoutElementUid' => Str::uuid()->toString(),
+ ])->assertBadRequest();
+});
+
+it('forbids copying values from a site the user cannot edit', function () {
+ $entry = EntryModel::factory()
+ ->forSection($this->section)
+ ->forEntryType($this->entryType)
+ ->createElement([
+ 'title' => 'Canonical Title',
+ 'slug' => 'canonical-title',
+ ]);
+
+ $viewer = UserModel::factory()
+ ->withPermissions([
+ 'accessCp',
+ sprintf('editSite:%s', Sites::getPrimarySite()->uid),
+ sprintf('viewEntries:%s', $this->section->uid),
+ sprintf('viewPeerEntries:%s', $this->section->uid),
+ ])
+ ->createElement();
+
+ actingAs($viewer);
+
+ postJson(action(CopyElementValuesController::class), [
+ 'elementType' => Entry::class,
+ 'elementId' => $entry->id,
+ 'siteId' => $entry->siteId,
+ 'fromSiteId' => $this->secondarySite->id,
+ 'layoutElementUid' => customFieldUid($entry),
+ ])->assertForbidden();
+});
+
+it('returns 400 for invalid layout element uuids', function () {
+ $entry = EntryModel::factory()
+ ->forSection($this->section)
+ ->forEntryType($this->entryType)
+ ->createElement([
+ 'title' => 'Canonical Title',
+ 'slug' => 'canonical-title',
+ ]);
+ localizeEntry($entry, $this->secondarySite->id);
+
+ postJson(action(CopyElementValuesController::class), [
+ 'elementType' => Entry::class,
+ 'elementId' => $entry->id,
+ 'siteId' => $entry->siteId,
+ 'fromSiteId' => $this->secondarySite->id,
+ 'layoutElementUid' => Str::uuid()->toString(),
+ ])->assertBadRequest();
+});
+
+it('copies a title field value from another site and returns updated field html', function () {
+ $entry = EntryModel::factory()
+ ->forSection($this->section)
+ ->forEntryType($this->entryType)
+ ->createElement([
+ 'title' => 'Primary Title',
+ 'slug' => 'primary-title',
+ ]);
+ localizeEntry($entry, $this->secondarySite->id);
+
+ /** @var Entry $secondaryEntry */
+ $secondaryEntry = Entry::find()
+ ->id($entry->id)
+ ->siteId($this->secondarySite->id)
+ ->status(null)
+ ->one();
+ $secondaryEntry->setFieldValue('copyField', 'Secondary field value');
+ $secondaryEntry->setAuthorIds([auth()->id()]);
+ Elements::saveElement($secondaryEntry);
+
+ postJson(action(CopyElementValuesController::class), [
+ 'elementType' => Entry::class,
+ 'elementId' => $entry->id,
+ 'siteId' => $entry->siteId,
+ 'fromSiteId' => $this->secondarySite->id,
+ 'layoutElementUid' => customFieldUid($entry),
+ 'namespace' => 'copyNamespace',
+ ])->assertOk()
+ ->assertJson(fn (AssertableJson $json) => $json
+ ->where('message', t('Field value copied.'))
+ ->where('modelName', 'element')
+ ->where('fieldHtml', fn (string $html) => $html !== ''
+ && str_contains($html, 'data-layout-element="'.customFieldUid($entry).'"'))
+ ->has('headHtml')
+ ->has('bodyHtml')
+ ->etc()
+ );
+});
+
+function customFieldUid(Entry $entry): string
+{
+ $layoutElement = $entry->getFieldLayout()->getCustomFieldElements()[0] ?? null;
+
+ expect($layoutElement?->uid)->not->toBeNull();
+
+ return $layoutElement->uid;
+}
+
+function localizeEntry(Entry $entry, int $siteId): void
+{
+ SectionSiteSettings::query()->firstOrCreate([
+ 'sectionId' => $entry->sectionId,
+ 'siteId' => $siteId,
+ ], [
+ 'uid' => (string) Str::uuid(),
+ 'hasUrls' => true,
+ ]);
+
+ EntryModel::query()->findOrFail($entry->id)->element->siteSettings()->firstOrCreate([
+ 'siteId' => $siteId,
+ ]);
+}
diff --git a/tests/Feature/Http/Controllers/Elements/CreateElementControllerTest.php b/tests/Feature/Http/Controllers/Elements/CreateElementControllerTest.php
new file mode 100644
index 00000000000..bcd802d9b0f
--- /dev/null
+++ b/tests/Feature/Http/Controllers/Elements/CreateElementControllerTest.php
@@ -0,0 +1,133 @@
+entryType = EntryType::factory()->create();
+ $this->section = Section::factory()->withEntryTypes($this->entryType)->create([
+ 'handle' => 'blog',
+ ]);
+});
+
+function createElementControllerPayload(object $section, object $entryType, array $overrides = []): array
+{
+ return array_merge([
+ 'elementType' => Entry::class,
+ 'siteId' => Sites::getPrimarySite()->id,
+ 'sectionId' => $section->id,
+ 'typeId' => $entryType->id,
+ 'title' => 'New Draft Entry',
+ ], $overrides);
+}
+
+it('requires authentication', function () {
+ Auth::logout();
+
+ postJson(action(CreateElementController::class), [
+ 'elementType' => Entry::class,
+ ])->assertUnauthorized();
+});
+
+it('requires a valid element type', function () {
+ postJson(action(CreateElementController::class), [
+ 'elementType' => stdClass::class,
+ ])->assertBadRequest();
+});
+
+it('forbids creating an element when the user cannot save it', function () {
+ $viewer = UserModel::factory()
+ ->withPermissions([
+ 'accessCp',
+ sprintf('editSite:%s', Sites::getPrimarySite()->uid),
+ ])
+ ->createElement();
+
+ actingAs($viewer);
+
+ postJson(action(CreateElementController::class), createElementControllerPayload($this->section, $this->entryType))
+ ->assertForbidden();
+});
+
+it('returns a failure response when saving the draft fails', function () {
+ app()->instance(Drafts::class, new readonly class(app(Elements::class)) extends Drafts
+ {
+ public function saveElementAsDraft(
+ ElementInterface $element,
+ ?int $creatorId = null,
+ ?string $name = null,
+ ?string $notes = null,
+ bool $markAsSaved = true,
+ ): bool {
+ return false;
+ }
+ });
+
+ postJson(action(CreateElementController::class), createElementControllerPayload($this->section, $this->entryType))
+ ->assertBadRequest()
+ ->assertJson(fn (AssertableJson $json) => $json
+ ->where('message', mb_ucfirst(t('Couldn’t create {type}.', [
+ 'type' => Entry::lowerDisplayName(),
+ ])))
+ ->where('modelName', 'element')
+ ->where('element.title', 'New Draft Entry')
+ ->etc()
+ );
+});
+
+it('creates a draft and returns its control panel edit url for json requests', function () {
+ $response = postJson(
+ cp_url('actions/elements/create'),
+ createElementControllerPayload($this->section, $this->entryType),
+ )->assertOk()
+ ->assertJson(fn (AssertableJson $json) => $json
+ ->where('message', t('{type} created.', ['type' => t('Draft')]))
+ ->where('modelName', 'element')
+ ->where('element.title', 'New Draft Entry')
+ ->where('element.sectionId', $this->section->id)
+ ->where('element.typeId', $this->entryType->id)
+ ->where('element.draftId', fn (int $draftId) => $draftId > 0)
+ ->etc()
+ );
+
+ /** @var Entry $draft */
+ $draft = Entry::find()
+ ->id($response->json('element.id'))
+ ->drafts()
+ ->status(null)
+ ->one();
+
+ expect($draft)->not->toBeNull()
+ ->and($draft->draftId)->toBe($response->json('element.draftId'))
+ ->and($draft->getIsUnpublishedDraft())->toBeTrue()
+ ->and($draft->title)->toBe('New Draft Entry');
+});
+
+it('redirects to the draft edit page for non-json requests', function () {
+ $draftCount = Entry::find()->drafts()->status(null)->count();
+
+ post(cp_url('actions/elements/create'), createElementControllerPayload($this->section, $this->entryType))
+ ->assertRedirect();
+
+ expect(Entry::find()->drafts()->status(null)->count())->toBe($draftCount + 1);
+});
diff --git a/tests/Feature/Http/Controllers/Elements/DeleteElementControllerTest.php b/tests/Feature/Http/Controllers/Elements/DeleteElementControllerTest.php
new file mode 100644
index 00000000000..869b7889ece
--- /dev/null
+++ b/tests/Feature/Http/Controllers/Elements/DeleteElementControllerTest.php
@@ -0,0 +1,220 @@
+logout();
+
+ postJson(action([DeleteElementController::class, 'destroy']))->assertUnauthorized();
+ postJson(action([DeleteElementController::class, 'destroyForSite']))->assertUnauthorized();
+});
+
+describe('destroy', function () {
+ it('returns 400 when no element is identified by the request', function () {
+ postJson(action([DeleteElementController::class, 'destroy']), [
+ 'elementType' => Entry::class,
+ 'siteId' => Sites::getPrimarySite()->id,
+ ])->assertBadRequest();
+ });
+
+ it('returns 400 for saved drafts', function () {
+ $entry = EntryModel::factory()->createElement();
+ $draft = app(Drafts::class)->createDraft($entry, auth()->id(), name: 'Delete Draft');
+
+ postJson(action([DeleteElementController::class, 'destroy']), [
+ 'elementId' => $entry->id,
+ 'draftId' => $draft->draftId,
+ 'siteId' => $draft->siteId,
+ ])->assertBadRequest();
+ });
+
+ it('returns 400 for revisions', function () {
+ $entry = EntryModel::factory()->createElement();
+ /** @var Entry $revision */
+ $revision = ElementsFacade::getElementById(app(Revisions::class)->createRevision($entry, auth()->id()));
+
+ postJson(action([DeleteElementController::class, 'destroy']), [
+ 'elementId' => $entry->id,
+ 'revisionId' => $revision->revisionId,
+ 'siteId' => $entry->siteId,
+ ])->assertBadRequest();
+ });
+
+ it('deletes the canonical element when a provisional draft is requested', function () {
+ $entry = EntryModel::factory()->createElement([
+ 'title' => 'Canonical Entry',
+ ]);
+ $draft = app(Drafts::class)->createDraft($entry, auth()->id(), provisional: true);
+
+ postJson(action([DeleteElementController::class, 'destroy']), [
+ 'elementId' => $draft->id,
+ 'siteId' => $draft->siteId,
+ ])->assertOk()
+ ->assertJsonPath('message', t('{type} deleted.', ['type' => Entry::displayName()]));
+
+ expect(Entry::find()->status(null)->id($entry->id)->trashed()->one()?->dateDeleted)
+ ->not->toBeNull();
+ });
+
+ it('returns a failure response when deleting the element fails', function () {
+ $entry = EntryModel::factory()->createElement();
+
+ Event::listen(BeforeDelete::class, function (BeforeDelete $event) use ($entry) {
+ if ($event->element->id === $entry->id) {
+ $event->isValid = false;
+ }
+ });
+
+ postJson(action([DeleteElementController::class, 'destroy']), [
+ 'elementId' => $entry->id,
+ 'siteId' => $entry->siteId,
+ ])->assertStatus(400)
+ ->assertJsonPath('message', t('Couldn’t delete {type}.', ['type' => $entry::lowerDisplayName()]));
+ });
+});
+
+describe('destroyForSite', function () {
+ it('returns 400 when no element is identified by the request', function () {
+ postJson(action([DeleteElementController::class, 'destroyForSite']), [
+ 'elementType' => Entry::class,
+ 'siteId' => Sites::getPrimarySite()->id,
+ ])->assertBadRequest();
+ });
+
+ it('returns 400 for revisions', function () {
+ $secondarySite = Site::factory()->create();
+ $section = Section::factory()
+ ->withSites($secondarySite)
+ ->withEntryTypes($entryType = EntryType::factory()->create())
+ ->create([
+ 'propagationMethod' => PropagationMethod::Custom,
+ ]);
+ $entry = EntryModel::factory()
+ ->forSection($section)
+ ->forEntryType($entryType)
+ ->enabledForSites($secondarySite)
+ ->createElement();
+ /** @var Entry $revision */
+ $revision = ElementsFacade::getElementById(app(Revisions::class)->createRevision($entry, auth()->id()));
+
+ postJson(action([DeleteElementController::class, 'destroyForSite']), [
+ 'elementId' => $entry->id,
+ 'revisionId' => $revision->revisionId,
+ 'siteId' => $entry->siteId,
+ ])->assertBadRequest();
+ });
+
+ it('deletes only the requested site for canonical entries', function () {
+ $secondarySite = Site::factory()->create();
+ $section = Section::factory()
+ ->withSites($secondarySite)
+ ->withEntryTypes($entryType = EntryType::factory()->create())
+ ->create([
+ 'propagationMethod' => PropagationMethod::Custom,
+ ]);
+ $entry = EntryModel::factory()
+ ->forSection($section)
+ ->forEntryType($entryType)
+ ->enabledForSites($secondarySite)
+ ->createElement([
+ 'title' => 'Multi-site entry',
+ ]);
+
+ postJson(action([DeleteElementController::class, 'destroyForSite']), [
+ 'elementId' => $entry->id,
+ 'siteId' => $secondarySite->id,
+ ])->assertOk()
+ ->assertJsonPath('message', t('{type} deleted for site.', ['type' => Entry::displayName()]));
+
+ expect(entryQuery()->id($entry->id)->siteId($entry->siteId)->status(null)->exists())->toBeTrue()
+ ->and(entryQuery()->id($entry->id)->siteId($secondarySite->id)->status(null)->exists())->toBeFalse();
+ });
+
+ it('returns the draft label when deleting a saved draft for a site', function () {
+ $secondarySite = Site::factory()->create();
+ $section = Section::factory()
+ ->withSites($secondarySite)
+ ->withEntryTypes($entryType = EntryType::factory()->create())
+ ->create([
+ 'propagationMethod' => PropagationMethod::Custom,
+ ]);
+ $entry = EntryModel::factory()
+ ->forSection($section)
+ ->forEntryType($entryType)
+ ->enabledForSites($secondarySite)
+ ->createElement([
+ 'title' => 'Multi-site entry',
+ ]);
+
+ $secondaryEntry = entryQuery()
+ ->id($entry->id)
+ ->siteId($secondarySite->id)
+ ->status(null)
+ ->one();
+ $draft = app(Drafts::class)->createDraft($secondaryEntry, auth()->id(), name: 'Site Draft');
+
+ postJson(action([DeleteElementController::class, 'destroyForSite']), [
+ 'elementId' => $draft->id,
+ 'siteId' => $draft->siteId,
+ ])->assertOk()
+ ->assertJsonPath('message', t('{type} deleted for site.', ['type' => t('Draft')]));
+ });
+
+ it('deletes the canonical site when the current user has a provisional draft for that site', function () {
+ $secondarySite = Site::factory()->create();
+ $section = Section::factory()
+ ->withSites($secondarySite)
+ ->withEntryTypes($entryType = EntryType::factory()->create())
+ ->create([
+ 'propagationMethod' => PropagationMethod::Custom,
+ ]);
+ $entry = EntryModel::factory()
+ ->forSection($section)
+ ->forEntryType($entryType)
+ ->enabledForSites($secondarySite)
+ ->createElement([
+ 'title' => 'Multi-site entry',
+ ]);
+
+ $secondaryEntry = entryQuery()
+ ->id($entry->id)
+ ->siteId($secondarySite->id)
+ ->status(null)
+ ->one();
+
+ $draft = app(Drafts::class)->createDraft($secondaryEntry, auth()->id(), provisional: true);
+
+ postJson(action([DeleteElementController::class, 'destroyForSite']), [
+ 'elementId' => $entry->id,
+ 'siteId' => $secondarySite->id,
+ ])->assertOk()
+ ->assertJsonPath('message', t('{type} deleted for site.', ['type' => Entry::displayName()]));
+
+ expect(entryQuery()->id($entry->id)->siteId($entry->siteId)->status(null)->exists())->toBeTrue()
+ ->and(entryQuery()->id($entry->id)->siteId($secondarySite->id)->status(null)->exists())->toBeFalse()
+ ->and(Entry::find()->id($draft->id)->drafts()->provisionalDrafts()->siteId($secondarySite->id)->status(null)->exists())->toBeFalse();
+ });
+});
diff --git a/tests/Feature/Http/Controllers/Elements/DuplicateElementControllerTest.php b/tests/Feature/Http/Controllers/Elements/DuplicateElementControllerTest.php
new file mode 100644
index 00000000000..ae1cc919da0
--- /dev/null
+++ b/tests/Feature/Http/Controllers/Elements/DuplicateElementControllerTest.php
@@ -0,0 +1,339 @@
+entryType = EntryType::factory()->create();
+ $this->section = Section::factory()->withEntryTypes($this->entryType)->create([
+ 'handle' => 'blog',
+ 'enableVersioning' => true,
+ ]);
+});
+
+describe('duplicate', function () {
+ it('requires authentication', function () {
+ Auth::logout();
+
+ postJson(action([DuplicateElementController::class, 'duplicate']), [
+ 'elementType' => Entry::class,
+ ])->assertUnauthorized();
+ });
+
+ it('returns 400 when no element is identified by the request', function () {
+ postJson(action([DuplicateElementController::class, 'duplicate']), [
+ 'elementType' => Entry::class,
+ 'elementId' => 999999,
+ 'siteId' => Sites::getPrimarySite()->id,
+ ])->assertBadRequest();
+ });
+
+ it('returns 400 for revisions', function () {
+ $entry = EntryModel::factory()
+ ->forSection($this->section)
+ ->forEntryType($this->entryType)
+ ->createElement([
+ 'title' => 'Canonical Title',
+ 'slug' => 'canonical-title',
+ ]);
+ /** @var Entry $revision */
+ $revision = Elements::getElementById(app(Revisions::class)->createRevision($entry, auth()->id()));
+
+ postJson(action([DuplicateElementController::class, 'duplicate']), [
+ 'elementType' => Entry::class,
+ 'revisionId' => $revision->revisionId,
+ 'siteId' => $revision->siteId,
+ ])->assertBadRequest();
+ });
+
+ it('forbids duplicating an element without permission', function () {
+ $entry = EntryModel::factory()
+ ->forSection($this->section)
+ ->forEntryType($this->entryType)
+ ->createElement([
+ 'title' => 'Canonical Title',
+ 'slug' => 'canonical-title',
+ ]);
+
+ $viewer = UserModel::factory()
+ ->withPermissions([
+ 'accessCp',
+ sprintf('editSite:%s', Sites::getPrimarySite()->uid),
+ sprintf('viewEntries:%s', $this->section->uid),
+ sprintf('viewPeerEntries:%s', $this->section->uid),
+ ])
+ ->createElement();
+
+ actingAs($viewer);
+
+ postJson(action([DuplicateElementController::class, 'duplicate']), [
+ 'elementType' => Entry::class,
+ 'elementId' => $entry->id,
+ 'siteId' => $entry->siteId,
+ ])->assertForbidden();
+ });
+
+ it('returns a failure response when duplication raises an invalid element exception', function () {
+ $entry = EntryModel::factory()
+ ->forSection($this->section)
+ ->forEntryType($this->entryType)
+ ->createElement([
+ 'title' => 'Canonical Title',
+ 'slug' => 'canonical-title',
+ ]);
+ $entry->errors()->add('title', 'Title is invalid.');
+
+ $elements = Mockery::mock(ElementsService::class);
+ $elements->shouldReceive('duplicateElement')
+ ->once()
+ ->andThrow(new InvalidElementException($entry));
+
+ app()->instance(ElementsService::class, $elements);
+
+ postJson(action([DuplicateElementController::class, 'duplicate']), [
+ 'elementType' => Entry::class,
+ 'elementId' => $entry->id,
+ 'siteId' => $entry->siteId,
+ ])->assertBadRequest()
+ ->assertJson(fn (AssertableJson $json) => $json
+ ->where('message', t('Couldn’t duplicate {type}.', ['type' => Entry::lowerDisplayName()]))
+ ->where('errors.title.0', 'Title is invalid.')
+ ->etc()
+ );
+ });
+
+ it('duplicates a canonical element', function () {
+ $entry = EntryModel::factory()
+ ->forSection($this->section)
+ ->forEntryType($this->entryType)
+ ->createElement([
+ 'title' => 'Canonical Title',
+ 'slug' => 'canonical-title',
+ ]);
+
+ $response = postJson(action([DuplicateElementController::class, 'duplicate']), [
+ 'elementType' => Entry::class,
+ 'elementId' => $entry->id,
+ 'siteId' => $entry->siteId,
+ ])->assertOk()
+ ->assertJson(fn (AssertableJson $json) => $json
+ ->where('message', t('{type} duplicated.', ['type' => Entry::displayName()]))
+ ->where('modelName', 'element')
+ ->where('element.title', 'Canonical Title')
+ ->etc()
+ );
+
+ /** @var Entry $duplicate */
+ $duplicate = Entry::find()
+ ->id($response->json('element.id'))
+ ->siteId($entry->siteId)
+ ->status(null)
+ ->one();
+
+ expect($duplicate)->not->toBeNull()
+ ->and($duplicate->id)->not->toBe($entry->id)
+ ->and($duplicate->getCanonicalId())->toBe($duplicate->id)
+ ->and($duplicate->draftId)->toBeNull()
+ ->and($duplicate->title)->toBe('Canonical Title');
+ });
+
+ it('duplicates a provisional draft as an unpublished draft and deletes the provisional source', function () {
+ $entry = EntryModel::factory()
+ ->forSection($this->section)
+ ->forEntryType($this->entryType)
+ ->createElement([
+ 'title' => 'Canonical Title',
+ 'slug' => 'canonical-title',
+ ]);
+ /** @var Entry $draft */
+ $draft = app(Drafts::class)->createDraft($entry, auth()->id(), provisional: true);
+
+ $response = postJson(action([DuplicateElementController::class, 'duplicate']), [
+ 'elementType' => Entry::class,
+ 'draftId' => $draft->draftId,
+ 'siteId' => $draft->siteId,
+ 'asUnpublishedDraft' => true,
+ 'deleteProvisionalDraft' => true,
+ ])->assertOk()
+ ->assertJson(fn (AssertableJson $json) => $json
+ ->where('message', t('{type} duplicated.', ['type' => Entry::displayName()]))
+ ->where('element.draftName', t('First draft'))
+ ->etc()
+ );
+
+ /** @var Entry $duplicate */
+ $duplicate = Entry::find()
+ ->id($response->json('element.id'))
+ ->drafts()
+ ->status(null)
+ ->one();
+
+ expect($duplicate)->not->toBeNull()
+ ->and($duplicate->draftId)->not->toBeNull()
+ ->and($duplicate->getIsUnpublishedDraft())->toBeTrue()
+ ->and($duplicate->draftName)->toBe(t('First draft'))
+ ->and(Entry::find()->draftId($draft->draftId)->status(null)->one())->toBeNull();
+ });
+});
+
+describe('bulkDuplicate', function () {
+ it('requires authentication', function () {
+ Auth::logout();
+
+ postJson(action([DuplicateElementController::class, 'bulkDuplicate']))->assertUnauthorized();
+ });
+
+ it('validates the payload', function () {
+ postJson(action([DuplicateElementController::class, 'bulkDuplicate']), [])
+ ->assertUnprocessable()
+ ->assertJsonValidationErrors(['elements', 'newAttributes']);
+ });
+
+ it('skips unidentified elements', function () {
+ postJson(action([DuplicateElementController::class, 'bulkDuplicate']), [
+ 'elements' => [[
+ 'type' => Entry::class,
+ 'id' => 999999,
+ 'siteId' => Sites::getPrimarySite()->id,
+ ]],
+ 'newAttributes' => [
+ 'sectionId' => $this->section->id,
+ 'typeId' => $this->entryType->id,
+ ],
+ ])->assertOk()
+ ->assertJson(fn (AssertableJson $json) => $json
+ ->where('message', mb_ucfirst(t('{type} duplicated.', ['type' => Entry::displayName()])))
+ ->where('newElements', [])
+ );
+ });
+
+ it('forbids bulk duplication when the user cannot duplicate the element', function () {
+ $entry = EntryModel::factory()
+ ->forSection($this->section)
+ ->forEntryType($this->entryType)
+ ->createElement([
+ 'title' => 'Canonical Title',
+ 'slug' => 'canonical-title',
+ ]);
+
+ $viewer = UserModel::factory()
+ ->withPermissions([
+ 'accessCp',
+ sprintf('editSite:%s', Sites::getPrimarySite()->uid),
+ sprintf('viewEntries:%s', $this->section->uid),
+ sprintf('viewPeerEntries:%s', $this->section->uid),
+ ])
+ ->createElement();
+
+ actingAs($viewer);
+
+ postJson(action([DuplicateElementController::class, 'bulkDuplicate']), [
+ 'elements' => [[
+ 'type' => Entry::class,
+ 'id' => $entry->id,
+ 'siteId' => $entry->siteId,
+ ]],
+ 'newAttributes' => [
+ 'sectionId' => $this->section->id,
+ 'typeId' => $this->entryType->id,
+ ],
+ ])->assertForbidden();
+ });
+
+ it('returns a failure response when bulk duplication raises an invalid element exception', function () {
+ $entry = EntryModel::factory()
+ ->forSection($this->section)
+ ->forEntryType($this->entryType)
+ ->createElement([
+ 'title' => 'Canonical Title',
+ 'slug' => 'canonical-title',
+ ]);
+ $entry->errors()->add('title', 'Title is invalid.');
+
+ $elements = Mockery::mock(ElementsService::class);
+ $elements->shouldReceive('duplicateElement')
+ ->once()
+ ->andThrow(new InvalidElementException($entry));
+
+ app()->instance(ElementsService::class, $elements);
+
+ postJson(action([DuplicateElementController::class, 'bulkDuplicate']), [
+ 'elements' => [[
+ 'type' => Entry::class,
+ 'id' => $entry->id,
+ 'siteId' => $entry->siteId,
+ ]],
+ 'newAttributes' => [
+ 'sectionId' => $this->section->id,
+ 'typeId' => $this->entryType->id,
+ ],
+ ])->assertBadRequest()
+ ->assertJson(fn (AssertableJson $json) => $json
+ ->where('message', t('Couldn’t duplicate {type}.', ['type' => Entry::lowerDisplayName()]))
+ ->where('errors.title.0', 'Title is invalid.')
+ ->etc()
+ );
+ });
+
+ it('duplicates revisions as regular elements', function () {
+ $entry = EntryModel::factory()
+ ->forSection($this->section)
+ ->forEntryType($this->entryType)
+ ->createElement([
+ 'title' => 'Original Title',
+ 'slug' => 'original-title',
+ ]);
+ /** @var Entry $revision */
+ $revision = Elements::getElementById(app(Revisions::class)->createRevision($entry, auth()->id()));
+
+ $response = postJson(action([DuplicateElementController::class, 'bulkDuplicate']), [
+ 'elements' => [[
+ 'type' => Entry::class,
+ 'revisionId' => $revision->revisionId,
+ 'siteId' => $revision->siteId,
+ ]],
+ 'newAttributes' => [
+ 'sectionId' => $this->section->id,
+ 'typeId' => $this->entryType->id,
+ ],
+ ])->assertOk()
+ ->assertJson(fn (AssertableJson $json) => $json
+ ->where('message', mb_ucfirst(t('{type} duplicated.', ['type' => Entry::displayName()])))
+ ->has('newElements', 1)
+ ->where('newElements.0.title', 'Original Title')
+ );
+
+ /** @var Entry $duplicate */
+ $duplicate = Entry::find()
+ ->id($response->json('newElements.0.id'))
+ ->siteId($entry->siteId)
+ ->status(null)
+ ->one();
+
+ expect($duplicate)->not->toBeNull()
+ ->and($duplicate->id)->not->toBe($entry->id)
+ ->and($duplicate->revisionId)->toBeNull()
+ ->and($duplicate->title)->toBe('Original Title');
+ });
+});
diff --git a/tests/Feature/Http/Controllers/Elements/EditElementControllerTest.php b/tests/Feature/Http/Controllers/Elements/EditElementControllerTest.php
new file mode 100644
index 00000000000..8d111d6a290
--- /dev/null
+++ b/tests/Feature/Http/Controllers/Elements/EditElementControllerTest.php
@@ -0,0 +1,329 @@
+ [
+ fn (Entry $entry) => cp_url(sprintf(
+ 'entries/%s/%d-%s',
+ $entry->getSection()->handle,
+ $entry->id,
+ $entry->slug,
+ )),
+ ],
+ 'content route' => [
+ fn (Entry $entry) => cp_url(sprintf(
+ 'content/entries/%s/%d-%s',
+ $entry->getSection()->handle,
+ $entry->id,
+ $entry->slug,
+ )),
+ ],
+]);
+
+beforeEach(function () {
+ actingAs(User::findOne());
+
+ config()->set('filesystems.disks.edit-element-controller-test', [
+ 'driver' => 'local',
+ 'root' => storage_path('framework/testing/edit-element-controller-test'),
+ ]);
+
+ $this->entryType = EntryType::factory()->create();
+ $this->section = Section::factory()->withEntryTypes($this->entryType)->create([
+ 'handle' => 'news',
+ 'enableVersioning' => true,
+ ]);
+ $this->volume = Volume::factory()->create(['fs' => 'disk:edit-element-controller-test']);
+ $this->folder = VolumeFolderModel::factory()->create(['volumeId' => $this->volume->id]);
+});
+
+it('requires login for each entry control panel edit route', function (Closure $route) {
+ $entry = EntryModel::factory()
+ ->forSection($this->section)
+ ->forEntryType($this->entryType)
+ ->createElement([
+ 'title' => 'Current Title',
+ 'slug' => 'current-title',
+ ]);
+
+ Auth::logout();
+
+ get($route($entry))->assertRedirectContains('login');
+})->with('editElementEntryRoutes');
+
+it('requires login for the asset control panel edit route', function () {
+ $asset = AssetModel::factory()->createElement([
+ 'volumeId' => $this->volume->id,
+ 'folderId' => $this->folder->id,
+ ]);
+
+ Auth::logout();
+
+ get($asset->getCpEditUrl())->assertRedirectContains('login');
+});
+
+it('requires authentication for the action route', function () {
+ Auth::logout();
+
+ postJson(action(EditElementController::class), [
+ 'elementType' => Entry::class,
+ ], [
+ 'X-Craft-Container-Id' => 'slideout',
+ ])->assertUnauthorized();
+});
+
+it('renders the current entry edit screen for each control panel route', function (Closure $route) {
+ $entry = EntryModel::factory()
+ ->forSection($this->section)
+ ->forEntryType($this->entryType)
+ ->createElement([
+ 'title' => 'Current Title',
+ 'slug' => 'current-title',
+ ]);
+
+ get($route($entry))
+ ->assertOk()
+ ->assertSeeText('Current Title')
+ ->assertSeeText('Create a draft')
+ ->assertSee('elements/save', false);
+})->with('editElementEntryRoutes');
+
+it('renders the asset edit screen', function () {
+ $asset = AssetModel::factory()->createElement([
+ 'volumeId' => $this->volume->id,
+ 'folderId' => $this->folder->id,
+ 'filename' => 'featured-image.jpg',
+ ]);
+
+ get($asset->getCpEditUrl())
+ ->assertOk()
+ ->assertSee(sprintf('"elementId":%d', $asset->id), false);
+});
+
+it('returns responses resolved by the element request', function () {
+ $entry = EntryModel::factory()
+ ->forSection($this->section)
+ ->forEntryType($this->entryType)
+ ->createElement([
+ 'title' => 'Canonical Title',
+ 'slug' => 'canonical-title',
+ ]);
+
+ post(action(EditElementController::class), [
+ 'elementType' => $entry::class,
+ 'elementId' => $entry->id,
+ 'siteId' => $entry->siteId,
+ 'draftId' => 999999,
+ ])->assertRedirect($entry->getCpEditUrl());
+});
+
+it('returns 400 when no element is identified by the request', function () {
+ postJson(action(EditElementController::class), [
+ 'elementType' => Entry::class,
+ 'siteId' => Sites::getPrimarySite()->id,
+ ], [
+ 'X-Craft-Container-Id' => 'slideout',
+ ])->assertBadRequest();
+});
+
+it('returns a json editor payload for the current element', function () {
+ $entry = EntryModel::factory()
+ ->forSection($this->section)
+ ->forEntryType($this->entryType)
+ ->createElement([
+ 'title' => 'Current Title',
+ 'slug' => 'current-title',
+ ]);
+
+ getJson(action(EditElementController::class, [
+ 'elementType' => $entry::class,
+ 'elementId' => $entry->id,
+ 'siteId' => $entry->siteId,
+ ]), [
+ 'X-Craft-Container-Id' => 'slideout',
+ ])
+ ->assertOk()
+ ->assertJson(fn (AssertableJson $json) => $json
+ ->where('action', 'elements/save')
+ ->where('notice', null)
+ ->where('content', fn (string $content) => $content !== ''
+ && str_contains($content, 'elements/save'))
+ ->where('bodyHtml', fn (string $html) => str_contains($html, sprintf('"elementId":%d', $entry->id))
+ && str_contains($html, sprintf('"canonicalId":%d', $entry->id))
+ && str_contains($html, '"isStatic":false')
+ && str_contains($html, '"isProvisionalDraft":false')
+ && str_contains($html, '"isUnpublishedDraft":false'))
+ ->has('headHtml')
+ ->has('bodyHtml')
+ ->has('deltaNames')
+ ->has('initialDeltaValues')
+ ->etc()
+ );
+});
+
+it('prevalidates enabled live elements and returns an error summary', function () {
+ $entry = EntryModel::factory()
+ ->forSection($this->section)
+ ->forEntryType($this->entryType)
+ ->createElement([
+ 'title' => 'Current Title',
+ 'slug' => 'current-title',
+ ]);
+
+ postJson(action(EditElementController::class), [
+ 'elementType' => $entry::class,
+ 'elementId' => $entry->id,
+ 'siteId' => $entry->siteId,
+ 'prevalidate' => 1,
+ 'title' => '',
+ ], [
+ 'X-Craft-Container-Id' => 'slideout',
+ ])
+ ->assertOk()
+ ->assertJson(fn (AssertableJson $json) => $json
+ ->where('action', 'elements/save')
+ ->where('errorSummary', fn (?string $summary) => is_string($summary)
+ && str_contains($summary, 'The title field is required.')
+ && str_contains($summary, 'field-error-key'))
+ ->etc()
+ );
+});
+
+it('renders draft editing controls for saved drafts', function () {
+ $entry = EntryModel::factory()
+ ->forSection($this->section)
+ ->forEntryType($this->entryType)
+ ->createElement([
+ 'title' => 'Canonical Title',
+ 'slug' => 'canonical-title',
+ ]);
+ /** @var Entry $draft */
+ $draft = app(Drafts::class)->createDraft($entry, auth()->id(), name: 'Working Draft');
+
+ get(cp_url(sprintf(
+ 'entries/%s/%d-%s?draftId=%d',
+ $entry->getSection()->handle,
+ $entry->id,
+ $entry->slug,
+ $draft->draftId,
+ )))
+ ->assertOk()
+ ->assertSeeText('Apply draft')
+ ->assertSeeText(mb_ucfirst(t('Save {type}', ['type' => t('draft')])))
+ ->assertSee('elements/save-draft', false);
+});
+
+it('renders revision notices and controls for revisions', function () {
+ $entry = EntryModel::factory()
+ ->forSection($this->section)
+ ->forEntryType($this->entryType)
+ ->createElement([
+ 'title' => 'Canonical Title',
+ 'slug' => 'canonical-title',
+ ]);
+ /** @var Entry $revision */
+ $revision = Elements::getElementById(app(Revisions::class)->createRevision($entry, auth()->id(), 'Revision notes'));
+
+ get(cp_url(sprintf(
+ 'entries/%s/%d-%s?revisionId=%d',
+ $entry->getSection()->handle,
+ $entry->id,
+ $entry->slug,
+ $revision->revisionId,
+ )))
+ ->assertOk()
+ ->assertSeeText('viewing a revision')
+ ->assertSeeText('Revert content from this revision')
+ ->assertSee('elements/revert', false);
+});
+
+it('renders provisional draft notices when a provisional draft exists', function () {
+ $entry = EntryModel::factory()
+ ->forSection($this->section)
+ ->forEntryType($this->entryType)
+ ->createElement([
+ 'title' => 'Canonical Title',
+ 'slug' => 'canonical-title',
+ ]);
+
+ app(Drafts::class)->createDraft($entry, auth()->id(), provisional: true);
+
+ get(cp_url(sprintf(
+ 'entries/%s/%d-%s',
+ $entry->getSection()->handle,
+ $entry->id,
+ $entry->slug,
+ )))
+ ->assertOk()
+ ->assertSeeText('Showing your unsaved changes.')
+ ->assertSee('elements/apply-draft', false);
+});
+
+it('renders unpublished draft controls', function () {
+ /** @var Entry $draft */
+ $draft = app(Entry::class);
+ $draft->siteId = Sites::getPrimarySite()->id;
+ $draft->sectionId = $this->section->id;
+ $draft->typeId = $this->entryType->id;
+ $draft->title = 'Unpublished Draft';
+ $draft->slug = Str::slug($draft->title);
+ $draft->setAuthorIds([auth()->id()]);
+
+ app(Drafts::class)->saveElementAsDraft($draft, auth()->id(), markAsSaved: false);
+
+ get($draft->getCpEditUrl())
+ ->assertOk()
+ ->assertSeeText(mb_ucfirst(t('Create {type}', ['type' => Entry::lowerDisplayName()])))
+ ->assertSee('elements/apply-draft', false);
+});
+
+it('merges canonical changes into outdated drafts before rendering', function () {
+ $entry = EntryModel::factory()
+ ->forSection($this->section)
+ ->forEntryType($this->entryType)
+ ->createElement([
+ 'title' => 'Canonical Title',
+ 'slug' => 'canonical-title',
+ ]);
+ /** @var Entry $draft */
+ $draft = app(Drafts::class)->createDraft($entry, auth()->id(), name: 'Working Draft');
+
+ $entry->title = 'Updated Canonical Title';
+ Elements::saveElement($entry);
+
+ get(cp_url(sprintf(
+ 'entries/%s/%d-%s?draftId=%d',
+ $entry->getSection()->handle,
+ $entry->id,
+ $entry->slug,
+ $draft->draftId,
+ )))
+ ->assertOk()
+ ->assertSeeText('Recent changes to the Current revision have been merged into this draft.');
+});
diff --git a/tests/Feature/Http/Controllers/Elements/ElementActivityControllerTest.php b/tests/Feature/Http/Controllers/Elements/ElementActivityControllerTest.php
new file mode 100644
index 00000000000..3063161dced
--- /dev/null
+++ b/tests/Feature/Http/Controllers/Elements/ElementActivityControllerTest.php
@@ -0,0 +1,106 @@
+ Entry::class,
+ ])->assertUnauthorized();
+});
+
+it('returns responses resolved by the element request', function () {
+ $entry = EntryModel::factory()->createElement([
+ 'title' => 'Canonical Title',
+ 'slug' => 'canonical-title',
+ ]);
+
+ post(action(ElementActivityController::class), [
+ 'elementType' => Entry::class,
+ 'elementId' => $entry->id,
+ 'draftId' => 999999,
+ 'siteId' => $entry->siteId,
+ ])->assertRedirect($entry->getCpEditUrl());
+});
+
+it('returns 400 when no element is identified by the request', function () {
+ postJson(action(ElementActivityController::class), [
+ 'elementType' => Entry::class,
+ 'siteId' => 1,
+ ])->assertBadRequest();
+});
+
+it('returns 400 for revisions', function () {
+ $entry = EntryModel::factory()->createElement([
+ 'title' => 'Canonical Title',
+ 'slug' => 'canonical-title',
+ ]);
+ /** @var Entry $revision */
+ $revision = Elements::getElementById(app(Revisions::class)->createRevision($entry, auth()->id()));
+
+ postJson(action(ElementActivityController::class), [
+ 'elementType' => Entry::class,
+ 'revisionId' => $revision->revisionId,
+ 'siteId' => $revision->siteId,
+ ])->assertBadRequest();
+});
+
+it('returns recent activity and tracks the current user viewing the element', function () {
+ $entry = EntryModel::factory()->createElement([
+ 'title' => 'Canonical Title',
+ 'slug' => 'canonical-title',
+ ]);
+ $otherUser = UserModel::factory()->createElement();
+
+ app(ElementActivityService::class)->trackActivity($entry, ElementActivityType::Edit, $otherUser);
+
+ $response = postJson(action(ElementActivityController::class), [
+ 'elementType' => Entry::class,
+ 'elementId' => $entry->id,
+ 'siteId' => $entry->siteId,
+ ])
+ ->assertOk()
+ ->assertJson(fn (AssertableJson $json) => $json
+ ->where('updatedTimestamp', $entry->dateUpdated->getTimestamp())
+ ->where('canonicalUpdatedTimestamp', $entry->getCanonical()->dateUpdated->getTimestamp())
+ ->has('activity', 1)
+ ->where('activity.0.userId', $otherUser->id)
+ ->where('activity.0.userName', $otherUser->getName())
+ ->where('activity.0.type', ElementActivityType::Edit->value)
+ );
+
+ expect($response->json('activity.0.message'))->toContain('is editing this entry.');
+
+ $viewerActivity = DB::table(Table::ELEMENTACTIVITY)
+ ->where('elementId', $entry->id)
+ ->where('userId', auth()->id())
+ ->where('siteId', $entry->siteId)
+ ->whereNull('draftId')
+ ->where('type', ElementActivityType::View->value)
+ ->first();
+
+ expect($viewerActivity)->not->toBeNull();
+});
diff --git a/tests/Feature/Http/Controllers/Elements/ElementDraftsControllerTest.php b/tests/Feature/Http/Controllers/Elements/ElementDraftsControllerTest.php
new file mode 100644
index 00000000000..e8404d46726
--- /dev/null
+++ b/tests/Feature/Http/Controllers/Elements/ElementDraftsControllerTest.php
@@ -0,0 +1,716 @@
+ $entry->id,
+ 'siteId' => $entry->siteId,
+ 'title' => 'Ported Draft Title',
+ 'slug' => 'ported-draft-title',
+ 'draftName' => 'Ported Draft',
+ 'notes' => 'Ported draft notes',
+ ], $overrides);
+}
+
+beforeEach(function () {
+ actingAs(User::findOne());
+});
+
+describe('ensure', function () {
+ it('returns 400 when ensure does not identify an element', function () {
+ postJson(action([ElementDraftsController::class, 'ensure']), [
+ 'elementType' => Entry::class,
+ 'siteId' => Sites::getPrimarySite()->id,
+ ])->assertBadRequest();
+ });
+
+ it('returns 400 for revisions when ensuring drafts', function () {
+ $entry = EntryModel::factory()->createElement([
+ 'title' => 'Canonical Title',
+ ]);
+ /** @var Entry $revision */
+ $revision = ElementsFacade::getElementById(app(Revisions::class)->createRevision($entry, auth()->id()));
+
+ postJson(action([ElementDraftsController::class, 'ensure']), [
+ 'elementType' => Entry::class,
+ 'elementId' => $entry->id,
+ 'revisionId' => $revision->revisionId,
+ 'siteId' => $entry->siteId,
+ ])->assertBadRequest();
+ });
+
+ it('returns the existing draft when ensure identifies one', function () {
+ $entry = EntryModel::factory()->createElement([
+ 'title' => 'Canonical Title',
+ ]);
+ /** @var Entry $draft */
+ $draft = app(Drafts::class)->createDraft($entry, auth()->id(), name: 'Existing Draft');
+
+ postJson(action([ElementDraftsController::class, 'ensure']), [
+ 'elementType' => Entry::class,
+ 'draftId' => $draft->draftId,
+ 'siteId' => $draft->siteId,
+ ])->assertOk()
+ ->assertJsonPath('elementId', $draft->id);
+ });
+
+ it('returns the existing provisional draft for the requested element', function () {
+ $entry = EntryModel::factory()->createElement([
+ 'title' => 'Canonical Title',
+ ]);
+ /** @var Entry $draft */
+ $draft = app(Drafts::class)->createDraft($entry, auth()->id(), provisional: true);
+
+ postJson(action([ElementDraftsController::class, 'ensure']), [
+ 'elementType' => Entry::class,
+ 'elementId' => $entry->id,
+ 'siteId' => $entry->siteId,
+ ])->assertOk()
+ ->assertJsonPath('elementId', $draft->id);
+
+ expect(
+ Entry::find()
+ ->drafts()
+ ->provisionalDrafts()
+ ->draftOf($entry->id)
+ ->draftCreator(auth()->id())
+ ->status(null)
+ ->count()
+ )->toBe(1);
+ });
+
+ it('returns an existing provisional draft after resolving the canonical element', function () {
+ $entry = EntryModel::factory()->createElement([
+ 'title' => 'Canonical Title',
+ ]);
+ /** @var Entry $draft */
+ $draft = app(Drafts::class)->createDraft($entry, auth()->id(), provisional: true);
+
+ $request = Mockery::mock(ElementRequest::class);
+ $request->shouldReceive('element')
+ ->once()
+ ->with([], true)
+ ->andReturn($entry);
+ $request->shouldReceive('user')
+ ->once()
+ ->andReturn(auth()->user());
+
+ app()->instance('request', Request::create('/actions/elements/ensure-draft', 'POST', [], [], [], [
+ 'HTTP_ACCEPT' => 'application/json',
+ ]));
+
+ $response = app()->make(ElementDraftsController::class, [
+ 'request' => $request,
+ ])->ensure();
+
+ $payload = json_decode($response->getContent(), true, flags: JSON_THROW_ON_ERROR);
+
+ expect($response->getStatusCode())->toBe(200)
+ ->and($payload['elementId'])->toBe($draft->id)
+ ->and(
+ Entry::find()
+ ->drafts()
+ ->provisionalDrafts()
+ ->draftOf($entry->id)
+ ->draftCreator(auth()->id())
+ ->status(null)
+ ->count()
+ )->toBe(1);
+ });
+
+ it('creates a provisional draft when ensuring a canonical element', function () {
+ $entry = EntryModel::factory()->createElement([
+ 'title' => 'Canonical Title',
+ 'slug' => 'canonical-title',
+ ]);
+
+ $response = postJson(action([ElementDraftsController::class, 'ensure']), [
+ 'elementType' => Entry::class,
+ 'elementId' => $entry->id,
+ 'siteId' => $entry->siteId,
+ ]);
+
+ $response->assertOk();
+
+ /** @var Entry $draft */
+ $draft = Entry::find()
+ ->id($response->json('elementId'))
+ ->drafts()
+ ->provisionalDrafts()
+ ->status(null)
+ ->one();
+
+ expect($draft)->not->toBeNull()
+ ->and($draft->id)->not->toBe($entry->id)
+ ->and($draft->getCanonicalId())->toBe($entry->id)
+ ->and($draft->isProvisionalDraft)->toBeTrue()
+ ->and($draft->draftCreatorId)->toBe(auth()->id());
+ });
+});
+
+describe('store', function () {
+ it('requires authentication', function () {
+ auth()->logout();
+
+ postJson(action([ElementDraftsController::class, 'store']))->assertUnauthorized();
+ });
+
+ it('returns 400 when no element is identified by the request', function () {
+ postJson(action([ElementDraftsController::class, 'store']), [
+ 'elementType' => Entry::class,
+ 'siteId' => Sites::getPrimarySite()->id,
+ ])->assertBadRequest();
+ });
+
+ it('returns 400 for revisions', function () {
+ $entry = EntryModel::factory()->createElement([
+ 'title' => 'Canonical Title',
+ ]);
+ /** @var Entry $revision */
+ $revision = ElementsFacade::getElementById(app(Revisions::class)->createRevision($entry, auth()->id()));
+
+ postJson(action([ElementDraftsController::class, 'store']), [
+ 'elementType' => Entry::class,
+ 'elementId' => $entry->id,
+ 'revisionId' => $revision->revisionId,
+ 'siteId' => $entry->siteId,
+ ])->assertBadRequest();
+ });
+
+ it('creates a draft from a canonical element and authorizes previewing it', function () {
+ $entry = EntryModel::factory()->createElement([
+ 'title' => 'Canonical Title',
+ 'slug' => 'canonical-title',
+ ]);
+
+ $response = postJson(
+ action([ElementDraftsController::class, 'store']),
+ elementDraftsControllerPayload($entry),
+ );
+
+ $response->assertOk()
+ ->assertJson(fn (AssertableJson $json) => $json
+ ->where('message', t('{type} saved.', ['type' => t('Draft')]))
+ ->where('canonicalId', $entry->id)
+ ->where('draftName', 'Ported Draft')
+ ->where('draftNotes', 'Ported draft notes')
+ ->where('creator', auth()->user()->getName())
+ ->etc()
+ );
+
+ /** @var Entry $draft */
+ $draft = Entry::find()
+ ->id($response->json('elementId'))
+ ->drafts()
+ ->status(null)
+ ->one();
+
+ expect($draft)->not->toBeNull()
+ ->and($draft->getCanonicalId())->toBe($entry->id)
+ ->and($draft->title)->toBe('Ported Draft Title')
+ ->and($draft->slug)->toBe('ported-draft-title')
+ ->and($draft->draftName)->toBe('Ported Draft')
+ ->and($draft->draftNotes)->toBe('Ported draft notes')
+ ->and(SessionAuth::checkAuthorization("previewDraft:$draft->draftId"))->toBeTrue();
+
+ expect($response->json('draftElementIds'))->toMatchArray([
+ (string) $entry->id => $draft->id,
+ ])->and($response->json('draftElementUids'))->toMatchArray([
+ $entry->uid => $draft->uid,
+ ]);
+ });
+
+ it('updates an existing draft', function () {
+ $entry = EntryModel::factory()->createElement([
+ 'title' => 'Canonical Title',
+ 'slug' => 'canonical-title',
+ ]);
+ /** @var Entry $draft */
+ $draft = app(Drafts::class)->createDraft($entry, auth()->id(), name: 'Existing Draft');
+
+ postJson(action([ElementDraftsController::class, 'store']), [
+ 'elementType' => Entry::class,
+ 'draftId' => $draft->draftId,
+ 'siteId' => $draft->siteId,
+ 'title' => 'Updated Draft Title',
+ 'slug' => 'updated-draft-title',
+ 'draftName' => 'Renamed Draft',
+ 'notes' => 'Updated draft notes',
+ ])->assertOk()
+ ->assertJson(fn (AssertableJson $json) => $json
+ ->where('elementId', $draft->id)
+ ->where('draftId', $draft->draftId)
+ ->where('draftName', 'Renamed Draft')
+ ->where('draftNotes', 'Updated draft notes')
+ ->etc()
+ );
+
+ /** @var Entry $updatedDraft */
+ $updatedDraft = Entry::find()
+ ->draftId($draft->draftId)
+ ->siteId($draft->siteId)
+ ->status(null)
+ ->one();
+
+ expect($updatedDraft->title)->toBe('Updated Draft Title')
+ ->and($updatedDraft->slug)->toBe('updated-draft-title')
+ ->and($updatedDraft->draftName)->toBe('Renamed Draft')
+ ->and($updatedDraft->draftNotes)->toBe('Updated draft notes');
+ });
+
+ it('overwrites an existing provisional draft for the same element and user', function () {
+ $entry = EntryModel::factory()->createElement([
+ 'title' => 'Canonical Title',
+ ]);
+ /** @var Entry $existingDraft */
+ $existingDraft = app(Drafts::class)->createDraft($entry, auth()->id(), provisional: true);
+ $existingDraft->title = 'Existing provisional title';
+ ElementsFacade::saveElement($existingDraft);
+
+ $response = postJson(
+ action([ElementDraftsController::class, 'store']),
+ elementDraftsControllerPayload($entry, [
+ 'provisional' => true,
+ 'title' => 'Replacement provisional title',
+ 'slug' => 'replacement-provisional-title',
+ ]),
+ );
+
+ $response->assertOk();
+
+ /** @var Entry $replacementDraft */
+ $replacementDraft = Entry::find()
+ ->id($response->json('elementId'))
+ ->drafts()
+ ->provisionalDrafts()
+ ->status(null)
+ ->one();
+
+ expect($replacementDraft)->not->toBeNull()
+ ->and($replacementDraft->id)->not->toBe($existingDraft->id)
+ ->and($replacementDraft->title)->toBe('Replacement provisional title')
+ ->and($replacementDraft->isProvisionalDraft)->toBeTrue()
+ ->and(Entry::find()->id($existingDraft->id)->drafts()->status(null)->one())->toBeNull()
+ ->and(
+ Entry::find()
+ ->drafts()
+ ->provisionalDrafts()
+ ->draftOf($entry->id)
+ ->draftCreator(auth()->id())
+ ->status(null)
+ ->count()
+ )->toBe(1);
+ });
+
+ it('drops provisional status when requested', function () {
+ $entry = EntryModel::factory()->createElement([
+ 'title' => 'Canonical Title',
+ ]);
+ /** @var Entry $draft */
+ $draft = app(Drafts::class)->createDraft($entry, auth()->id(), provisional: true);
+
+ postJson(action([ElementDraftsController::class, 'store']), [
+ 'elementType' => Entry::class,
+ 'draftId' => $draft->draftId,
+ 'siteId' => $draft->siteId,
+ 'dropProvisional' => true,
+ 'title' => 'Saved Draft Title',
+ ])->assertOk();
+
+ /** @var Entry $savedDraft */
+ $savedDraft = Entry::find()
+ ->draftId($draft->draftId)
+ ->siteId($draft->siteId)
+ ->status(null)
+ ->one();
+
+ expect($savedDraft->isProvisionalDraft)->toBeFalse()
+ ->and($savedDraft->title)->toBe('Saved Draft Title');
+ });
+
+ it('includes cp editor payload fields on the control panel action route', function () {
+ $entry = EntryModel::factory()->createElement([
+ 'title' => 'Canonical Title',
+ 'slug' => 'canonical-title',
+ ]);
+
+ postJson(
+ cp_url('actions/elements/save-draft'),
+ elementDraftsControllerPayload($entry, [
+ 'title' => 'CP Draft Title',
+ 'slug' => 'cp-draft-title',
+ 'draftName' => 'CP Draft',
+ ]),
+ )->assertOk()
+ ->assertJsonPath('title', 'CP Draft Title')
+ ->assertJsonPath('docTitle', fn (string $docTitle) => str_contains($docTitle, '(CP Draft)'))
+ ->assertJsonStructure([
+ 'previewTargets',
+ 'previewParamValue',
+ 'deltaNames',
+ 'initialDeltaValues',
+ 'updatedTimestamp',
+ 'canonicalUpdatedTimestamp',
+ ]);
+ });
+
+ it('forbids saving a peer draft without save permission', function () {
+ $entryType = EntryType::factory()->create();
+ $section = Section::factory()->withEntryTypes($entryType)->create();
+ $entry = EntryModel::factory()
+ ->forSection($section)
+ ->forEntryType($entryType)
+ ->createElement([
+ 'title' => 'Canonical Title',
+ ]);
+ /** @var Entry $draft */
+ $draft = app(Drafts::class)->createDraft($entry, auth()->id(), name: 'Peer Draft');
+
+ $viewer = UserModel::factory()
+ ->withPermissions([
+ 'accessCp',
+ sprintf('editSite:%s', Sites::getPrimarySite()->uid),
+ sprintf('viewEntries:%s', $section->uid),
+ sprintf('viewPeerEntryDrafts:%s', $section->uid),
+ ])
+ ->createElement();
+
+ actingAs($viewer);
+
+ postJson(action([ElementDraftsController::class, 'store']), [
+ 'elementType' => Entry::class,
+ 'draftId' => $draft->draftId,
+ 'siteId' => $draft->siteId,
+ 'title' => 'Unauthorized Update',
+ ])->assertForbidden();
+ });
+
+ it('returns any response resolved by the element request', function () {
+ $entry = EntryModel::factory()->createElement([
+ 'title' => 'Canonical Title',
+ 'slug' => 'canonical-title',
+ ]);
+
+ post(action([ElementDraftsController::class, 'store']), [
+ 'elementId' => $entry->id,
+ 'draftId' => 999999,
+ 'siteId' => $entry->siteId,
+ ])->assertRedirect($entry->getCpEditUrl());
+ });
+
+ it('returns a failure response when saving the draft fails', function () {
+ $entry = EntryModel::factory()->createElement([
+ 'title' => 'Canonical Title',
+ ]);
+ /** @var Entry $draft */
+ $draft = app(Drafts::class)->createDraft($entry, auth()->id(), name: 'Failing Draft');
+
+ Event::listen(BeforeSave::class, function (BeforeSave $event) use ($draft) {
+ if ($event->element->id === $draft->id) {
+ $event->isValid = false;
+ }
+ });
+
+ postJson(action([ElementDraftsController::class, 'store']), [
+ 'elementType' => Entry::class,
+ 'draftId' => $draft->draftId,
+ 'siteId' => $draft->siteId,
+ ])->assertStatus(400)
+ ->assertJsonPath('message', t('Couldn’t save {type}.', ['type' => t('draft')]));
+ });
+
+ it('rechecks save authorization after applying request params', function () {
+ $entry = EntryModel::factory()->createElement([
+ 'title' => 'Canonical Title',
+ ]);
+ /** @var Entry $draft */
+ $draft = app(Drafts::class)->createDraft($entry, auth()->id(), name: 'Guarded Draft');
+
+ $request = ElementRequest::create('/actions/elements/save-draft', 'POST', [
+ 'elementType' => Entry::class,
+ 'draftId' => $draft->draftId,
+ 'siteId' => $draft->siteId,
+ ]);
+ $request->setUserResolver(fn () => auth()->user());
+ app()->instance('request', $request);
+
+ $controller = new class($request, app(Drafts::class), app(Elements::class), app(ElementActivity::class)) extends ElementDraftsController
+ {
+ private int $canSaveCalls = 0;
+
+ protected function applyParamsToElement(ElementInterface $element): void {}
+
+ protected function canSave(ElementInterface $element, User $user): bool
+ {
+ return ++$this->canSaveCalls === 1;
+ }
+ };
+
+ expect(fn () => $controller->store())
+ ->toThrow(HttpException::class, 'User not authorized to save this element.');
+ });
+});
+
+describe('apply', function () {
+ it('requires authentication', function () {
+ auth()->logout();
+
+ postJson(action([ElementDraftsController::class, 'apply']))->assertUnauthorized();
+ });
+
+ it('returns 400 when no draft is identified by the request', function () {
+ $entry = EntryModel::factory()->createElement([
+ 'title' => 'Canonical Title',
+ ]);
+
+ postJson(action([ElementDraftsController::class, 'apply']), [
+ 'elementType' => Entry::class,
+ 'elementId' => $entry->id,
+ 'siteId' => $entry->siteId,
+ ])->assertBadRequest();
+ });
+
+ it('returns any response resolved by the element request', function () {
+ $entry = EntryModel::factory()->createElement([
+ 'title' => 'Canonical Title',
+ 'slug' => 'canonical-title',
+ ]);
+
+ post(action([ElementDraftsController::class, 'apply']), [
+ 'elementId' => $entry->id,
+ 'draftId' => 999999,
+ 'siteId' => $entry->siteId,
+ ])->assertRedirect($entry->getCpEditUrl());
+ });
+
+ it('applies a draft to its canonical element', function () {
+ $entry = EntryModel::factory()->createElement([
+ 'title' => 'Canonical Title',
+ 'slug' => 'canonical-title',
+ ]);
+ /** @var Entry $draft */
+ $draft = app(Drafts::class)->createDraft($entry, auth()->id(), name: 'Existing Draft');
+
+ postJson(action([ElementDraftsController::class, 'apply']), [
+ 'elementType' => Entry::class,
+ 'draftId' => $draft->draftId,
+ 'siteId' => $draft->siteId,
+ 'title' => 'Applied Draft Title',
+ 'slug' => 'applied-draft-title',
+ ])->assertOk()
+ ->assertJsonPath('message', t('Draft applied.'));
+
+ /** @var Entry $canonical */
+ $canonical = Entry::find()
+ ->id($entry->id)
+ ->status(null)
+ ->one();
+
+ expect($canonical->title)->toBe('Applied Draft Title')
+ ->and($canonical->slug)->toBe('applied-draft-title')
+ ->and(Entry::find()->draftId($draft->draftId)->status(null)->one())->toBeNull();
+ });
+
+ it('forbids applying a draft when the user cannot save the canonical element', function () {
+ $entryType = EntryType::factory()->create();
+ $section = Section::factory()->withEntryTypes($entryType)->create();
+ $entry = EntryModel::factory()
+ ->forSection($section)
+ ->forEntryType($entryType)
+ ->createElement([
+ 'title' => 'Canonical Title',
+ ]);
+
+ $viewer = UserModel::factory()
+ ->withPermissions([
+ 'accessCp',
+ sprintf('editSite:%s', Sites::getPrimarySite()->uid),
+ sprintf('viewEntries:%s', $section->uid),
+ ])
+ ->createElement();
+
+ /** @var Entry $draft */
+ $draft = app(Drafts::class)->createDraft($entry, $viewer->id, name: 'Viewer Draft');
+
+ actingAs($viewer);
+
+ postJson(action([ElementDraftsController::class, 'apply']), [
+ 'elementType' => Entry::class,
+ 'draftId' => $draft->draftId,
+ 'siteId' => $draft->siteId,
+ 'title' => 'Unauthorized Apply',
+ ])->assertForbidden();
+ });
+
+ it('returns a failure response and preserves the draft when applying fails validation', function () {
+ $entry = EntryModel::factory()->createElement([
+ 'title' => 'Canonical Title',
+ 'slug' => 'canonical-title',
+ ]);
+ /** @var Entry $draft */
+ $draft = app(Drafts::class)->createDraft($entry, auth()->id(), name: 'Failing Draft');
+
+ $failedSave = false;
+
+ Event::listen(BeforeSave::class, function (BeforeSave $event) use ($draft, &$failedSave) {
+ if ($event->element->id === $draft->id && ! $failedSave) {
+ $event->isValid = false;
+ $failedSave = true;
+ }
+ });
+
+ postJson(action([ElementDraftsController::class, 'apply']), [
+ 'elementType' => Entry::class,
+ 'draftId' => $draft->draftId,
+ 'siteId' => $draft->siteId,
+ 'title' => 'Failed Apply Title',
+ 'slug' => 'failed-apply-title',
+ ])->assertBadRequest()
+ ->assertJsonPath('message', t('Couldn’t apply draft.'));
+
+ /** @var Entry $savedDraft */
+ $savedDraft = Entry::find()
+ ->draftId($draft->draftId)
+ ->siteId($draft->siteId)
+ ->status(null)
+ ->one();
+
+ /** @var Entry $canonical */
+ $canonical = Entry::find()
+ ->id($entry->id)
+ ->status(null)
+ ->one();
+
+ expect($savedDraft)->not->toBeNull()
+ ->and($savedDraft->title)->toBe('Failed Apply Title')
+ ->and($savedDraft->slug)->toBe('failed-apply-title')
+ ->and($canonical->title)->toBe('Canonical Title')
+ ->and($canonical->slug)->toBe('canonical-title');
+ });
+});
+
+describe('destroy', function () {
+ it('returns any response resolved by the element request', function () {
+ $entry = EntryModel::factory()->createElement([
+ 'title' => 'Canonical Title',
+ 'slug' => 'canonical-title',
+ ]);
+
+ post(action([ElementDraftsController::class, 'destroy']), [
+ 'elementId' => $entry->id,
+ 'draftId' => 999999,
+ 'siteId' => $entry->siteId,
+ ])->assertRedirect($entry->getCpEditUrl());
+ });
+
+ it('returns 400 when no draft is identified by the request', function () {
+ $entry = EntryModel::factory()->createElement([
+ 'title' => 'Canonical Title',
+ ]);
+
+ postJson(action([ElementDraftsController::class, 'destroy']), [
+ 'elementId' => $entry->id,
+ 'siteId' => $entry->siteId,
+ ])->assertBadRequest();
+ });
+
+ it('deletes a draft', function () {
+ $entry = EntryModel::factory()->createElement([
+ 'title' => 'Canonical Title',
+ ]);
+ /** @var Entry $draft */
+ $draft = app(Drafts::class)->createDraft($entry, auth()->id(), name: 'Disposable Draft');
+
+ $response = postJson(action([ElementDraftsController::class, 'destroy']), [
+ 'elementType' => Entry::class,
+ 'draftId' => $draft->draftId,
+ 'siteId' => $draft->siteId,
+ ]);
+
+ $response->assertOk()
+ ->assertJsonPath('message', t('{type} deleted.', ['type' => t('Draft')]));
+
+ expect(Entry::find()->draftId($draft->draftId)->status(null)->one())->toBeNull();
+ });
+
+ it('discards provisional draft changes', function () {
+ $entry = EntryModel::factory()->createElement([
+ 'title' => 'Canonical Title',
+ ]);
+ /** @var Entry $draft */
+ $draft = app(Drafts::class)->createDraft($entry, auth()->id(), provisional: true);
+
+ $response = postJson(action([ElementDraftsController::class, 'destroy']), [
+ 'elementType' => Entry::class,
+ 'draftId' => $draft->draftId,
+ 'siteId' => $draft->siteId,
+ ]);
+
+ $response->assertOk()
+ ->assertJsonPath('message', t('Changes discarded.'));
+
+ expect(
+ Entry::find()
+ ->drafts()
+ ->provisionalDrafts()
+ ->draftOf($entry->id)
+ ->draftCreator(auth()->id())
+ ->status(null)
+ ->one()
+ )->toBeNull();
+ });
+
+ it('returns a failure response when deleting the draft fails', function () {
+ $entry = EntryModel::factory()->createElement([
+ 'title' => 'Canonical Title',
+ ]);
+ /** @var Entry $draft */
+ $draft = app(Drafts::class)->createDraft($entry, auth()->id(), name: 'Undeletable Draft');
+
+ Event::listen(BeforeDelete::class, function (BeforeDelete $event) use ($draft) {
+ if ($event->element->id === $draft->id) {
+ $event->isValid = false;
+ }
+ });
+
+ postJson(action([ElementDraftsController::class, 'destroy']), [
+ 'elementType' => Entry::class,
+ 'draftId' => $draft->draftId,
+ 'siteId' => $draft->siteId,
+ ])->assertStatus(400)
+ ->assertJsonPath('message', t('Couldn’t delete {type}.', ['type' => t('draft')]));
+
+ expect(Entry::find()->draftId($draft->draftId)->status(null)->one())->not->toBeNull();
+ });
+});
diff --git a/tests/Feature/Http/Controllers/Elements/ElementIndexControllerTest.php b/tests/Feature/Http/Controllers/Elements/ElementIndexControllerTest.php
new file mode 100644
index 00000000000..e3526aba24a
--- /dev/null
+++ b/tests/Feature/Http/Controllers/Elements/ElementIndexControllerTest.php
@@ -0,0 +1,163 @@
+postIndexAction = fn (string $path, array $payload = []) => postJson(
+ action([ElementIndexController::class, match ($path) {
+ 'get-elements' => 'getElements',
+ 'get-more-elements' => 'getMoreElements',
+ 'count-elements' => 'countElements',
+ 'filter-hud' => 'filterHud',
+ 'element-table-html' => 'elementTableHtml',
+ }]),
+ array_merge([
+ 'context' => ElementSources::CONTEXT_INDEX,
+ 'elementType' => Entry::class,
+ 'source' => '*',
+ 'viewState' => [
+ 'mode' => 'table',
+ 'static' => false,
+ ],
+ ], $payload),
+ [
+ 'Accept' => 'application/json',
+ ],
+ );
+});
+
+it('requires authentication for get-elements', function () {
+ auth()->logout();
+
+ postJson(action([ElementIndexController::class, 'getElements']), [
+ 'elementType' => Entry::class,
+ ])->assertUnauthorized();
+});
+
+it('returns element HTML and action metadata for get-elements', function () {
+ EntryModel::factory()->count(2)->create();
+
+ ($this->postIndexAction)('get-elements')->assertOk()
+ ->assertJsonStructure([
+ 'html',
+ 'headHtml',
+ 'bodyHtml',
+ 'actionsHeadHtml',
+ 'actionsBodyHtml',
+ 'exporters',
+ ]);
+});
+
+it('omits action metadata for get-more-elements', function () {
+ EntryModel::factory()->count(2)->create();
+
+ ($this->postIndexAction)('get-more-elements')->assertOk()
+ ->assertJsonMissingPath('actions')
+ ->assertJsonMissingPath('actionsHeadHtml')
+ ->assertJsonMissingPath('actionsBodyHtml')
+ ->assertJsonMissingPath('exporters')
+ ->assertJsonStructure([
+ 'html',
+ ]);
+});
+
+it('returns different filtered and unfiltered counts when filters are applied', function () {
+ EntryModel::factory()->count(2)->create();
+
+ $entry = Entry::find()->status(null)->orderBy('elements.id')->first();
+
+ ($this->postIndexAction)('count-elements', [
+ 'criteria' => [
+ 'id' => [$entry->id],
+ ],
+ 'resultSet' => 'filtered',
+ ])->assertOk()
+ ->assertJsonPath('resultSet', 'filtered')
+ ->assertJsonPath('total', 1)
+ ->assertJsonPath('unfilteredTotal', 2);
+});
+
+it('accepts the embedded index context for element index routes', function () {
+ EntryModel::factory()->create();
+
+ ($this->postIndexAction)('get-elements', [
+ 'context' => ElementSources::CONTEXT_EMBEDDED_INDEX,
+ ])->assertOk();
+});
+
+it('returns filter hud html with asset payloads', function () {
+ ($this->postIndexAction)('filter-hud', [
+ 'id' => 'filters',
+ 'conditionConfig' => [
+ 'class' => ElementCondition::class,
+ 'elementType' => Entry::class,
+ ],
+ ])->assertOk()
+ ->assertJsonPath('hudHtml', fn (string $html) => str_contains($html, 'condition-container'))
+ ->assertJsonStructure([
+ 'headHtml',
+ 'bodyHtml',
+ ]);
+});
+
+it('prefers the current users provisional draft for element table html', function () {
+ $entry = EntryModel::factory()->createElement([
+ 'title' => 'Canonical Title',
+ ]);
+
+ $draft = app(Drafts::class)->createDraft($entry, auth()->id(), provisional: true);
+ $draft->title = 'Draft Title';
+ Elements::saveElement($draft);
+
+ postJson(action([ElementIndexController::class, 'elementTableHtml']), [
+ 'context' => ElementSources::CONTEXT_INDEX,
+ 'elementType' => Entry::class,
+ 'source' => '*',
+ 'id' => $entry->id,
+ 'viewState' => [
+ 'mode' => 'table',
+ 'tableColumns' => ['title'],
+ ],
+ ], [
+ 'Accept' => 'application/json',
+ ])->assertOk()
+ ->assertJsonPath('attributeHtml.title', fn (string $html) => str_contains($html, 'Draft Title'));
+});
+
+it('preserves the legacy action route contract for get-elements', function () {
+ EntryModel::factory()->create();
+
+ postJson('/'.implode('/', array_filter([
+ Cms::config()->cpTrigger,
+ Cms::config()->actionTrigger,
+ 'element-indexes/get-elements',
+ ])), [
+ 'context' => ElementSources::CONTEXT_INDEX,
+ 'elementType' => Entry::class,
+ 'source' => '*',
+ 'viewState' => [
+ 'mode' => 'table',
+ 'static' => false,
+ ],
+ ], [
+ 'Accept' => 'application/json',
+ ])->assertOk()
+ ->assertJsonStructure([
+ 'html',
+ ]);
+});
diff --git a/tests/Feature/Http/Controllers/Elements/ElementIndexSourcesControllerTest.php b/tests/Feature/Http/Controllers/Elements/ElementIndexSourcesControllerTest.php
new file mode 100644
index 00000000000..f057fd980fb
--- /dev/null
+++ b/tests/Feature/Http/Controllers/Elements/ElementIndexSourcesControllerTest.php
@@ -0,0 +1,82 @@
+postIndexSourceAction = fn (string $path, array $payload = []) => postJson(
+ action([ElementIndexSourcesController::class, match ($path) {
+ 'source-path' => 'sourcePath',
+ 'source-attribute-info' => 'sourceAttributeInfo',
+ 'get-source-tree-html' => 'getSourceTreeHtml',
+ }]),
+ array_merge([
+ 'context' => ElementSources::CONTEXT_INDEX,
+ 'elementType' => Entry::class,
+ 'source' => '*',
+ 'viewState' => [
+ 'mode' => 'table',
+ 'static' => false,
+ ],
+ ], $payload),
+ [
+ 'Accept' => 'application/json',
+ ],
+ );
+});
+
+it('returns source attribute info for the selected source', function () {
+ $response = ($this->postIndexSourceAction)('source-attribute-info');
+
+ $response->assertOk()
+ ->assertJsonStructure([
+ 'sortOptions',
+ 'tableColumns',
+ 'defaultTableColumns',
+ ]);
+
+ expect($response->json('sortOptions'))->toBeArray()
+ ->and($response->json('defaultTableColumns'))->toContain('status');
+});
+
+it('returns source path info for asset folder steps', function () {
+ config()->set('filesystems.disks.test-disk', [
+ 'driver' => 'local',
+ 'root' => storage_path('framework/testing/element-index-controller-test/test-disk'),
+ ]);
+
+ $volume = Volume::factory()->create(['fs' => 'disk:test-disk']);
+ $folder = VolumeFolder::factory()->create([
+ 'volumeId' => $volume->id,
+ 'name' => 'Docs',
+ 'path' => 'docs/',
+ ]);
+
+ postJson(action([ElementIndexSourcesController::class, 'sourcePath']), [
+ 'context' => ElementSources::CONTEXT_INDEX,
+ 'elementType' => Asset::class,
+ 'source' => "volume:$volume->uid",
+ 'stepKey' => "folder:$folder->uid",
+ ], [
+ 'Accept' => 'application/json',
+ ])->assertOk()
+ ->assertJsonPath('sourcePath.0.key', "volume:$volume->uid")
+ ->assertJsonPath('sourcePath.0.folderId', $folder->id);
+});
+
+it('returns source tree html', function () {
+ ($this->postIndexSourceAction)('get-source-tree-html')->assertOk()
+ ->assertJsonPath('html', fn (string $html) => str_contains($html, 'sources-list'));
+});
diff --git a/tests/Feature/Http/Controllers/Elements/ElementRedirectControllerTest.php b/tests/Feature/Http/Controllers/Elements/ElementRedirectControllerTest.php
new file mode 100644
index 00000000000..b8af06b4617
--- /dev/null
+++ b/tests/Feature/Http/Controllers/Elements/ElementRedirectControllerTest.php
@@ -0,0 +1,187 @@
+edition = Edition::get();
+ $this->tempAssetUploadFs = Cms::config()->tempAssetUploadFs;
+ $this->entryType = EntryType::factory()->create();
+ $this->section = Section::factory()->withEntryTypes($this->entryType)->create([
+ 'handle' => 'news',
+ ]);
+});
+
+afterEach(function () {
+ Edition::set($this->edition);
+ Cms::config()->tempAssetUploadFs = $this->tempAssetUploadFs;
+});
+
+it('returns redirect responses returned by the element request for id routes', function () {
+ $entry = EntryModel::factory()
+ ->forSection($this->section)
+ ->forEntryType($this->entryType)
+ ->createElement([
+ 'title' => 'Canonical Title',
+ 'slug' => 'canonical-title',
+ ]);
+
+ get(cp_url("edit/$entry->id-$entry->slug")."?draftId=999999&siteId=$entry->siteId")
+ ->assertRedirect($entry->getCpEditUrl());
+});
+
+it('returns redirect responses returned by the element request for uuid routes', function () {
+ $entry = EntryModel::factory()
+ ->forSection($this->section)
+ ->forEntryType($this->entryType)
+ ->createElement([
+ 'title' => 'Canonical Title',
+ 'slug' => 'canonical-title',
+ ]);
+
+ get(cp_url("edit/$entry->uid")."?draftId=999999&siteId=$entry->siteId")
+ ->assertRedirect($entry->getCpEditUrl());
+});
+
+it('redirects to non-standard control panel edit urls', function () {
+ $entry = EntryModel::factory()
+ ->forSection($this->section)
+ ->forEntryType($this->entryType)
+ ->createElement([
+ 'title' => 'Canonical Title',
+ 'slug' => 'canonical-title',
+ ]);
+
+ get(cp_url("edit/$entry->id-$entry->slug"))
+ ->assertRedirect($entry->getCpEditUrl());
+});
+
+it('aborts when the element has no control panel edit url', function () {
+ config()->set('filesystems.disks.element-redirect-temp-disk', [
+ 'driver' => 'local',
+ 'root' => storage_path('framework/testing/element-redirect-controller/temp-disk'),
+ ]);
+ Cms::config()->tempAssetUploadFs = 'disk:element-redirect-temp-disk';
+
+ $volume = Volume::factory()->create(['fs' => 'disk:element-redirect-temp-disk']);
+ $folder = VolumeFolderModel::factory()->create(['volumeId' => $volume->id]);
+ $asset = AssetModel::factory()->createElement([
+ 'volumeId' => $volume->id,
+ 'folderId' => $folder->id,
+ 'filename' => 'temp-file.jpg',
+ 'uploaderId' => auth()->id(),
+ ]);
+
+ $this->withoutExceptionHandling();
+
+ expect(fn () => get(cp_url("edit/$asset->id-test")))
+ ->toThrow(HttpException::class, 'The element doesn’t have an edit page.');
+});
+
+it('returns inline edit responses for standard control panel edit urls', function () {
+ $innerField = Field::factory()->create([
+ 'name' => 'Inner Text',
+ 'handle' => 'innerText',
+ 'type' => PlainText::class,
+ ]);
+
+ $matrixEntryType = EntryType::factory()
+ ->withField($innerField)
+ ->create([
+ 'name' => 'Matrix Block',
+ 'handle' => 'matrixBlock',
+ 'hasTitleField' => true,
+ ]);
+
+ $matrixField = Field::factory()->create([
+ 'name' => 'Matrix Field',
+ 'handle' => 'matrixField',
+ 'type' => Matrix::class,
+ 'settings' => ['entryTypes' => [$matrixEntryType->id]],
+ ]);
+
+ $ownerType = EntryType::factory()
+ ->withField($matrixField)
+ ->create([
+ 'name' => 'Owner',
+ 'handle' => 'owner',
+ 'hasTitleField' => true,
+ ]);
+
+ $section = Section::factory()
+ ->withEntryTypes($ownerType)
+ ->create([
+ 'handle' => 'owners',
+ ]);
+
+ $owner = EntryModel::factory()
+ ->forSection($section)
+ ->forEntryType($ownerType)
+ ->createElement([
+ 'title' => 'Owner Entry',
+ 'slug' => 'owner-entry',
+ ]);
+
+ EntryTypesFacade::refreshEntryTypes();
+ FieldsFacade::invalidateCaches();
+ FieldsFacade::refreshFields();
+
+ $matrixField = FieldsFacade::getFieldById($matrixField->id);
+ /** @var Entry $owner */
+ $owner = Entry::find()->id($owner->id)->status(null)->one();
+
+ $blockUid = fake()->uuid();
+ $owner->setFieldValueFromRequest('matrixField', [
+ 'entries' => [
+ "uid:$blockUid" => [
+ 'type' => $matrixEntryType->handle,
+ 'title' => 'Inline Block',
+ 'enabled' => true,
+ 'fields' => [
+ 'innerText' => 'Inline block content',
+ ],
+ ],
+ ],
+ 'sortOrder' => [$blockUid],
+ ]);
+
+ expect(Elements::saveElement($owner))->toBeTrue();
+
+ /** @var Entry $entry */
+ $entry = Entry::find()
+ ->fieldId($matrixField->id)
+ ->ownerId($owner->id)
+ ->siteId($owner->siteId)
+ ->status(null)
+ ->one();
+
+ expect($entry->getCpEditUrl())->toStartWith(cp_url('edit'));
+
+ get(cp_url("edit/$entry->id-$entry->slug"))
+ ->assertOk()
+ ->assertSeeText('Inline Block')
+ ->assertSee('elements/save', false);
+});
diff --git a/tests/Feature/Http/Controllers/Elements/ElementRevisionsControllerTest.php b/tests/Feature/Http/Controllers/Elements/ElementRevisionsControllerTest.php
new file mode 100644
index 00000000000..5ba9e5d9b27
--- /dev/null
+++ b/tests/Feature/Http/Controllers/Elements/ElementRevisionsControllerTest.php
@@ -0,0 +1,236 @@
+entryType = EntryType::factory()->create();
+ $this->section = Section::factory()->withEntryTypes($this->entryType)->create([
+ 'handle' => 'blog',
+ 'enableVersioning' => true,
+ ]);
+});
+
+dataset('elementRevisionRoutes', [
+ 'generic revisions route' => [
+ fn (Entry $entry) => action([ElementRevisionsController::class, 'index'], [
+ 'id' => $entry->id,
+ 'slug' => "-$entry->slug",
+ ]),
+ ],
+ 'entries revisions route' => [
+ fn (Entry $entry) => cp_url(sprintf(
+ 'entries/%s/%d-%s/revisions',
+ $entry->getSection()->handle,
+ $entry->id,
+ $entry->slug,
+ )),
+ ],
+ 'content revisions route' => [
+ fn (Entry $entry) => cp_url(sprintf(
+ '%s/%d-%s/revisions',
+ $entry->getSection()->getCpIndexUri(),
+ $entry->id,
+ $entry->slug,
+ )),
+ ],
+]);
+
+describe('index', function () {
+ it('requires login for each control panel revisions route', function (Closure $route) {
+ $entry = EntryModel::factory()
+ ->forSection($this->section)
+ ->forEntryType($this->entryType)
+ ->createElement([
+ 'title' => 'My Entry',
+ 'slug' => 'my-entry',
+ ]);
+
+ Auth::logout();
+
+ get($route($entry))->assertRedirectContains('login');
+ })->with('elementRevisionRoutes');
+
+ it('renders the revisions screen for each control panel revisions route', function (Closure $route) {
+ $timestamp = now()->startOfMinute();
+ Date::setTestNow($timestamp);
+
+ $entry = EntryModel::factory()
+ ->forSection($this->section)
+ ->forEntryType($this->entryType)
+ ->createElement([
+ 'title' => 'Current Title',
+ 'slug' => 'current-title',
+ ]);
+
+ app(Revisions::class)->createRevision($entry, auth()->id(), 'Initial notes');
+
+ Date::setTestNow($timestamp->copy()->addMinutes(5));
+
+ $entry->title = 'Updated Title';
+ Elements::saveElement($entry);
+
+ Date::setTestNow();
+
+ get($route($entry))
+ ->assertOk()
+ ->assertSeeText('Revisions for')
+ ->assertSeeText('Updated Title')
+ ->assertSee('id="revisions"', false)
+ ->assertSeeText('Revision 1')
+ ->assertSeeText('Initial notes');
+ })->with('elementRevisionRoutes');
+
+ it('returns 400 when the element type does not support revisions', function () {
+ $entryType = EntryType::factory()->create();
+ $section = Section::factory()->withEntryTypes($entryType)->create([
+ 'handle' => 'plain',
+ 'enableVersioning' => false,
+ ]);
+
+ $entry = EntryModel::factory()
+ ->forSection($section)
+ ->forEntryType($entryType)
+ ->createElement([
+ 'title' => 'Plain Entry',
+ 'slug' => 'plain-entry',
+ ]);
+
+ get(cp_url("revisions/$entry->id-$entry->slug"))->assertBadRequest();
+ });
+
+ it('returns 400 for unpublished drafts', function () {
+ $draft = app(Entry::class);
+ $draft->siteId = Sites::getPrimarySite()->id;
+ $draft->sectionId = $this->section->id;
+ $draft->typeId = $this->entryType->id;
+ $draft->title = 'Unpublished Draft';
+ $draft->slug = 'unpublished-draft';
+ $draft->setAuthorIds([auth()->id()]);
+
+ app(Drafts::class)->saveElementAsDraft($draft, auth()->id(), markAsSaved: false);
+
+ get(cp_url("revisions/$draft->id-$draft->slug"))->assertBadRequest();
+ });
+});
+
+describe('revert', function () {
+ it('requires login', function () {
+ Auth::logout();
+
+ postJson(action([ElementRevisionsController::class, 'revert']))->assertUnauthorized();
+ });
+
+ it('returns 400 when no revision is identified by the request', function () {
+ $entry = EntryModel::factory()
+ ->forSection($this->section)
+ ->forEntryType($this->entryType)
+ ->createElement([
+ 'title' => 'Canonical Title',
+ 'slug' => 'canonical-title',
+ ]);
+
+ postJson(action([ElementRevisionsController::class, 'revert']), [
+ 'elementType' => Entry::class,
+ 'elementId' => $entry->id,
+ 'siteId' => $entry->siteId,
+ ])->assertBadRequest();
+ });
+
+ it('forbids reverting a revision when the user cannot save the canonical element', function () {
+ $entry = EntryModel::factory()
+ ->forSection($this->section)
+ ->forEntryType($this->entryType)
+ ->createElement([
+ 'title' => 'Canonical Title',
+ 'slug' => 'canonical-title',
+ ]);
+ /** @var Entry $revision */
+ $revision = Elements::getElementById(app(Revisions::class)->createRevision($entry, auth()->id()));
+
+ $viewer = UserModel::factory()
+ ->withPermissions([
+ 'accessCp',
+ sprintf('editSite:%s', Sites::getPrimarySite()->uid),
+ sprintf('viewEntries:%s', $this->section->uid),
+ ])
+ ->createElement();
+
+ actingAs($viewer);
+
+ postJson(action([ElementRevisionsController::class, 'revert']), [
+ 'elementType' => Entry::class,
+ 'revisionId' => $revision->revisionId,
+ 'siteId' => $revision->siteId,
+ ])->assertForbidden();
+ });
+
+ it('reverts a revision to its canonical element and tracks save activity', function () {
+ $entry = EntryModel::factory()
+ ->forSection($this->section)
+ ->forEntryType($this->entryType)
+ ->createElement([
+ 'title' => 'Original Title',
+ 'slug' => 'original-title',
+ ]);
+ /** @var Entry $revision */
+ $revision = Elements::getElementById(app(Revisions::class)->createRevision($entry, auth()->id(), 'Initial notes'));
+
+ $entry->title = 'Updated Title';
+ $entry->slug = 'updated-title';
+ Elements::saveElement($entry);
+
+ postJson(action([ElementRevisionsController::class, 'revert']), [
+ 'elementType' => Entry::class,
+ 'revisionId' => $revision->revisionId,
+ 'siteId' => $revision->siteId,
+ ])->assertOk()
+ ->assertJson(fn (AssertableJson $json) => $json
+ ->where('message', t('{type} reverted to past revision.', ['type' => Entry::displayName()]))
+ ->where('element.title', 'Original Title')
+ ->where('element.slug', 'original-title')
+ ->etc()
+ );
+
+ /** @var Entry $canonical */
+ $canonical = Entry::find()
+ ->id($entry->id)
+ ->siteId($entry->siteId)
+ ->status(null)
+ ->one();
+
+ $activity = DB::table(Table::ELEMENTACTIVITY)->first();
+
+ expect($canonical->title)->toBe('Original Title')
+ ->and($canonical->slug)->toBe('original-title')
+ ->and($activity->elementId)->toBe($entry->id)
+ ->and($activity->userId)->toBe(auth()->id())
+ ->and($activity->draftId)->toBeNull()
+ ->and($activity->type)->toBe(ElementActivityType::Save->value);
+ });
+});
diff --git a/tests/Feature/Http/Controllers/Elements/ElementSelectorModalControllerTest.php b/tests/Feature/Http/Controllers/Elements/ElementSelectorModalControllerTest.php
new file mode 100644
index 00000000000..6ad43593bec
--- /dev/null
+++ b/tests/Feature/Http/Controllers/Elements/ElementSelectorModalControllerTest.php
@@ -0,0 +1,213 @@
+elementIndexHtmlState = new stdClass;
+
+ app()->instance(ElementIndexHtml::class, new readonly class($this->elementIndexHtmlState) extends ElementIndexHtml
+ {
+ public function __construct(
+ private stdClass $state,
+ ) {}
+
+ public function html(string $elementType, array $config = []): string
+ {
+ $this->state->elementType = $elementType;
+ $this->state->config = $config;
+ $this->state->sortOptions = $elementType::sortOptions();
+
+ return 'Modal body
';
+ }
+ });
+});
+
+it('requires authentication', function () {
+ Auth::logout();
+
+ postJson(action(ElementSelectorModalController::class), [
+ 'elementType' => Entry::class,
+ ])->assertUnauthorized();
+});
+
+it('validates missing required payload', function () {
+ postJson(action(ElementSelectorModalController::class), [])
+ ->assertUnprocessable()
+ ->assertJsonValidationErrors(['elementType']);
+});
+
+it('validates invalid request payloads', function (array $payload, array $errors) {
+ postJson(action(ElementSelectorModalController::class), $payload)
+ ->assertUnprocessable()
+ ->assertJsonValidationErrors($errors);
+})->with([
+ 'invalid element type' => [[
+ 'elementType' => ElementSelectorModalController::class,
+ ], ['elementType']],
+ 'invalid show site menu' => [[
+ 'elementType' => Entry::class,
+ 'showSiteMenu' => 'auto',
+ ], ['showSiteMenu']],
+ 'invalid sources type' => [[
+ 'elementType' => Entry::class,
+ 'sources' => 'not-an-array',
+ ], ['sources']],
+ 'invalid source item type' => [[
+ 'elementType' => Entry::class,
+ 'sources' => [123],
+ ], ['sources.0']],
+ 'invalid condition type' => [[
+ 'elementType' => Entry::class,
+ 'condition' => 123,
+ ], ['condition']],
+ 'missing condition class' => [[
+ 'elementType' => Entry::class,
+ 'condition' => [],
+ ], ['condition']],
+ 'invalid reference element ids' => [[
+ 'elementType' => Entry::class,
+ 'referenceElementId' => 'invalid',
+ 'referenceElementOwnerId' => 'invalid',
+ 'referenceElementSiteId' => 'invalid',
+ ], ['referenceElementId', 'referenceElementOwnerId', 'referenceElementSiteId']],
+]);
+
+it('renders modal HTML with the expected config', function () {
+ $response = postJson(action(ElementSelectorModalController::class), [
+ 'elementType' => Entry::class,
+ 'context' => ElementSources::CONTEXT_MODAL,
+ 'showSiteMenu' => '1',
+ 'sources' => ['*', 'singles'],
+ ])
+ ->assertOk()
+ ->assertExactJson([
+ 'html' => 'Modal body
',
+ ]);
+
+ expect($response->json('html'))->toBe('Modal body
')
+ ->and($this->elementIndexHtmlState->elementType)->toBe(Entry::class)
+ ->and($this->elementIndexHtmlState->config)->toMatchArray([
+ 'class' => 'content',
+ 'context' => ElementSources::CONTEXT_MODAL,
+ 'registerJs' => false,
+ 'showSiteMenu' => '1',
+ 'showStatusMenu' => true,
+ 'sources' => ['*', 'singles'],
+ ])
+ ->and(array_keys($this->elementIndexHtmlState->config['statuses']))->toBe(array_keys(Entry::statuses()));
+});
+
+it('passes the provided context through to the element index html', function () {
+ postJson(action(ElementSelectorModalController::class), [
+ 'elementType' => Entry::class,
+ 'context' => ElementSources::CONTEXT_INDEX,
+ ])->assertOk();
+
+ expect($this->elementIndexHtmlState->config['context'])->toBe(ElementSources::CONTEXT_INDEX);
+});
+
+it('uses auto for show site menu when it is omitted', function () {
+ postJson(action(ElementSelectorModalController::class), [
+ 'elementType' => Entry::class,
+ ])->assertOk();
+
+ expect($this->elementIndexHtmlState->config['showSiteMenu'])->toBe('auto');
+});
+
+it('passes null statuses and disables the status menu for element types without statuses', function () {
+ postJson(action(ElementSelectorModalController::class), [
+ 'elementType' => Address::class,
+ ])->assertOk();
+
+ expect($this->elementIndexHtmlState->elementType)->toBe(Address::class)
+ ->and($this->elementIndexHtmlState->config['showStatusMenu'])->toBeFalse()
+ ->and($this->elementIndexHtmlState->config['statuses'])->toBeNull();
+});
+
+it('filters statuses using an in status condition rule', function () {
+ postJson(action(ElementSelectorModalController::class), [
+ 'elementType' => Entry::class,
+ 'condition' => [
+ 'class' => ElementCondition::class,
+ 'elementType' => Entry::class,
+ 'conditionRules' => [[
+ 'class' => StatusConditionRule::class,
+ 'operator' => 'in',
+ 'values' => ['live'],
+ ]],
+ ],
+ ])->assertOk();
+
+ expect($this->elementIndexHtmlState->config['statuses']->keys()->all())->toBe(['live']);
+});
+
+it('filters statuses using an excluding status condition rule', function () {
+ postJson(action(ElementSelectorModalController::class), [
+ 'elementType' => Entry::class,
+ 'condition' => [
+ 'class' => ElementCondition::class,
+ 'elementType' => Entry::class,
+ 'conditionRules' => [[
+ 'class' => StatusConditionRule::class,
+ 'operator' => 'not in',
+ 'values' => ['pending'],
+ ]],
+ ],
+ ])->assertOk();
+
+ expect($this->elementIndexHtmlState->config['statuses']->keys()->all())
+ ->toBe(array_values(array_diff(array_keys(Entry::statuses()), ['pending'])));
+});
+
+it('leaves statuses unchanged when the condition has no status rule', function () {
+ postJson(action(ElementSelectorModalController::class), [
+ 'elementType' => Entry::class,
+ 'condition' => [
+ 'class' => ElementCondition::class,
+ 'elementType' => Entry::class,
+ ],
+ ])->assertOk();
+
+ expect(array_keys($this->elementIndexHtmlState->config['statuses']))->toBe(array_keys(Entry::statuses()));
+});
+
+it('activates element index context for folder-only asset selector requests', function () {
+ config()->set('filesystems.disks.test-disk', [
+ 'driver' => 'local',
+ 'root' => storage_path('framework/testing/element-selector-modal-controller-test/test-disk'),
+ ]);
+
+ $volume = Volume::factory()->create(['fs' => 'disk:test-disk']);
+ VolumeFolderModel::factory()->create([
+ 'volumeId' => $volume->id,
+ 'name' => 'Docs',
+ 'path' => 'docs/',
+ ]);
+
+ postJson(action(ElementSelectorModalController::class), [
+ 'elementType' => Asset::class,
+ 'foldersOnly' => true,
+ ])->assertOk();
+
+ expect($this->elementIndexHtmlState->sortOptions)->toBe([
+ 'title' => 'Folder',
+ ]);
+});
diff --git a/tests/Feature/Http/Controllers/Elements/ElementSourcesControllerTest.php b/tests/Feature/Http/Controllers/Elements/ElementSourcesControllerTest.php
new file mode 100644
index 00000000000..94898baafbc
--- /dev/null
+++ b/tests/Feature/Http/Controllers/Elements/ElementSourcesControllerTest.php
@@ -0,0 +1,394 @@
+refreshFields();
+});
+
+it('returns fully normalized source customization data', function () {
+ $primarySite = Sites::getPrimarySite();
+
+ $field = Field::factory()->create([
+ 'name' => 'Preview Field',
+ 'handle' => 'previewField',
+ 'type' => Dropdown::class,
+ 'settings' => [
+ 'options' => [
+ ['label' => 'Alpha', 'value' => 'alpha'],
+ ['label' => 'Beta', 'value' => 'beta'],
+ ],
+ ],
+ ]);
+
+ FieldLayoutRecord::factory()
+ ->forField($field)
+ ->create([
+ 'type' => TestElementSourcesElement::class,
+ ]);
+
+ $userGroup = UserGroup::factory()->create([
+ 'name' => 'Editors',
+ 'handle' => 'editors',
+ 'uid' => Str::uuid()->toString(),
+ ]);
+
+ app(Fields::class)->refreshFields();
+
+ app(ProjectConfig::class)->set(sprintf('%s.%s', ProjectConfig::PATH_ELEMENT_SOURCES, TestElementSourcesElement::class), [
+ [
+ 'type' => ElementSources::TYPE_HEADING,
+ 'heading' => 'Primary Sources',
+ ],
+ [
+ 'type' => ElementSources::TYPE_NATIVE,
+ 'key' => 'structured',
+ 'defaultSort' => 'slug',
+ ],
+ [
+ 'type' => ElementSources::TYPE_NATIVE,
+ 'key' => 'fallback',
+ ],
+ [
+ 'type' => ElementSources::TYPE_CUSTOM,
+ 'key' => 'custom:existing',
+ 'label' => 'Existing Custom',
+ 'defaultSort' => ['field:'.$field->uid, 'desc'],
+ 'condition' => [
+ 'class' => ElementCondition::class,
+ 'elementType' => TestElementSourcesElement::class,
+ 'conditionRules' => [],
+ ],
+ 'sites' => [$primarySite->uid, 'missing-site'],
+ 'userGroups' => false,
+ ],
+ [
+ 'type' => ElementSources::TYPE_CUSTOM,
+ 'key' => 'custom:false-sites',
+ 'label' => 'No Sites',
+ 'sites' => false,
+ ],
+ ]);
+
+ app(ProjectConfig::class)->set(sprintf('%s.%s', ProjectConfig::PATH_ELEMENT_SOURCE_PAGES, TestElementSourcesElement::class), [
+ 'entries' => ['label' => 'Entries'],
+ ]);
+
+ $response = postJson(action([ElementSourcesController::class, 'show']), [
+ 'elementType' => TestElementSourcesElement::class,
+ ]);
+
+ $response->assertOk()
+ ->assertJson(fn (AssertableJson $json) => $json
+ ->where('multiPage', true)
+ ->where('sources.0.type', ElementSources::TYPE_HEADING)
+ ->where('sources.0.page', 'Test Elements')
+ ->where('sources.1.page', 'Test Elements')
+ ->where('sources.1.sortOptions.0.attr', 'structure')
+ ->where('sources.1.defaultSort.0', 'slug')
+ ->where('sources.1.defaultSort.1', 'asc')
+ ->where('sources.1.tableAttributes.0.0', 'slug')
+ ->where('sources.1.tableAttributes.0.1', 'Slug')
+ ->where('sources.2.defaultSort.0', 'id')
+ ->where('sources.2.defaultSort.1', 'asc')
+ ->where('sources.3.defaultSort.0', 'field:'.$field->uid)
+ ->where('sources.3.defaultSort.1', 'desc')
+ ->where('sources.3.sites.0', $primarySite->uid)
+ ->where('sources.3.userGroups', [])
+ ->missing('sources.3.condition')
+ ->where('sources.4.sites', [])
+ ->where('pageSettings.entries.label', 'Entries')
+ ->where('defaultSortOptions.0.attr', 'field:'.$field->uid)
+ ->where('availableTableAttributes.0.0', 'title')
+ ->where('customFieldAttributes.0.0', 'field:'.$field->uid)
+ ->where('customFieldAttributes.0.1', 'Preview Field')
+ ->where('elementTypeName', 'Test Element')
+ ->where('userGroups.0.label', t($userGroup->name, category: 'site'))
+ ->where('userGroups.0.value', $userGroup->uid)
+ ->etc()
+ );
+
+ $payload = $response->json();
+ $structuredSortAttrs = array_column($payload['sources'][1]['sortOptions'], 'attr');
+ $baseSortAttrs = array_column($payload['baseSortOptions'], 'attr');
+ $viewModes = collect($payload['viewModes']);
+
+ expect($payload['sources'][3]['conditionBuilderHtml'])->toContain('condition-container')
+ ->and($payload['sources'][3]['conditionBuilderJs'])->toContain('Craft.initUiElements')
+ ->and($payload['sources'][1]['availableTableAttributes'])->toBe([])
+ ->and($payload['sources'][3]['tableAttributes'][0])->toBe(['title', 'Test Element'])
+ ->and($structuredSortAttrs)->toContain('title', 'slug', 'postDate')
+ ->and($baseSortAttrs)->toContain('id', 'title', 'slug', 'postDate')
+ ->and($viewModes->contains(fn (array $viewMode) => $viewMode['mode'] === 'table' && is_string($viewMode['iconSvg'])))->toBeTrue()
+ ->and($payload['conditionBuilderHtml'])->toContain('__SOURCE_KEY__')
+ ->and($payload['conditionBuilderJs'])->toContain('Craft.initUiElements')
+ ->and($payload['headHtml'])->toBeString()
+ ->and($payload['bodyHtml'])->toBeString();
+});
+
+it('stores normalized source settings for multi-page sources', function () {
+ $projectConfig = app(ProjectConfig::class);
+
+ $projectConfig->set(sprintf('%s.%s', ProjectConfig::PATH_ELEMENT_SOURCES, TestElementSourcesElement::class), [
+ [
+ 'key' => 'native-disabled',
+ 'type' => ElementSources::TYPE_NATIVE,
+ 'page' => 'Archived',
+ 'disabled' => true,
+ 'tableAttributes' => ['slug'],
+ ],
+ ]);
+
+ $response = postJson(action([ElementSourcesController::class, 'store']), [
+ 'elementType' => TestElementSourcesElement::class,
+ 'sourceOrder' => [
+ 'native-enabled',
+ 'custom:new',
+ 'heading:content',
+ 'native-disabled',
+ 'custom:missing',
+ ],
+ 'sourcePages' => [
+ 'native-enabled' => 'Archived',
+ 'custom:new' => 'Content',
+ 'heading:content' => 'Content',
+ 'native-disabled' => 'Archived',
+ ],
+ 'pageSettings' => [
+ 'Content' => [
+ 'label' => 'Content',
+ 'description' => '',
+ ],
+ 'Archived' => [
+ 'label' => 'Archived',
+ 'description' => null,
+ ],
+ ],
+ 'sources' => [
+ 'native-enabled' => [
+ 'tableAttributes' => ['', 'slug'],
+ 'defaultSort' => ['slug', 'desc'],
+ 'defaultViewMode' => 'cards',
+ 'enabled' => true,
+ ],
+ 'custom:new' => [
+ 'label' => 'Fresh Custom',
+ 'tableAttributes' => ['', 'postDate'],
+ 'defaultSort' => ['postDate', 'desc'],
+ 'defaultViewMode' => 'cards',
+ 'condition' => [
+ 'class' => ElementCondition::class,
+ 'elementType' => TestElementSourcesElement::class,
+ 'conditionRules' => [],
+ ],
+ 'sites' => 'not-an-array',
+ 'userGroups' => ['group-editors'],
+ ],
+ 'heading:content' => [
+ 'heading' => 'Content Heading',
+ ],
+ ],
+ ]);
+
+ $response->assertOk()
+ ->assertJsonPath('message', t('Source settings saved'))
+ ->assertJsonPath('disabledSourceKeys.0', 'native-disabled');
+
+ expect(normalizeConfig($projectConfig->get(sprintf('%s.%s', ProjectConfig::PATH_ELEMENT_SOURCES, TestElementSourcesElement::class))))->toBe(normalizeConfig([
+ [
+ 'type' => ElementSources::TYPE_CUSTOM,
+ 'key' => 'custom:new',
+ 'page' => 'Content',
+ 'tableAttributes' => ['postDate'],
+ 'defaultSort' => ['postDate', 'desc'],
+ 'defaultViewMode' => 'cards',
+ 'label' => 'Fresh Custom',
+ 'condition' => [
+ 'elementType' => TestElementSourcesElement::class,
+ 'fieldContext' => 'global',
+ 'class' => ElementCondition::class,
+ ],
+ 'sites' => false,
+ 'userGroups' => ['group-editors'],
+ ],
+ [
+ 'type' => ElementSources::TYPE_HEADING,
+ 'key' => 'heading:content',
+ 'page' => 'Content',
+ 'heading' => 'Content Heading',
+ ],
+ [
+ 'type' => ElementSources::TYPE_NATIVE,
+ 'key' => 'native-enabled',
+ 'page' => 'Archived',
+ 'tableAttributes' => ['slug'],
+ 'defaultSort' => ['slug', 'desc'],
+ 'defaultViewMode' => 'cards',
+ 'disabled' => false,
+ ],
+ [
+ 'type' => ElementSources::TYPE_NATIVE,
+ 'key' => 'native-disabled',
+ 'page' => 'Archived',
+ 'tableAttributes' => ['slug'],
+ 'disabled' => true,
+ ],
+ ]))
+ ->and(normalizeConfig($projectConfig->get(sprintf('%s.%s', ProjectConfig::PATH_ELEMENT_SOURCE_PAGES, TestElementSourcesElement::class))))->toBe(normalizeConfig([
+ 'Content' => ['label' => 'Content'],
+ 'Archived' => ['label' => 'Archived'],
+ ]));
+});
+
+it('stores single-page source settings without page reordering', function () {
+ $projectConfig = app(ProjectConfig::class);
+
+ $response = postJson(action([ElementSourcesController::class, 'store']), [
+ 'elementType' => TestSinglePageElementSourcesElement::class,
+ 'sourceOrder' => ['native-only'],
+ 'sources' => [
+ 'native-only' => [
+ 'tableAttributes' => [],
+ 'enabled' => false,
+ ],
+ ],
+ ]);
+
+ $response->assertOk()
+ ->assertJsonPath('disabledSourceKeys.0', 'native-only');
+
+ expect(normalizeConfig($projectConfig->get(sprintf('%s.%s', ProjectConfig::PATH_ELEMENT_SOURCES, TestSinglePageElementSourcesElement::class))))->toBe(normalizeConfig([
+ [
+ 'type' => ElementSources::TYPE_NATIVE,
+ 'key' => 'native-only',
+ 'tableAttributes' => '-',
+ 'disabled' => true,
+ ],
+ ]))
+ ->and($projectConfig->get(sprintf('%s.%s', ProjectConfig::PATH_ELEMENT_SOURCE_PAGES, TestSinglePageElementSourcesElement::class)))->toBeNull();
+});
+
+function normalizeConfig(mixed $value): mixed
+{
+ if (! is_array($value)) {
+ return $value;
+ }
+
+ if (array_is_list($value)) {
+ return array_map(normalizeConfig(...), $value);
+ }
+
+ ksort($value);
+
+ foreach ($value as $key => $nestedValue) {
+ $value[$key] = normalizeConfig($nestedValue);
+ }
+
+ return $value;
+}
+
+class TestElementSourcesElement extends Element
+{
+ #[Override]
+ public static function displayName(): string
+ {
+ return 'Test Element';
+ }
+
+ #[Override]
+ public static function pluralDisplayName(): string
+ {
+ return 'Test Elements';
+ }
+
+ #[Override]
+ public static function multiPageSources(): bool
+ {
+ return true;
+ }
+
+ #[Override]
+ protected static function defineSources(string $context): array
+ {
+ return [
+ [
+ 'heading' => 'Primary Sources',
+ ],
+ [
+ 'key' => 'structured',
+ 'label' => 'Structured',
+ 'structureId' => 1,
+ ],
+ [
+ 'key' => 'fallback',
+ 'label' => 'Fallback',
+ ],
+ ];
+ }
+
+ #[Override]
+ protected static function defineFieldLayouts(?string $source): array
+ {
+ return match ($source) {
+ 'structured' => [],
+ 'fallback' => [],
+ default => app(Fields::class)->getLayoutsByType(static::class)->all(),
+ };
+ }
+
+ #[Override]
+ protected static function defineTableAttributes(): array
+ {
+ return [
+ 'title' => ['label' => 'Test Element'],
+ 'slug' => ['label' => 'Slug'],
+ 'postDate' => ['label' => 'Post Date'],
+ ];
+ }
+
+ #[Override]
+ protected static function defineDefaultTableAttributes(string $source): array
+ {
+ return match ($source) {
+ 'structured' => ['slug'],
+ default => ['title'],
+ };
+ }
+
+ #[Override]
+ public function getCanonical(bool $anySite = false): ElementInterface
+ {
+ return $this;
+ }
+}
+
+class TestSinglePageElementSourcesElement extends TestElementSourcesElement
+{
+ #[Override]
+ public static function multiPageSources(): bool
+ {
+ return false;
+ }
+}
diff --git a/tests/Feature/Http/Controllers/Elements/ExportElementIndexControllerTest.php b/tests/Feature/Http/Controllers/Elements/ExportElementIndexControllerTest.php
index 32209c0d7a9..97becb05aae 100644
--- a/tests/Feature/Http/Controllers/Elements/ExportElementIndexControllerTest.php
+++ b/tests/Feature/Http/Controllers/Elements/ExportElementIndexControllerTest.php
@@ -9,7 +9,7 @@
use CraftCms\Cms\Element\Queries\Contracts\ElementQueryInterface;
use CraftCms\Cms\Entry\Elements\Entry;
use CraftCms\Cms\Entry\Models\Entry as EntryModel;
-use CraftCms\Cms\Http\Controllers\Elements\ExportElementIndexController;
+use CraftCms\Cms\Http\Controllers\Elements\ElementIndex\ExportElementIndexController;
use CraftCms\Cms\User\Elements\User;
use Illuminate\Support\Facades\Event;
diff --git a/tests/Feature/Http/Controllers/Elements/PreviewElementControllerTest.php b/tests/Feature/Http/Controllers/Elements/PreviewElementControllerTest.php
new file mode 100644
index 00000000000..43e24a0e618
--- /dev/null
+++ b/tests/Feature/Http/Controllers/Elements/PreviewElementControllerTest.php
@@ -0,0 +1,146 @@
+createElement([
+ 'title' => 'Canonical Title',
+ 'slug' => 'canonical-title',
+ ]);
+
+ $draft = app(Drafts::class)->createDraft($entry, auth()->id(), name: 'Preview Draft');
+ $draft->title = 'Draft Title';
+ Elements::saveElement($draft);
+
+ $request = Mockery::mock(ElementRequest::class);
+ $request->shouldReceive('element')
+ ->once()
+ ->with(['id' => $entry->id], true)
+ ->andReturn($draft);
+ $request->shouldReceive('getSigned')
+ ->once()
+ ->with('returnUrl', ElementHelper::postEditUrl($draft))
+ ->andReturn('entries');
+
+ HtmlStack::clear();
+
+ $view = new PreviewElementController($request)->__invoke($entry->id, "-$entry->slug");
+ $html = $view->render();
+
+ expect($view->getData()['title'])->toBe('Draft Title')
+ ->and($view->getData()['docTitle'])->toContain("($draft->draftName)")
+ ->and($html)->toContain('new Craft.Preview({')
+ ->and($html)->toContain(sprintf('elementId: %d', $draft->id))
+ ->and($html)->toContain(sprintf('draftId: %d', $draft->draftId))
+ ->and($html)->toContain('revisionId: null')
+ ->and($html)->toContain('redirectUrl: "entries"');
+});
+
+it('renders preview pages for provisional drafts using canonical ids in the preview config', function () {
+ $entry = EntryModel::factory()->createElement([
+ 'title' => 'Canonical Title',
+ 'slug' => 'canonical-title',
+ ]);
+
+ $draft = app(Drafts::class)->createDraft($entry, auth()->id(), provisional: true);
+ $draft->title = 'Edited Title';
+ Elements::saveElement($draft);
+
+ $request = Mockery::mock(ElementRequest::class);
+ $request->shouldReceive('element')
+ ->once()
+ ->with(['id' => $entry->id], true)
+ ->andReturn($draft);
+ $request->shouldReceive('getSigned')
+ ->once()
+ ->with('returnUrl', ElementHelper::postEditUrl($draft))
+ ->andReturn(ElementHelper::postEditUrl($draft));
+
+ HtmlStack::clear();
+
+ $view = new PreviewElementController($request)->__invoke($entry->id, "-$entry->slug");
+ $html = $view->render();
+
+ expect($view->getData()['title'])->toBe('Edited Title')
+ ->and($view->getData()['docTitle'])->toContain('Edited')
+ ->and($html)->toContain(sprintf('elementId: %d', $entry->id))
+ ->and($html)->toContain('draftId: null')
+ ->and($html)->toContain(
+ sprintf('redirectUrl: %s', json_encode(ElementHelper::postEditUrl($draft), JSON_THROW_ON_ERROR)),
+ );
+});
+
+it('renders preview pages for revisions', function () {
+ $entry = EntryModel::factory()->createElement([
+ 'title' => 'Current Title',
+ 'slug' => 'current-title',
+ ]);
+
+ $revisionElementId = app(Revisions::class)->createRevision($entry, auth()->id());
+ $revision = Elements::getElementById($revisionElementId, Entry::class, $entry->siteId);
+
+ $request = Mockery::mock(ElementRequest::class);
+ $request->shouldReceive('element')
+ ->once()
+ ->with(['id' => $entry->id], true)
+ ->andReturn($revision);
+ $request->shouldReceive('getSigned')
+ ->once()
+ ->with('returnUrl', ElementHelper::postEditUrl($revision))
+ ->andReturn(ElementHelper::postEditUrl($revision));
+
+ HtmlStack::clear();
+
+ $view = new PreviewElementController($request)->__invoke($entry->id, "-$entry->slug");
+ $html = $view->render();
+
+ expect($view->getData()['title'])->toBe($revision->title)
+ ->and($view->getData()['docTitle'])->toContain($revision->getRevisionLabel())
+ ->and($html)->toContain(sprintf('elementId: %d', $revision->id))
+ ->and($html)->toContain('draftId: null')
+ ->and($html)->toContain(sprintf('revisionId: %d', $revision->revisionId));
+});
+
+it('redirects to the canonical edit url when the requested draft is invalid', function () {
+ $entry = EntryModel::factory()->createElement([
+ 'title' => 'Canonical Title',
+ 'slug' => 'canonical-title',
+ ]);
+
+ $redirect = redirect($entry->getCpEditUrl());
+
+ $request = Mockery::mock(ElementRequest::class);
+ $request->shouldReceive('element')
+ ->once()
+ ->with(['id' => $entry->id], true)
+ ->andReturn($redirect);
+
+ $response = new PreviewElementController($request)->__invoke($entry->id, "-$entry->slug");
+
+ expect($response)->toBe($redirect);
+});
+
+it('returns a bad request when no element matches the preview request', function () {
+ get(action(PreviewElementController::class, [
+ 'id' => 999999,
+ 'slug' => '-missing',
+ ]))->assertBadRequest();
+});
diff --git a/tests/Feature/Http/Controllers/Elements/SaveElementControllerTest.php b/tests/Feature/Http/Controllers/Elements/SaveElementControllerTest.php
new file mode 100644
index 00000000000..8d682ef1893
--- /dev/null
+++ b/tests/Feature/Http/Controllers/Elements/SaveElementControllerTest.php
@@ -0,0 +1,740 @@
+entryType = EntryType::factory()->create();
+ $this->section = Section::factory()->withEntryTypes($this->entryType)->create([
+ 'handle' => 'news',
+ 'enableVersioning' => true,
+ ]);
+});
+
+function createSaveElementMatrixFixture(): array
+{
+ $innerField = Field::factory()->create([
+ 'name' => 'Inner Text',
+ 'handle' => 'innerText',
+ 'type' => PlainText::class,
+ ]);
+
+ $matrixEntryType = EntryType::factory()
+ ->withField($innerField)
+ ->create([
+ 'name' => 'Matrix Block',
+ 'handle' => 'matrixBlock',
+ 'hasTitleField' => true,
+ ]);
+
+ $matrixField = Field::factory()->create([
+ 'name' => 'Matrix Field',
+ 'handle' => 'matrixField',
+ 'type' => Matrix::class,
+ 'settings' => ['entryTypes' => [$matrixEntryType->id]],
+ ]);
+
+ $ownerType = EntryType::factory()
+ ->withField($matrixField)
+ ->create([
+ 'name' => 'Owner',
+ 'handle' => 'owner',
+ 'hasTitleField' => true,
+ ]);
+
+ $section = Section::factory()
+ ->withEntryTypes($ownerType)
+ ->create([
+ 'handle' => 'owners',
+ ]);
+
+ $owner = EntryModel::factory()
+ ->forSection($section)
+ ->forEntryType($ownerType)
+ ->createElement([
+ 'title' => 'Owner Entry',
+ 'slug' => Str::slug('Owner Entry '.Str::random(6)),
+ ]);
+
+ EntryTypesFacade::refreshEntryTypes();
+ FieldsFacade::invalidateCaches();
+
+ $matrixField = FieldsFacade::getFieldById($matrixField->id);
+ /** @var Entry $owner */
+ $owner = Entry::find()->id($owner->id)->status(null)->one();
+
+ $blockUid = Str::uuid()->toString();
+ $owner->setFieldValueFromRequest('matrixField', [
+ 'entries' => [
+ "uid:$blockUid" => [
+ 'type' => $matrixEntryType->handle,
+ 'title' => 'Block 1',
+ 'enabled' => true,
+ 'fields' => [
+ 'innerText' => 'Canonical matrix value',
+ ],
+ ],
+ ],
+ 'sortOrder' => [$blockUid],
+ ]);
+
+ expect(Elements::saveElement($owner))->toBeTrue();
+
+ $owner = Entry::find()->id($owner->id)->status(null)->one();
+ $canonicalBlock = $owner->getFieldValue('matrixField')->status(null)->one();
+ $ownerDraft = app(Drafts::class)->createDraft($owner, auth()->id(), name: 'Owner Draft');
+ $draftBlock = app(Drafts::class)->createDraft($canonicalBlock, auth()->id(), name: 'Block Draft');
+
+ /** @var Entry $ownerDraft */
+ $ownerDraft = Entry::find()
+ ->draftId($ownerDraft->draftId)
+ ->siteId($ownerDraft->siteId)
+ ->status(null)
+ ->one();
+ $draftBlock = Entry::find()
+ ->draftId($draftBlock->draftId)
+ ->fieldId($matrixField->id)
+ ->ownerId($owner->id)
+ ->drafts()
+ ->siteId($draftBlock->siteId)
+ ->status(null)
+ ->one();
+
+ return [
+ 'field' => $matrixField,
+ 'owner' => $owner,
+ 'ownerDraft' => $ownerDraft,
+ 'canonicalBlock' => $canonicalBlock,
+ 'draftBlock' => $draftBlock,
+ ];
+}
+
+describe('store', function () {
+ it('requires authentication', function () {
+ Auth::logout();
+
+ postJson(action([SaveElementController::class, 'store']))->assertUnauthorized();
+ });
+
+ it('returns any response resolved by the element request', function () {
+ $entry = EntryModel::factory()
+ ->forSection($this->section)
+ ->forEntryType($this->entryType)
+ ->createElement([
+ 'title' => 'Canonical Title',
+ 'slug' => 'canonical-title',
+ ]);
+
+ post(action([SaveElementController::class, 'store']), [
+ 'elementType' => Entry::class,
+ 'elementId' => $entry->id,
+ 'siteId' => $entry->siteId,
+ 'draftId' => 999999,
+ ])->assertRedirect($entry->getCpEditUrl());
+ });
+
+ it('returns 400 when no element is identified by the request', function () {
+ postJson(action([SaveElementController::class, 'store']), [
+ 'elementType' => Entry::class,
+ 'siteId' => Sites::getPrimarySite()->id,
+ ])->assertBadRequest();
+ });
+
+ it('returns 400 for drafts', function () {
+ $entry = EntryModel::factory()
+ ->forSection($this->section)
+ ->forEntryType($this->entryType)
+ ->createElement([
+ 'title' => 'Canonical Title',
+ 'slug' => 'canonical-title',
+ ]);
+ /** @var Entry $draft */
+ $draft = app(Drafts::class)->createDraft($entry, auth()->id(), name: 'Existing Draft');
+
+ postJson(action([SaveElementController::class, 'store']), [
+ 'elementType' => Entry::class,
+ 'draftId' => $draft->draftId,
+ 'siteId' => $draft->siteId,
+ ])->assertBadRequest();
+ });
+
+ it('returns 400 for revisions', function () {
+ $entry = EntryModel::factory()
+ ->forSection($this->section)
+ ->forEntryType($this->entryType)
+ ->createElement([
+ 'title' => 'Canonical Title',
+ 'slug' => 'canonical-title',
+ ]);
+ $revisionId = app(Revisions::class)->createRevision($entry, auth()->id());
+
+ postJson(action([SaveElementController::class, 'store']), [
+ 'elementType' => Entry::class,
+ 'revisionId' => $revisionId,
+ 'siteId' => $entry->siteId,
+ ])->assertBadRequest();
+ });
+
+ it('forbids saving when the user cannot save the element', function () {
+ $entry = EntryModel::factory()
+ ->forSection($this->section)
+ ->forEntryType($this->entryType)
+ ->createElement([
+ 'title' => 'Canonical Title',
+ 'slug' => 'canonical-title',
+ ]);
+
+ $viewer = UserModel::factory()
+ ->withPermissions([
+ 'accessCp',
+ sprintf('editSite:%s', Sites::getPrimarySite()->uid),
+ sprintf('viewEntries:%s', $this->section->uid),
+ sprintf('viewPeerEntries:%s', $this->section->uid),
+ ])
+ ->createElement();
+
+ actingAs($viewer);
+
+ postJson(action([SaveElementController::class, 'store']), [
+ 'elementType' => Entry::class,
+ 'elementId' => $entry->id,
+ 'siteId' => $entry->siteId,
+ 'title' => 'Updated Title',
+ ])->assertForbidden();
+ });
+
+ it('returns 500 when it cannot acquire a lock for an existing element', function () {
+ $entry = EntryModel::factory()
+ ->forSection($this->section)
+ ->forEntryType($this->entryType)
+ ->createElement([
+ 'title' => 'Canonical Title',
+ 'slug' => 'canonical-title',
+ ]);
+
+ $lock = Cache::lock("element:$entry->id", 15);
+
+ expect($lock->get())->toBeTrue();
+
+ try {
+ postJson(action([SaveElementController::class, 'store']), [
+ 'elementType' => Entry::class,
+ 'elementId' => $entry->id,
+ 'siteId' => $entry->siteId,
+ 'title' => 'Updated Title',
+ ])->assertInternalServerError();
+ } finally {
+ $lock->release();
+ }
+ });
+
+ it('returns a failure response when saving fails', function () {
+ $entry = EntryModel::factory()
+ ->forSection($this->section)
+ ->forEntryType($this->entryType)
+ ->createElement([
+ 'title' => 'Canonical Title',
+ 'slug' => 'canonical-title',
+ ]);
+ $entry->errors()->add('title', 'Title is invalid.');
+
+ app()->instance(ElementsService::class, new class(app(ElementPlaceholders::class)) extends ElementsService
+ {
+ public function saveElement(
+ ElementInterface $element,
+ bool $runValidation = true,
+ bool $propagate = true,
+ ?bool $updateSearchIndex = null,
+ bool $forceTouch = false,
+ ?bool $crossSiteValidate = false,
+ bool $saveContent = false,
+ ): bool {
+ return false;
+ }
+ });
+
+ postJson(action([SaveElementController::class, 'store']), [
+ 'elementType' => Entry::class,
+ 'elementId' => $entry->id,
+ 'siteId' => $entry->siteId,
+ 'title' => 'Updated Title',
+ ])->assertBadRequest()
+ ->assertJson(fn (AssertableJson $json) => $json
+ ->where('message', mb_ucfirst(t('Couldn’t save {type}.', [
+ 'type' => Entry::lowerDisplayName(),
+ ])))
+ ->where('modelName', 'element')
+ ->etc()
+ );
+ });
+
+ it('returns a failure response when saving throws an unsupported site exception', function () {
+ $entry = EntryModel::factory()
+ ->forSection($this->section)
+ ->forEntryType($this->entryType)
+ ->createElement([
+ 'title' => 'Canonical Title',
+ 'slug' => 'canonical-title',
+ ]);
+
+ app()->instance(ElementsService::class, new class(app(ElementPlaceholders::class)) extends ElementsService
+ {
+ public function saveElement(
+ ElementInterface $element,
+ bool $runValidation = true,
+ bool $propagate = true,
+ ?bool $updateSearchIndex = null,
+ bool $forceTouch = false,
+ ?bool $crossSiteValidate = false,
+ bool $saveContent = false,
+ ): bool {
+ throw new UnsupportedSiteException($element, 999999, 'Unsupported site.');
+ }
+ });
+
+ postJson(action([SaveElementController::class, 'store']), [
+ 'elementType' => Entry::class,
+ 'elementId' => $entry->id,
+ 'siteId' => $entry->siteId,
+ 'title' => 'Updated Title',
+ ])->assertBadRequest()
+ ->assertJson(fn (AssertableJson $json) => $json
+ ->where('errors.siteId.0', 'Unsupported site.')
+ ->etc()
+ );
+ });
+
+ it('saves canonical elements, tracks save activity, deletes provisional drafts, and cross-site validates for multisite requests', function () {
+ Site::factory()->create(['handle' => 'secondary']);
+
+ $entry = EntryModel::factory()
+ ->forSection($this->section)
+ ->forEntryType($this->entryType)
+ ->createElement([
+ 'title' => 'Canonical Title',
+ 'slug' => 'canonical-title',
+ ]);
+ app(Drafts::class)->createDraft($entry, auth()->id(), provisional: true);
+
+ $elements = new class(app(ElementPlaceholders::class)) extends ElementsService
+ {
+ public ?bool $capturedCrossSiteValidate = null;
+
+ public function saveElement(
+ ElementInterface $element,
+ bool $runValidation = true,
+ bool $propagate = true,
+ ?bool $updateSearchIndex = null,
+ bool $forceTouch = false,
+ ?bool $crossSiteValidate = false,
+ bool $saveContent = false,
+ ): bool {
+ $this->capturedCrossSiteValidate = $crossSiteValidate;
+
+ return parent::saveElement(
+ $element,
+ $runValidation,
+ $propagate,
+ $updateSearchIndex,
+ $forceTouch,
+ $crossSiteValidate,
+ $saveContent,
+ );
+ }
+ };
+
+ app()->instance(ElementsService::class, $elements);
+
+ $response = postJson(action([SaveElementController::class, 'store']), [
+ 'elementType' => Entry::class,
+ 'elementId' => $entry->id,
+ 'siteId' => $entry->siteId,
+ 'title' => 'Updated Title',
+ 'slug' => 'updated-title',
+ ]);
+
+ $response->assertOk()
+ ->assertJson(fn (AssertableJson $json) => $json
+ ->where('message', t('{type} saved.', ['type' => Entry::displayName()]))
+ ->where('modelName', 'element')
+ ->where('element.id', $entry->id)
+ ->where('element.title', 'Updated Title')
+ ->where('element.slug', 'updated-title')
+ ->etc()
+ );
+
+ expect($elements->capturedCrossSiteValidate)->toBeTrue()
+ ->and(Entry::find()->id($entry->id)->status(null)->one()->title)->toBe('Updated Title')
+ ->and(
+ Entry::find()
+ ->drafts()
+ ->provisionalDrafts()
+ ->draftOf($entry->id)
+ ->draftCreator(auth()->id())
+ ->status(null)
+ ->count()
+ )->toBe(0)
+ ->and(DB::table(Table::ELEMENTACTIVITY)
+ ->where('elementId', $entry->id)
+ ->where('userId', auth()->id())
+ ->where('type', ElementActivityType::Save->value)
+ ->exists())
+ ->toBeTrue();
+ });
+
+ it('marks nested elements to update their owner search index before saving', function () {
+ $fixture = createSaveElementMatrixFixture();
+
+ $elements = new class(app(ElementPlaceholders::class)) extends ElementsService
+ {
+ public bool $capturedNestedOwnerIndexFlag = false;
+
+ public function saveElement(
+ ElementInterface $element,
+ bool $runValidation = true,
+ bool $propagate = true,
+ ?bool $updateSearchIndex = null,
+ bool $forceTouch = false,
+ ?bool $crossSiteValidate = false,
+ bool $saveContent = false,
+ ): bool {
+ if ($element instanceof NestedElementInterface) {
+ $this->capturedNestedOwnerIndexFlag = $element->updateSearchIndexForOwner;
+ }
+
+ return parent::saveElement(
+ $element,
+ $runValidation,
+ $propagate,
+ $updateSearchIndex,
+ $forceTouch,
+ $crossSiteValidate,
+ $saveContent,
+ );
+ }
+ };
+
+ app()->instance(ElementsService::class, $elements);
+
+ postJson(action([SaveElementController::class, 'store']), [
+ 'elementType' => Entry::class,
+ 'elementId' => $fixture['canonicalBlock']->id,
+ 'siteId' => $fixture['canonicalBlock']->siteId,
+ 'ownerId' => $fixture['owner']->id,
+ 'fieldId' => $fixture['field']->id,
+ 'title' => 'Updated Block Title',
+ ])->assertOk();
+
+ expect($elements->capturedNestedOwnerIndexFlag)->toBeTrue();
+ });
+
+ it('redirects to a new draft when add another is requested', function () {
+ $entry = EntryModel::factory()
+ ->forSection($this->section)
+ ->forEntryType($this->entryType)
+ ->createElement([
+ 'title' => 'Canonical Title',
+ 'slug' => 'canonical-title',
+ ]);
+
+ $draftCount = Entry::find()->drafts()->status(null)->count();
+
+ post(cp_url('actions/elements/save'), [
+ 'elementType' => Entry::class,
+ 'elementId' => $entry->id,
+ 'siteId' => $entry->siteId,
+ 'title' => 'Updated Title',
+ 'addAnother' => 1,
+ ])->assertRedirect();
+
+ expect(Entry::find()->drafts()->status(null)->count())->toBe($draftCount + 1);
+ });
+});
+
+describe('storeForDerivative', function () {
+ it('requires authentication', function () {
+ Auth::logout();
+
+ postJson(action([SaveElementController::class, 'storeForDerivative']))->assertUnauthorized();
+ });
+
+ it('returns 400 when no new owner is identified', function () {
+ postJson(action([SaveElementController::class, 'storeForDerivative']), [
+ 'elementType' => Entry::class,
+ ])->assertBadRequest();
+ });
+
+ it('returns 400 when the element is not a nested draft derivative', function () {
+ $fixture = createSaveElementMatrixFixture();
+
+ postJson(action([SaveElementController::class, 'storeForDerivative']), [
+ 'elementType' => Entry::class,
+ 'elementId' => $fixture['canonicalBlock']->id,
+ 'siteId' => $fixture['canonicalBlock']->siteId,
+ 'ownerId' => $fixture['owner']->id,
+ 'fieldId' => $fixture['field']->id,
+ 'newOwnerId' => $fixture['ownerDraft']->id,
+ ])->assertBadRequest();
+ });
+
+ it('returns 400 when the new owner is canonical', function () {
+ $fixture = createSaveElementMatrixFixture();
+
+ DB::table(Table::ENTRIES)
+ ->where('id', $fixture['draftBlock']->id)
+ ->update(['primaryOwnerId' => $fixture['owner']->id]);
+
+ postJson(action([SaveElementController::class, 'storeForDerivative']), [
+ 'elementType' => Entry::class,
+ 'elementId' => $fixture['draftBlock']->id,
+ 'siteId' => $fixture['draftBlock']->siteId,
+ 'ownerId' => $fixture['ownerDraft']->id,
+ 'fieldId' => $fixture['field']->id,
+ 'draftId' => $fixture['draftBlock']->draftId,
+ 'newOwnerId' => $fixture['owner']->id,
+ ])->assertBadRequest();
+ });
+
+ it('returns 400 when the new owner does not share the nested element primary owner', function () {
+ $fixture = createSaveElementMatrixFixture();
+
+ DB::table(Table::ENTRIES)
+ ->where('id', $fixture['draftBlock']->id)
+ ->update(['primaryOwnerId' => $fixture['owner']->id]);
+
+ $otherFixture = createSaveElementMatrixFixture();
+
+ postJson(action([SaveElementController::class, 'storeForDerivative']), [
+ 'elementType' => Entry::class,
+ 'elementId' => $fixture['draftBlock']->id,
+ 'siteId' => $fixture['draftBlock']->siteId,
+ 'ownerId' => $fixture['ownerDraft']->id,
+ 'fieldId' => $fixture['field']->id,
+ 'draftId' => $fixture['draftBlock']->draftId,
+ 'newOwnerId' => $otherFixture['ownerDraft']->id,
+ ])->assertBadRequest();
+ });
+
+ it('forbids saving when the derivative owner cannot be saved', function () {
+ $fixture = createSaveElementMatrixFixture();
+
+ DB::table(Table::ENTRIES)
+ ->where('id', $fixture['draftBlock']->id)
+ ->update(['primaryOwnerId' => $fixture['owner']->id]);
+
+ Gate::before(function ($user, string $ability, array $arguments) use ($fixture) {
+ if ($ability === 'save' && ($arguments[0]->id ?? null) === $fixture['ownerDraft']->id) {
+ return false;
+ }
+
+ return null;
+ });
+
+ postJson(action([SaveElementController::class, 'storeForDerivative']), [
+ 'elementType' => Entry::class,
+ 'elementId' => $fixture['draftBlock']->id,
+ 'siteId' => $fixture['draftBlock']->siteId,
+ 'ownerId' => $fixture['ownerDraft']->id,
+ 'fieldId' => $fixture['field']->id,
+ 'draftId' => $fixture['draftBlock']->draftId,
+ 'newOwnerId' => $fixture['ownerDraft']->id,
+ ])->assertForbidden();
+ });
+
+ it('returns a failure response when saving the derivative fails', function () {
+ $fixture = createSaveElementMatrixFixture();
+
+ DB::table(Table::ENTRIES)
+ ->where('id', $fixture['draftBlock']->id)
+ ->update(['primaryOwnerId' => $fixture['owner']->id]);
+
+ $elements = new class(app(ElementPlaceholders::class)) extends ElementsService
+ {
+ public int $saveCalls = 0;
+
+ public function saveElement(
+ ElementInterface $element,
+ bool $runValidation = true,
+ bool $propagate = true,
+ ?bool $updateSearchIndex = null,
+ bool $forceTouch = false,
+ ?bool $crossSiteValidate = false,
+ bool $saveContent = false,
+ ): bool {
+ $this->saveCalls++;
+
+ if ($this->saveCalls === 1) {
+ return true;
+ }
+
+ $element->errors()->add('title', 'Title is invalid.');
+
+ return false;
+ }
+ };
+
+ app()->instance(ElementsService::class, $elements);
+
+ postJson(action([SaveElementController::class, 'storeForDerivative']), [
+ 'elementType' => Entry::class,
+ 'elementId' => $fixture['draftBlock']->id,
+ 'siteId' => $fixture['draftBlock']->siteId,
+ 'ownerId' => $fixture['ownerDraft']->id,
+ 'fieldId' => $fixture['field']->id,
+ 'draftId' => $fixture['draftBlock']->draftId,
+ 'newOwnerId' => $fixture['ownerDraft']->id,
+ 'title' => 'Updated Block Title',
+ ])->assertBadRequest()
+ ->assertJson(fn (AssertableJson $json) => $json
+ ->where('message', mb_ucfirst(t('Couldn’t save {type}.', [
+ 'type' => Entry::lowerDisplayName(),
+ ])))
+ ->etc()
+ );
+ });
+
+ it('returns a failure response when saving the derivative throws an unsupported site exception', function () {
+ $fixture = createSaveElementMatrixFixture();
+
+ DB::table(Table::ENTRIES)
+ ->where('id', $fixture['draftBlock']->id)
+ ->update(['primaryOwnerId' => $fixture['owner']->id]);
+
+ $elements = new class(app(ElementPlaceholders::class)) extends ElementsService
+ {
+ public int $saveCalls = 0;
+
+ public function saveElement(
+ ElementInterface $element,
+ bool $runValidation = true,
+ bool $propagate = true,
+ ?bool $updateSearchIndex = null,
+ bool $forceTouch = false,
+ ?bool $crossSiteValidate = false,
+ bool $saveContent = false,
+ ): bool {
+ $this->saveCalls++;
+
+ if ($this->saveCalls === 1) {
+ return true;
+ }
+
+ throw new UnsupportedSiteException($element, 999999, 'Unsupported site.');
+ }
+ };
+
+ app()->instance(ElementsService::class, $elements);
+
+ postJson(action([SaveElementController::class, 'storeForDerivative']), [
+ 'elementType' => Entry::class,
+ 'elementId' => $fixture['draftBlock']->id,
+ 'siteId' => $fixture['draftBlock']->siteId,
+ 'ownerId' => $fixture['ownerDraft']->id,
+ 'fieldId' => $fixture['field']->id,
+ 'draftId' => $fixture['draftBlock']->draftId,
+ 'newOwnerId' => $fixture['ownerDraft']->id,
+ 'title' => 'Updated Block Title',
+ ])->assertBadRequest()
+ ->assertJson(fn (AssertableJson $json) => $json
+ ->where('errors.siteId.0', 'Unsupported site.')
+ ->etc()
+ );
+ });
+
+ it('saves nested draft elements for derivative owners and removes their draft data', function () {
+ $fixture = createSaveElementMatrixFixture();
+
+ DB::table(Table::ENTRIES)
+ ->where('id', $fixture['draftBlock']->id)
+ ->update(['primaryOwnerId' => $fixture['owner']->id]);
+
+ $sortOrder = DB::table(Table::ELEMENTS_OWNERS)
+ ->where('elementId', $fixture['draftBlock']->id)
+ ->where('ownerId', $fixture['owner']->id)
+ ->value('sortOrder');
+
+ $draftRowExists = DB::table(Table::DRAFTS)
+ ->where('id', $fixture['draftBlock']->draftId)
+ ->exists();
+
+ expect($draftRowExists)->toBeTrue();
+
+ $response = postJson(action([SaveElementController::class, 'storeForDerivative']), [
+ 'elementType' => Entry::class,
+ 'elementId' => $fixture['draftBlock']->id,
+ 'siteId' => $fixture['draftBlock']->siteId,
+ 'ownerId' => $fixture['ownerDraft']->id,
+ 'fieldId' => $fixture['field']->id,
+ 'draftId' => $fixture['draftBlock']->draftId,
+ 'newOwnerId' => $fixture['ownerDraft']->id,
+ 'title' => 'Updated Block Title',
+ ]);
+
+ $response->assertOk()
+ ->assertJson(fn (AssertableJson $json) => $json
+ ->where('message', t('{type} saved.', ['type' => Entry::displayName()]))
+ ->where('modelName', 'element')
+ ->where('element.id', $fixture['draftBlock']->id)
+ ->where('element.title', 'Updated Block Title')
+ ->etc()
+ );
+
+ /** @var Entry $savedBlock */
+ $savedBlock = Entry::find()
+ ->id($fixture['draftBlock']->id)
+ ->status(null)
+ ->one();
+
+ expect($savedBlock->draftId)->toBeNull()
+ ->and($savedBlock->getOwnerId())->toBe($fixture['ownerDraft']->id)
+ ->and($savedBlock->getPrimaryOwnerId())->toBe($fixture['ownerDraft']->id)
+ ->and($savedBlock->title)->toBe('Updated Block Title')
+ ->and(DB::table(Table::ELEMENTS_OWNERS)
+ ->where('elementId', $savedBlock->id)
+ ->where('ownerId', $fixture['ownerDraft']->id)
+ ->value('sortOrder'))
+ ->toBe($sortOrder)
+ ->and(DB::table(Table::DRAFTS)
+ ->where('id', $fixture['draftBlock']->draftId)
+ ->exists())
+ ->toBeFalse();
+ });
+});
diff --git a/tests/Feature/Http/Controllers/Elements/SaveElementIndexElementsControllerTest.php b/tests/Feature/Http/Controllers/Elements/SaveElementIndexElementsControllerTest.php
new file mode 100644
index 00000000000..09f6a2706a4
--- /dev/null
+++ b/tests/Feature/Http/Controllers/Elements/SaveElementIndexElementsControllerTest.php
@@ -0,0 +1,202 @@
+postSaveElements = fn (array $payload = []) => postJson(
+ action(SaveElementIndexElementsController::class),
+ array_merge([
+ 'elementType' => Entry::class,
+ ], $payload),
+ );
+});
+
+it('requires authentication', function () {
+ auth()->logout();
+
+ postJson(action(SaveElementIndexElementsController::class), [
+ 'elementType' => Entry::class,
+ ])->assertUnauthorized();
+});
+
+it('rejects requests without element data', function () {
+ $entry = EntryModel::factory()->createElement();
+
+ ($this->postSaveElements)([
+ 'siteId' => $entry->siteId,
+ 'namespace' => 'elementindex-test',
+ ])->assertStatus(422)
+ ->assertJsonPath('message', 'The elementindex-test field is required.');
+});
+
+it('rejects requests without valid element ids', function () {
+ $entry = EntryModel::factory()->createElement();
+
+ ($this->postSaveElements)([
+ 'siteId' => $entry->siteId,
+ 'namespace' => 'elementindex-test',
+ 'elementindex-test' => [
+ 'element-999999' => [
+ 'title' => 'After Save',
+ ],
+ ],
+ ])->assertStatus(422)
+ ->assertJsonPath('message', 'No valid element IDs provided.');
+});
+
+it('aggregates validation errors when saving inline-edited elements', function () {
+ $field = Field::factory()->create([
+ 'handle' => 'requiredField',
+ 'type' => PlainText::class,
+ ]);
+
+ $firstEntry = EntryModel::factory()
+ ->withFieldLayout(FieldLayout::factory()->forField($field, true))
+ ->createElement();
+ $secondEntry = EntryModel::factory()
+ ->withFieldLayout(FieldLayout::factory()->forField($field, true))
+ ->createElement();
+
+ ($this->postSaveElements)([
+ 'siteId' => $firstEntry->siteId,
+ 'namespace' => 'elementindex-test',
+ 'elementindex-test' => [
+ "element-$firstEntry->id" => [
+ 'fields' => [
+ 'requiredField' => '',
+ ],
+ ],
+ "element-$secondEntry->id" => [
+ 'fields' => [
+ 'requiredField' => '',
+ ],
+ ],
+ ],
+ ])->assertOk()
+ ->assertJsonPath("errors.$firstEntry->id.requiredField.0", fn (string $message) => $message !== '')
+ ->assertJsonPath("errors.$secondEntry->id.requiredField.0", fn (string $message) => $message !== '');
+});
+
+it('saves inline-edited elements in a batch', function () {
+ $firstEntry = EntryModel::factory()->createElement([
+ 'title' => 'First Before Save',
+ ]);
+ $secondEntry = EntryModel::factory()->createElement([
+ 'title' => 'Second Before Save',
+ ]);
+
+ ($this->postSaveElements)([
+ 'siteId' => $firstEntry->siteId,
+ 'namespace' => 'elementindex-test',
+ 'elementindex-test' => [
+ "element-$firstEntry->id" => [
+ 'title' => 'First After Save',
+ ],
+ "element-$secondEntry->id" => [
+ 'title' => 'Second After Save',
+ ],
+ ],
+ ])->assertOk();
+
+ expect(Entry::find()->id($firstEntry->id)->status(null)->one()?->title)->toBe('First After Save')
+ ->and(Entry::find()->id($secondEntry->id)->status(null)->one()?->title)->toBe('Second After Save');
+});
+
+it('rolls back prior saves when a later element fails', function () {
+ $firstEntry = EntryModel::factory()->createElement([
+ 'title' => 'First Before Save',
+ ]);
+ $secondEntry = EntryModel::factory()->createElement([
+ 'title' => 'Second Before Save',
+ ]);
+
+ app()->instance(Elements::class, new class(app(ElementPlaceholders::class), $secondEntry->id) extends Elements
+ {
+ public function __construct(
+ ElementPlaceholders $placeholders,
+ private readonly int $failingElementId,
+ ) {
+ parent::__construct($placeholders);
+ }
+
+ public function saveElement(
+ ElementInterface $element,
+ bool $runValidation = true,
+ bool $propagate = true,
+ ?bool $updateSearchIndex = null,
+ bool $forceTouch = false,
+ ?bool $crossSiteValidate = false,
+ bool $saveContent = false,
+ ): bool {
+ if ($element->id === $this->failingElementId) {
+ return false;
+ }
+
+ return parent::saveElement(
+ $element,
+ $runValidation,
+ $propagate,
+ $updateSearchIndex,
+ $forceTouch,
+ $crossSiteValidate,
+ $saveContent,
+ );
+ }
+ });
+
+ ($this->postSaveElements)([
+ 'siteId' => $firstEntry->siteId,
+ 'namespace' => 'elementindex-test',
+ 'elementindex-test' => [
+ "element-$firstEntry->id" => [
+ 'title' => 'First After Save',
+ ],
+ "element-$secondEntry->id" => [
+ 'title' => 'Second After Save',
+ ],
+ ],
+ ])->assertServerError();
+
+ expect(Entry::find()->id($firstEntry->id)->status(null)->one()?->title)->toBe('First Before Save')
+ ->and(Entry::find()->id($secondEntry->id)->status(null)->one()?->title)->toBe('Second Before Save');
+});
+
+it('preserves the legacy action route contract for save-elements', function () {
+ $entry = EntryModel::factory()->createElement([
+ 'title' => 'Before Save',
+ ]);
+
+ postJson('/'.implode('/', array_filter([
+ Cms::config()->cpTrigger,
+ Cms::config()->actionTrigger,
+ 'element-indexes/save-elements',
+ ])), [
+ 'elementType' => Entry::class,
+ 'siteId' => $entry->siteId,
+ 'namespace' => 'elementindex-test',
+ 'elementindex-test' => [
+ "element-$entry->id" => [
+ 'title' => 'After Save',
+ ],
+ ],
+ ])->assertOk();
+
+ expect(Entry::find()->id($entry->id)->status(null)->one()?->title)->toBe('After Save');
+});
diff --git a/tests/Feature/Http/Controllers/Elements/SearchControllerTest.php b/tests/Feature/Http/Controllers/Elements/SearchControllerTest.php
new file mode 100644
index 00000000000..6d0aef705de
--- /dev/null
+++ b/tests/Feature/Http/Controllers/Elements/SearchControllerTest.php
@@ -0,0 +1,354 @@
+useFullText = false;
+ }
+
+ $this->entryType = EntryTypeModel::factory()
+ ->withFieldLayout()
+ ->create([
+ 'hasTitleField' => true,
+ ]);
+
+ $this->section = SectionModel::factory()
+ ->withEntryTypes($this->entryType)
+ ->create();
+
+ actingAs(User::findOne());
+});
+
+it('requires authentication', function () {
+ Auth::logout();
+
+ postJson(action(SearchController::class), [
+ 'elementType' => Entry::class,
+ 'search' => 'Alpha',
+ ])->assertUnauthorized();
+});
+
+it('validates the required payload', function () {
+ postJson(action(SearchController::class), [])
+ ->assertUnprocessable()
+ ->assertJsonValidationErrors(['search']);
+});
+
+it('validates invalid request payloads', function (array $payload, array $errors) {
+ postJson(action(SearchController::class), $payload)
+ ->assertUnprocessable()
+ ->assertJsonValidationErrors($errors);
+})->with([
+ 'invalid element type' => [[
+ 'elementType' => SearchController::class,
+ 'search' => 'Alpha',
+ ], ['elementType']],
+ 'invalid criteria type' => [[
+ 'elementType' => Entry::class,
+ 'search' => 'Alpha',
+ 'criteria' => 'invalid',
+ ], ['criteria']],
+ 'invalid exclude ids' => [[
+ 'elementType' => Entry::class,
+ 'search' => 'Alpha',
+ 'excludeIds' => ['invalid'],
+ ], ['excludeIds.0']],
+ 'invalid condition type' => [[
+ 'elementType' => Entry::class,
+ 'search' => 'Alpha',
+ 'condition' => 123,
+ ], ['condition']],
+ 'missing condition class' => [[
+ 'elementType' => Entry::class,
+ 'search' => 'Alpha',
+ 'condition' => [],
+ ], ['condition']],
+ 'invalid reference element ids' => [[
+ 'elementType' => Entry::class,
+ 'search' => 'Alpha',
+ 'referenceElementId' => 'invalid',
+ 'referenceElementOwnerId' => 'invalid',
+ 'referenceElementSiteId' => 'invalid',
+ ], ['referenceElementId', 'referenceElementOwnerId', 'referenceElementSiteId']],
+]);
+
+it('returns an empty result set when nothing matches', function () {
+ $entry = EntryModel::factory()
+ ->indexed()
+ ->forSection($this->section)
+ ->forEntryType($this->entryType)
+ ->createElement(['title' => 'Something Else Entirely']);
+
+ postJson(action(SearchController::class), [
+ 'elementType' => Entry::class,
+ 'search' => 'No Matches Here',
+ ])
+ ->assertOk()
+ ->assertExactJson([
+ 'elements' => [],
+ 'exactMatch' => false,
+ ]);
+});
+
+it('applies sanitized criteria to the query', function () {
+ $matchingEntry = EntryModel::factory()
+ ->indexed()
+ ->forSection($this->section)
+ ->forEntryType($this->entryType)
+ ->createElement(['title' => 'Criteria Target']);
+
+ $otherEntry = EntryModel::factory()
+ ->indexed()
+ ->forSection($this->section)
+ ->forEntryType($this->entryType)
+ ->createElement(['title' => 'Criteria Target']);
+
+ $response = postJson(action(SearchController::class), [
+ 'elementType' => Entry::class,
+ 'search' => 'Criteria Target',
+ 'criteria' => [
+ 'id' => [$matchingEntry->id],
+ 'where' => ['id' => 999999],
+ ],
+ ])
+ ->assertOk();
+
+ expect($response->json('elements'))->toHaveCount(1)
+ ->and($response->json('elements.0.id'))->toBe($matchingEntry->id)
+ ->and($response->json('elements.0.html'))->toContain('chromeless')
+ ->and($response->json('elements.0.html'))->toContain('Criteria Target')
+ ->and($response->json('exactMatch'))->toBeTrue();
+});
+
+it('marks exact matches and sorts excluded results last', function () {
+ $exactIncluded = EntryModel::factory()
+ ->indexed()
+ ->forSection($this->section)
+ ->forEntryType($this->entryType)
+ ->createElement(['title' => 'Alpha']);
+
+ $partialMatch = EntryModel::factory()
+ ->indexed()
+ ->forSection($this->section)
+ ->forEntryType($this->entryType)
+ ->createElement(['title' => 'Alpha Beta']);
+
+ $exactExcluded = EntryModel::factory()
+ ->indexed()
+ ->forSection($this->section)
+ ->forEntryType($this->entryType)
+ ->createElement(['title' => 'Alpha']);
+
+ $response = postJson(action(SearchController::class), [
+ 'elementType' => Entry::class,
+ 'search' => 'Alpha',
+ 'excludeIds' => [$exactExcluded->id],
+ ])
+ ->assertOk();
+
+ expect(collect($response->json('elements'))->pluck('id')->all())
+ ->toBe([$exactIncluded->id, $partialMatch->id, $exactExcluded->id])
+ ->and($response->json('elements.0.exclude'))->toBeFalse()
+ ->and($response->json('elements.1.exclude'))->toBeFalse()
+ ->and($response->json('elements.2.exclude'))->toBeTrue()
+ ->and($response->json('exactMatch'))->toBeTrue();
+});
+
+it('applies element conditions to the query', function () {
+ $matchingEntry = EntryModel::factory()
+ ->indexed()
+ ->forSection($this->section)
+ ->forEntryType($this->entryType)
+ ->createElement(['title' => 'Conditional Result']);
+
+ $otherEntry = EntryModel::factory()
+ ->indexed()
+ ->forSection($this->section)
+ ->forEntryType($this->entryType)
+ ->createElement(['title' => 'Conditional Result']);
+
+ $response = postJson(action(SearchController::class), [
+ 'elementType' => Entry::class,
+ 'search' => 'Conditional Result',
+ 'condition' => [
+ 'class' => ElementCondition::class,
+ 'elementType' => Entry::class,
+ 'conditionRules' => [[
+ 'class' => IdConditionRule::class,
+ 'operator' => '=',
+ 'value' => (string) $matchingEntry->id,
+ ]],
+ ],
+ ])
+ ->assertOk();
+
+ expect($response->json('elements'))->toHaveCount(1)
+ ->and($response->json('elements.0.id'))->toBe($matchingEntry->id)
+ ->and($response->json('exactMatch'))->toBeTrue();
+});
+
+it('ignores non-element conditions', function () {
+ $state = new stdClass;
+
+ app()->instance(Conditions::class, new readonly class($state) extends Conditions
+ {
+ public function __construct(
+ public stdClass $state,
+ ) {}
+
+ public function createCondition(array|string $config): ConditionInterface
+ {
+ $this->state->config = $config;
+
+ return new class extends BaseCondition
+ {
+ protected function selectableConditionRules(): array
+ {
+ return [];
+ }
+ };
+ }
+ });
+
+ $firstEntry = EntryModel::factory()
+ ->indexed()
+ ->forSection($this->section)
+ ->forEntryType($this->entryType)
+ ->createElement(['title' => 'Ignored Condition']);
+
+ $secondEntry = EntryModel::factory()
+ ->indexed()
+ ->forSection($this->section)
+ ->forEntryType($this->entryType)
+ ->createElement(['title' => 'Ignored Condition']);
+
+ $response = postJson(action(SearchController::class), [
+ 'elementType' => Entry::class,
+ 'search' => 'Ignored Condition',
+ 'condition' => 'ignored-condition',
+ ])
+ ->assertOk();
+
+ expect(collect($response->json('elements'))->pluck('id')->sort()->values()->all())
+ ->toBe(collect([$firstEntry->id, $secondEntry->id])->sort()->values()->all())
+ ->and($state->config)->toBe('ignored-condition');
+});
+
+it('passes the reference element context into element conditions', function () {
+ $referenceEntry = EntryModel::factory()
+ ->indexed()
+ ->forSection($this->section)
+ ->forEntryType($this->entryType)
+ ->createElement(['title' => 'Reference Result']);
+
+ $otherEntry = EntryModel::factory()
+ ->indexed()
+ ->forSection($this->section)
+ ->forEntryType($this->entryType)
+ ->createElement(['title' => 'Reference Result']);
+
+ $state = new stdClass;
+
+ $conditions = new readonly class($state) extends Conditions
+ {
+ public function __construct(
+ public stdClass $state,
+ ) {}
+
+ public function createCondition(array|string $config): ConditionInterface
+ {
+ $this->state->config = $config;
+
+ return new class($this->state) extends ElementCondition
+ {
+ public function __construct(
+ public stdClass $state,
+ ) {
+ parent::__construct(Entry::class);
+ }
+
+ public function modifyQuery(ElementQueryInterface $query): void
+ {
+ $this->state->modifyQueryCalled = true;
+ $this->state->referenceElementId = $this->referenceElement?->id;
+
+ $query->id($this->referenceElement?->id ?? 0);
+ }
+ };
+ }
+ };
+
+ $elements = new class(app(ElementPlaceholders::class), $referenceEntry) extends Elements
+ {
+ public ?int $requestedElementId = null;
+
+ public array|int|string|null $requestedSiteId = null;
+
+ public array $requestedCriteria = [];
+
+ public function __construct(
+ ElementPlaceholders $placeholders,
+ private readonly Entry $referenceEntry,
+ ) {
+ parent::__construct($placeholders);
+ }
+
+ public function getElementById(
+ int $elementId,
+ ?string $elementType = null,
+ array|int|string|null $siteId = null,
+ array $criteria = [],
+ ): ElementInterface {
+ $this->requestedElementId = $elementId;
+ $this->requestedSiteId = $siteId;
+ $this->requestedCriteria = $criteria;
+
+ return $this->referenceEntry;
+ }
+ };
+
+ app()->instance(Conditions::class, $conditions);
+ app()->instance(Elements::class, $elements);
+
+ $response = postJson(action(SearchController::class), [
+ 'elementType' => Entry::class,
+ 'search' => 'Reference Result',
+ 'condition' => 'reference-condition',
+ 'referenceElementId' => $referenceEntry->id,
+ 'referenceElementOwnerId' => 123,
+ 'referenceElementSiteId' => $referenceEntry->siteId,
+ ])
+ ->assertOk();
+
+ expect($response->json('elements'))->toHaveCount(1)
+ ->and($response->json('elements.0.id'))->toBe($referenceEntry->id)
+ ->and($state->config)->toBe('reference-condition')
+ ->and($state->modifyQueryCalled)->toBeTrue()
+ ->and($state->referenceElementId)->toBe($referenceEntry->id)
+ ->and($elements->requestedElementId)->toBe($referenceEntry->id)
+ ->and($elements->requestedSiteId)->toBe($referenceEntry->siteId)
+ ->and($elements->requestedCriteria)->toBe(['ownerId' => 123]);
+});
diff --git a/tests/Feature/Http/Controllers/Elements/UpdateFieldLayoutControllerTest.php b/tests/Feature/Http/Controllers/Elements/UpdateFieldLayoutControllerTest.php
new file mode 100644
index 00000000000..364119de5f2
--- /dev/null
+++ b/tests/Feature/Http/Controllers/Elements/UpdateFieldLayoutControllerTest.php
@@ -0,0 +1,133 @@
+entryType = EntryType::factory()->create();
+ $this->section = Section::factory()->withEntryTypes($this->entryType)->create([
+ 'handle' => 'blog',
+ ]);
+});
+
+it('requires authentication', function () {
+ Auth::logout();
+
+ postJson(action(UpdateFieldLayoutController::class), [
+ 'elementType' => Entry::class,
+ ])->assertUnauthorized();
+});
+
+it('returns responses resolved by the element request', function () {
+ $entry = EntryModel::factory()
+ ->forSection($this->section)
+ ->forEntryType($this->entryType)
+ ->createElement([
+ 'title' => 'Canonical Title',
+ 'slug' => 'canonical-title',
+ ]);
+
+ post(action(UpdateFieldLayoutController::class), [
+ 'elementType' => Entry::class,
+ 'elementId' => $entry->id,
+ 'draftId' => 999999,
+ 'siteId' => $entry->siteId,
+ ])->assertRedirect($entry->getCpEditUrl());
+});
+
+it('returns 400 when no element is identified by the request', function () {
+ postJson(action(UpdateFieldLayoutController::class), [
+ 'elementType' => Entry::class,
+ 'elementId' => 999999,
+ 'siteId' => Sites::getPrimarySite()->id,
+ ])->assertBadRequest();
+});
+
+it('returns 400 for revisions', function () {
+ $entry = EntryModel::factory()
+ ->forSection($this->section)
+ ->forEntryType($this->entryType)
+ ->createElement([
+ 'title' => 'Canonical Title',
+ 'slug' => 'canonical-title',
+ ]);
+ /** @var Entry $revision */
+ $revision = Elements::getElementById(app(Revisions::class)->createRevision($entry, auth()->id()));
+
+ postJson(action(UpdateFieldLayoutController::class), [
+ 'elementType' => Entry::class,
+ 'elementId' => $entry->id,
+ 'revisionId' => $revision->revisionId,
+ 'siteId' => $revision->siteId,
+ ])->assertBadRequest();
+});
+
+it('returns updated field layout data for existing elements', function () {
+ $entry = EntryModel::factory()
+ ->forSection($this->section)
+ ->forEntryType($this->entryType)
+ ->createElement([
+ 'title' => 'Canonical Title',
+ 'slug' => 'canonical-title',
+ ]);
+
+ postJson(action(UpdateFieldLayoutController::class), [
+ 'elementType' => Entry::class,
+ 'elementId' => $entry->id,
+ 'siteId' => $entry->siteId,
+ 'title' => 'Updated Title',
+ 'slug' => 'updated-title',
+ ])->assertOk()
+ ->assertJson(fn (AssertableJson $json) => $json
+ ->where('message', 'Field layout updated.')
+ ->where('modelName', 'element')
+ ->where('element.id', $entry->id)
+ ->where('element.title', 'Updated Title')
+ ->where('element.slug', 'updated-title')
+ ->has('missingElements')
+ ->has('initialDeltaValues')
+ ->has('headHtml')
+ ->has('bodyHtml')
+ ->etc()
+ );
+});
+
+it('returns field layout data for new elements', function () {
+ postJson(action(UpdateFieldLayoutController::class), [
+ 'elementType' => Entry::class,
+ 'siteId' => Sites::getPrimarySite()->id,
+ 'sectionId' => $this->section->id,
+ 'typeId' => $this->entryType->id,
+ 'title' => 'Unsaved Entry',
+ ])->assertOk()
+ ->assertJson(fn (AssertableJson $json) => $json
+ ->where('message', 'Field layout updated.')
+ ->where('modelName', 'element')
+ ->where('element.title', 'Unsaved Entry')
+ ->where('element.sectionId', $this->section->id)
+ ->where('element.typeId', $this->entryType->id)
+ ->where('element.slug', fn (string $slug) => $slug !== '')
+ ->has('missingElements')
+ ->has('initialDeltaValues')
+ ->has('headHtml')
+ ->has('bodyHtml')
+ ->etc()
+ );
+});
diff --git a/tests/Feature/Http/Controllers/Elements/ValidateElementControllerTest.php b/tests/Feature/Http/Controllers/Elements/ValidateElementControllerTest.php
new file mode 100644
index 00000000000..ac52763896b
--- /dev/null
+++ b/tests/Feature/Http/Controllers/Elements/ValidateElementControllerTest.php
@@ -0,0 +1,111 @@
+ Entry::class,
+ ])->assertUnauthorized();
+});
+
+it('returns responses resolved by the element request', function () {
+ $entry = EntryModel::factory()->createElement([
+ 'title' => 'Canonical Title',
+ 'slug' => 'canonical-title',
+ ]);
+
+ post(action(ValidateElementController::class), [
+ 'elementType' => Entry::class,
+ 'elementId' => $entry->id,
+ 'draftId' => 999999,
+ 'siteId' => $entry->siteId,
+ ])->assertRedirect($entry->getCpEditUrl());
+});
+
+it('returns 400 when no element is identified by the request', function () {
+ postJson(action(ValidateElementController::class), [
+ 'elementType' => Entry::class,
+ 'siteId' => 1,
+ ])->assertBadRequest();
+});
+
+it('returns 400 for revisions', function () {
+ $entry = EntryModel::factory()->createElement([
+ 'title' => 'Canonical Title',
+ 'slug' => 'canonical-title',
+ ]);
+ /** @var Entry $revision */
+ $revision = Elements::getElementById(app(Revisions::class)->createRevision($entry, auth()->id()));
+
+ postJson(action(ValidateElementController::class), [
+ 'elementType' => Entry::class,
+ 'revisionId' => $revision->revisionId,
+ 'siteId' => $revision->siteId,
+ ])->assertBadRequest();
+});
+
+it('returns a failure response for invalid elements', function () {
+ $entry = EntryModel::factory()->createElement([
+ 'title' => 'Valid Title',
+ 'slug' => 'valid-title',
+ ]);
+
+ $response = postJson(action(ValidateElementController::class), [
+ 'elementType' => Entry::class,
+ 'elementId' => $entry->id,
+ 'siteId' => $entry->siteId,
+ ])
+ ->assertBadRequest()
+ ->assertJson(fn (AssertableJson $json) => $json
+ ->where('message', t('{type} validation failed.', ['type' => Entry::displayName()]))
+ ->where('modelName', 'element')
+ ->where('element.title', 'Valid Title')
+ ->where('errors.authorIds.0', 'The author ids field is required.')
+ ->etc()
+ );
+
+ expect($response->json('errorSummary'))->toContain('field-error-key');
+});
+
+it('returns a success response for valid elements', function () {
+ $entry = EntryModel::factory()->createElement([
+ 'title' => 'Valid Title',
+ 'slug' => 'valid-title',
+ ]);
+ $entry->setAuthorIds([auth()->id()]);
+ Elements::saveElement($entry);
+
+ postJson(action(ValidateElementController::class), [
+ 'elementType' => Entry::class,
+ 'elementId' => $entry->id,
+ 'siteId' => $entry->siteId,
+ ])
+ ->assertOk()
+ ->assertJson(fn (AssertableJson $json) => $json
+ ->where('message', t('{type} validation successful.', ['type' => Entry::displayName()]))
+ ->where('modelName', 'element')
+ ->where('element.title', 'Valid Title')
+ ->missing('errors')
+ ->etc()
+ );
+});
diff --git a/tests/Feature/Http/Controllers/MatrixControllerTest.php b/tests/Feature/Http/Controllers/MatrixControllerTest.php
index 5bb10b23532..b7fa7b371c6 100644
--- a/tests/Feature/Http/Controllers/MatrixControllerTest.php
+++ b/tests/Feature/Http/Controllers/MatrixControllerTest.php
@@ -2,7 +2,7 @@
declare(strict_types=1);
-use craft\base\ElementInterface;
+use CraftCms\Cms\Element\Contracts\ElementInterface;
use CraftCms\Cms\Element\Drafts;
use CraftCms\Cms\Element\Elements;
use CraftCms\Cms\Element\Exceptions\InvalidElementException;
diff --git a/tests/Feature/Http/Controllers/NestedElementsControllerTest.php b/tests/Feature/Http/Controllers/NestedElementsControllerTest.php
index d65b4c15bae..fc035c8a827 100644
--- a/tests/Feature/Http/Controllers/NestedElementsControllerTest.php
+++ b/tests/Feature/Http/Controllers/NestedElementsControllerTest.php
@@ -2,10 +2,10 @@
declare(strict_types=1);
-use craft\base\ElementInterface;
use CraftCms\Cms\Address\Models\Address as AddressModel;
use CraftCms\Cms\Auth\SessionAuth;
use CraftCms\Cms\Database\Table;
+use CraftCms\Cms\Element\Contracts\ElementInterface;
use CraftCms\Cms\Element\Drafts;
use CraftCms\Cms\Element\Events\BeforeDelete;
use CraftCms\Cms\Entry\Elements\Entry as EntryElement;
diff --git a/tests/Feature/Http/Requests/ElementRequestTest.php b/tests/Feature/Http/Requests/ElementRequestTest.php
new file mode 100644
index 00000000000..afea64a7460
--- /dev/null
+++ b/tests/Feature/Http/Requests/ElementRequestTest.php
@@ -0,0 +1,102 @@
+createElement([
+ 'title' => 'Canonical Title',
+ ]);
+
+ $draft = app(Drafts::class)->createDraft($entry, auth()->id(), provisional: true);
+ $draft->title = 'Edited Title';
+ Elements::saveElement($draft);
+
+ $request = ElementRequest::create('/', 'POST');
+ $request->setUserResolver(fn () => auth()->user());
+
+ app()->instance('request', $request);
+ app(RequestedSite::class)->reset();
+
+ $element = $request->element(['id' => $entry->id], checkForProvisionalDraft: true);
+
+ expect($element)->not()->toBeNull()
+ ->and($element->id)->toBe($draft->id)
+ ->and($element->draftId)->toBe($draft->draftId)
+ ->and($element->isProvisionalDraft)->toBeTrue();
+});
+
+it('resolves unpublished drafts by id when no canonical element exists', function () {
+ $entryType = EntryType::factory()->create();
+ $section = Section::factory()->withEntryTypes($entryType)->create();
+
+ $draft = app(Entry::class);
+ $draft->siteId = Sites::getPrimarySite()->id;
+ $draft->sectionId = $section->id;
+ $draft->typeId = $entryType->id;
+ $draft->title = 'Unpublished Draft';
+ $draft->slug = 'unpublished-draft';
+ $draft->setAuthorIds([auth()->id()]);
+
+ app(Drafts::class)->saveElementAsDraft($draft, auth()->id(), markAsSaved: false);
+
+ $request = ElementRequest::create('/', 'POST');
+ $request->setUserResolver(fn () => auth()->user());
+
+ app()->instance('request', $request);
+ app(RequestedSite::class)->reset();
+
+ $element = $request->element(['id' => $draft->id]);
+
+ expect($element)->not()->toBeNull()
+ ->and($element->id)->toBe($draft->id)
+ ->and($element->draftId)->toBe($draft->draftId)
+ ->and($element->getIsUnpublishedDraft())->toBeTrue();
+});
+
+it('resolves unpublished drafts by uid when no canonical element exists', function () {
+ $entryType = EntryType::factory()->create();
+ $section = Section::factory()->withEntryTypes($entryType)->create();
+
+ $draft = app(Entry::class);
+ $draft->siteId = Sites::getPrimarySite()->id;
+ $draft->sectionId = $section->id;
+ $draft->typeId = $entryType->id;
+ $draft->title = 'UID Draft';
+ $draft->slug = 'uid-draft';
+ $draft->setAuthorIds([auth()->id()]);
+
+ app(Drafts::class)->saveElementAsDraft($draft, auth()->id(), markAsSaved: false);
+
+ $request = ElementRequest::create('/', 'POST', [
+ 'elementUid' => $draft->uid,
+ ]);
+ $request->setUserResolver(fn () => auth()->user());
+
+ app()->instance('request', $request);
+ app(RequestedSite::class)->reset();
+
+ $element = $request->element();
+
+ expect($element)->not()->toBeNull()
+ ->and($element->id)->toBe($draft->id)
+ ->and($element->uid)->toBe($draft->uid)
+ ->and($element->draftId)->toBe($draft->draftId)
+ ->and($element->getIsUnpublishedDraft())->toBeTrue();
+});
diff --git a/tests/Feature/Queue/JobTest.php b/tests/Feature/Queue/JobTest.php
index ea97c0e9b1d..7249812ae5a 100644
--- a/tests/Feature/Queue/JobTest.php
+++ b/tests/Feature/Queue/JobTest.php
@@ -178,3 +178,28 @@ public function handle(): void {}
// No job property set, so uuid() will return null
expect($job->shouldStillRun())->toBeTrue();
});
+
+it('returns true from shouldStillRun for sync jobs without progress', function () {
+ $job = new class extends Job
+ {
+ public function handle(): void {}
+ };
+
+ $mockQueueJob = new class
+ {
+ public function uuid(): string
+ {
+ return 'sync-job-uuid';
+ }
+
+ public function getConnectionName(): string
+ {
+ return 'sync';
+ }
+ };
+
+ $reflection = new ReflectionProperty($job, 'job');
+ $reflection->setValue($job, $mockQueueJob);
+
+ expect($job->shouldStillRun())->toBeTrue();
+});
diff --git a/tests/Feature/Queue/TestClasses/TestBatchedElementJob.php b/tests/Feature/Queue/TestClasses/TestBatchedElementJob.php
index 7101cdf8657..19132247040 100644
--- a/tests/Feature/Queue/TestClasses/TestBatchedElementJob.php
+++ b/tests/Feature/Queue/TestClasses/TestBatchedElementJob.php
@@ -4,7 +4,7 @@
namespace CraftCms\Cms\Tests\Feature\Queue\TestClasses;
-use craft\base\ElementInterface;
+use CraftCms\Cms\Element\Contracts\ElementInterface;
use CraftCms\Cms\Queue\BatchedElementJob;
class TestBatchedElementJob extends BatchedElementJob
diff --git a/tests/Feature/Search/Jobs/UpdateSearchIndexTest.php b/tests/Feature/Search/Jobs/UpdateSearchIndexTest.php
index 702d9b2f988..0187b720e6c 100644
--- a/tests/Feature/Search/Jobs/UpdateSearchIndexTest.php
+++ b/tests/Feature/Search/Jobs/UpdateSearchIndexTest.php
@@ -2,10 +2,13 @@
declare(strict_types=1);
+use CraftCms\Cms\Database\Table;
use CraftCms\Cms\Entry\Elements\Entry as EntryElement;
use CraftCms\Cms\Entry\Models\Entry;
use CraftCms\Cms\Queue\Job;
use CraftCms\Cms\Search\Jobs\UpdateSearchIndex;
+use CraftCms\Cms\Support\Facades\Sites;
+use Illuminate\Support\Facades\DB;
use Illuminate\Support\Facades\Queue;
it('extends Job', function () {
@@ -109,6 +112,35 @@
expect(true)->toBeTrue();
});
+it('can execute queued updates on the sync queue', function () {
+ $entry = Entry::factory()->create();
+ $siteId = Sites::getCurrentSite()->id;
+
+ DB::table(Table::SEARCHINDEX)
+ ->where('elementId', $entry->id)
+ ->delete();
+
+ $jobId = DB::table(Table::SEARCHINDEXQUEUE)->insertGetId([
+ 'elementId' => $entry->id,
+ 'siteId' => $siteId,
+ 'reserved' => false,
+ ]);
+
+ dispatch_sync(new UpdateSearchIndex(
+ elementType: EntryElement::class,
+ elementId: $entry->id,
+ siteId: $siteId,
+ queued: true,
+ ));
+
+ expect(DB::table(Table::SEARCHINDEX)
+ ->where('elementId', $entry->id)
+ ->exists())->toBeTrue()
+ ->and(DB::table(Table::SEARCHINDEXQUEUE)
+ ->where('id', $jobId)
+ ->exists())->toBeFalse();
+});
+
it('handles case with no matching elements', function () {
$job = new UpdateSearchIndex(
elementType: EntryElement::class,
diff --git a/tests/Feature/Search/SearchTest.php b/tests/Feature/Search/SearchTest.php
index 13019ce5eae..1c0d890b80f 100644
--- a/tests/Feature/Search/SearchTest.php
+++ b/tests/Feature/Search/SearchTest.php
@@ -29,16 +29,15 @@
function createIndexedEntry(string $title, ?string $slug = null): EntryModel
{
- $entryModel = EntryModel::factory()->create();
- $entryModel->element->siteSettings->first()->update(array_filter([
- 'title' => $title,
- 'slug' => $slug,
- ]));
+ $factory = EntryModel::factory()
+ ->indexed()
+ ->title($title);
- $element = Elements::getElementById($entryModel->id);
- Search::indexElementAttributes($element);
+ if ($slug !== null) {
+ $factory = $factory->slug($slug);
+ }
- return $entryModel;
+ return $factory->create();
}
describe('indexElementAttributes', function () {
diff --git a/tests/Feature/User/Elements/UserValidationTest.php b/tests/Feature/User/Elements/UserValidationTest.php
index 6f49df31ea1..3198f03fbf2 100644
--- a/tests/Feature/User/Elements/UserValidationTest.php
+++ b/tests/Feature/User/Elements/UserValidationTest.php
@@ -5,7 +5,7 @@
use CraftCms\Cms\Cms;
use CraftCms\Cms\Edition;
use CraftCms\Cms\FieldLayout\FieldLayout;
-use CraftCms\Cms\FieldLayout\LayoutElements\users\FullNameField;
+use CraftCms\Cms\FieldLayout\LayoutElements\Users\FullNameField;
use CraftCms\Cms\User\Elements\User;
use CraftCms\Cms\User\Models\User as UserModel;
use CraftCms\Cms\User\Validation\UserRules;
diff --git a/tests/TestClasses/Element/ElementEagerLoader/TestElementEagerLoaderElement.php b/tests/TestClasses/Element/ElementEagerLoader/TestElementEagerLoaderElement.php
index b33c4a69b01..614d4458323 100644
--- a/tests/TestClasses/Element/ElementEagerLoader/TestElementEagerLoaderElement.php
+++ b/tests/TestClasses/Element/ElementEagerLoader/TestElementEagerLoaderElement.php
@@ -5,7 +5,7 @@
namespace CraftCms\Cms\Tests\TestClasses\Element\ElementEagerLoader;
use Closure;
-use craft\base\ElementInterface;
+use CraftCms\Cms\Element\Contracts\ElementInterface;
use CraftCms\Cms\Element\Element;
use CraftCms\Cms\Element\Queries\ElementQuery;
diff --git a/tests/TestClasses/Element/ElementEagerLoader/TestElementEagerLoaderQuery.php b/tests/TestClasses/Element/ElementEagerLoader/TestElementEagerLoaderQuery.php
index 4305bc19464..8d978881c7f 100644
--- a/tests/TestClasses/Element/ElementEagerLoader/TestElementEagerLoaderQuery.php
+++ b/tests/TestClasses/Element/ElementEagerLoader/TestElementEagerLoaderQuery.php
@@ -4,7 +4,7 @@
namespace CraftCms\Cms\Tests\TestClasses\Element\ElementEagerLoader;
-use craft\base\ElementInterface;
+use CraftCms\Cms\Element\Contracts\ElementInterface;
use CraftCms\Cms\Element\Queries\ElementQuery;
use Illuminate\Support\Collection;
diff --git a/tests/TestClasses/Element/TestDuplicateElementActionElement.php b/tests/TestClasses/Element/TestDuplicateElementActionElement.php
index 752a4084c23..646b1505367 100644
--- a/tests/TestClasses/Element/TestDuplicateElementActionElement.php
+++ b/tests/TestClasses/Element/TestDuplicateElementActionElement.php
@@ -4,7 +4,7 @@
namespace CraftCms\Cms\Tests\TestClasses\Element;
-use craft\base\ElementInterface;
+use CraftCms\Cms\Element\Contracts\ElementInterface;
use CraftCms\Cms\Element\Element;
use CraftCms\Cms\Element\Queries\ElementQuery;
use CraftCms\Cms\Site\Models\Site;
diff --git a/tests/Unit/Asset/AssetsHelperTest.php b/tests/Unit/Asset/AssetsHelperTest.php
index 20f1f0c55c4..a8e3aad9f42 100644
--- a/tests/Unit/Asset/AssetsHelperTest.php
+++ b/tests/Unit/Asset/AssetsHelperTest.php
@@ -3,7 +3,7 @@
declare(strict_types=1);
use CraftCms\Cms\Asset\AssetsHelper;
-use CraftCms\Cms\Asset\Elements\Asset;
+use CraftCms\Cms\Asset\Enums\FileKind;
use CraftCms\Cms\Asset\Events\SetAssetFilename;
use CraftCms\Cms\Cms;
use CraftCms\Cms\Support\Path;
@@ -116,60 +116,60 @@
test('returns correct kind for known extensions', function (string $file, string $expectedKind) {
expect(AssetsHelper::getFileKindByExtension($file))->toBe($expectedKind);
})->with([
- 'jpg image' => ['photo.jpg', Asset::KIND_IMAGE],
- 'jpeg image' => ['photo.jpeg', Asset::KIND_IMAGE],
- 'png image' => ['photo.png', Asset::KIND_IMAGE],
- 'gif image' => ['photo.gif', Asset::KIND_IMAGE],
- 'webp image' => ['photo.webp', Asset::KIND_IMAGE],
- 'svg image' => ['photo.svg', Asset::KIND_IMAGE],
- 'avif image' => ['photo.avif', Asset::KIND_IMAGE],
- 'mp3 audio' => ['song.mp3', Asset::KIND_AUDIO],
- 'wav audio' => ['song.wav', Asset::KIND_AUDIO],
- 'flac audio' => ['song.flac', Asset::KIND_AUDIO],
- 'mp4 video' => ['movie.mp4', Asset::KIND_VIDEO],
- 'mov video' => ['movie.mov', Asset::KIND_VIDEO],
- 'webm video' => ['movie.webm', Asset::KIND_VIDEO],
- 'pdf document' => ['doc.pdf', Asset::KIND_PDF],
- 'json file' => ['data.json', Asset::KIND_JSON],
- 'xml file' => ['data.xml', Asset::KIND_XML],
- 'html file' => ['page.html', Asset::KIND_HTML],
- 'htm file' => ['page.htm', Asset::KIND_HTML],
- 'js file' => ['script.js', Asset::KIND_JAVASCRIPT],
- 'php file' => ['script.php', Asset::KIND_PHP],
- 'txt file' => ['readme.txt', Asset::KIND_TEXT],
- 'zip compressed' => ['archive.zip', Asset::KIND_COMPRESSED],
- 'doc word' => ['document.doc', Asset::KIND_WORD],
- 'docx word' => ['document.docx', Asset::KIND_WORD],
- 'xls excel' => ['sheet.xls', Asset::KIND_EXCEL],
- 'xlsx excel' => ['sheet.xlsx', Asset::KIND_EXCEL],
- 'ppt powerpoint' => ['slides.ppt', Asset::KIND_POWERPOINT],
- 'pptx powerpoint' => ['slides.pptx', Asset::KIND_POWERPOINT],
- 'psd photoshop' => ['design.psd', Asset::KIND_PHOTOSHOP],
- 'ai illustrator' => ['design.ai', Asset::KIND_ILLUSTRATOR],
- 'srt subtitles' => ['subtitles.srt', Asset::KIND_CAPTIONS_SUBTITLES],
- 'vtt subtitles' => ['subtitles.vtt', Asset::KIND_CAPTIONS_SUBTITLES],
- 'accdb access' => ['file.accdb', Asset::KIND_ACCESS],
+ 'jpg image' => ['photo.jpg', FileKind::Image->value],
+ 'jpeg image' => ['photo.jpeg', FileKind::Image->value],
+ 'png image' => ['photo.png', FileKind::Image->value],
+ 'gif image' => ['photo.gif', FileKind::Image->value],
+ 'webp image' => ['photo.webp', FileKind::Image->value],
+ 'svg image' => ['photo.svg', FileKind::Image->value],
+ 'avif image' => ['photo.avif', FileKind::Image->value],
+ 'mp3 audio' => ['song.mp3', FileKind::Audio->value],
+ 'wav audio' => ['song.wav', FileKind::Audio->value],
+ 'flac audio' => ['song.flac', FileKind::Audio->value],
+ 'mp4 video' => ['movie.mp4', FileKind::Video->value],
+ 'mov video' => ['movie.mov', FileKind::Video->value],
+ 'webm video' => ['movie.webm', FileKind::Video->value],
+ 'pdf document' => ['doc.pdf', FileKind::Pdf->value],
+ 'json file' => ['data.json', FileKind::Json->value],
+ 'xml file' => ['data.xml', FileKind::Xml->value],
+ 'html file' => ['page.html', FileKind::Html->value],
+ 'htm file' => ['page.htm', FileKind::Html->value],
+ 'js file' => ['script.js', FileKind::Javascript->value],
+ 'php file' => ['script.php', FileKind::Php->value],
+ 'txt file' => ['readme.txt', FileKind::Text->value],
+ 'zip compressed' => ['archive.zip', FileKind::Compressed->value],
+ 'doc word' => ['document.doc', FileKind::Word->value],
+ 'docx word' => ['document.docx', FileKind::Word->value],
+ 'xls excel' => ['sheet.xls', FileKind::Excel->value],
+ 'xlsx excel' => ['sheet.xlsx', FileKind::Excel->value],
+ 'ppt powerpoint' => ['slides.ppt', FileKind::Powerpoint->value],
+ 'pptx powerpoint' => ['slides.pptx', FileKind::Powerpoint->value],
+ 'psd photoshop' => ['design.psd', FileKind::Photoshop->value],
+ 'ai illustrator' => ['design.ai', FileKind::Illustrator->value],
+ 'srt subtitles' => ['subtitles.srt', FileKind::CaptionsSubtitles->value],
+ 'vtt subtitles' => ['subtitles.vtt', FileKind::CaptionsSubtitles->value],
+ 'accdb access' => ['file.accdb', FileKind::Access->value],
]);
test('returns unknown for unrecognized extensions', function () {
- expect(AssetsHelper::getFileKindByExtension('file.xyz123'))->toBe(Asset::KIND_UNKNOWN);
+ expect(AssetsHelper::getFileKindByExtension('file.xyz123'))->toBe(FileKind::Unknown->value);
});
test('returns unknown for files without extension', function () {
- expect(AssetsHelper::getFileKindByExtension('README'))->toBe(Asset::KIND_UNKNOWN);
+ expect(AssetsHelper::getFileKindByExtension('README'))->toBe(FileKind::Unknown->value);
});
test('returns unknown for bare extension name without dot', function () {
- expect(AssetsHelper::getFileKindByExtension('html'))->toBe(Asset::KIND_UNKNOWN);
+ expect(AssetsHelper::getFileKindByExtension('html'))->toBe(FileKind::Unknown->value);
});
test('is case insensitive', function () {
- expect(AssetsHelper::getFileKindByExtension('photo.JPG'))->toBe(Asset::KIND_IMAGE);
- expect(AssetsHelper::getFileKindByExtension('photo.Png'))->toBe(Asset::KIND_IMAGE);
+ expect(AssetsHelper::getFileKindByExtension('photo.JPG'))->toBe(FileKind::Image->value);
+ expect(AssetsHelper::getFileKindByExtension('photo.Png'))->toBe(FileKind::Image->value);
});
test('handles full paths', function () {
- expect(AssetsHelper::getFileKindByExtension('/path/to/photo.jpg'))->toBe(Asset::KIND_IMAGE);
+ expect(AssetsHelper::getFileKindByExtension('/path/to/photo.jpg'))->toBe(FileKind::Image->value);
});
});
@@ -188,8 +188,8 @@
]);
test('returns unknown for unrecognized kind', function () {
- expect(AssetsHelper::getFileKindLabel('Raaa'))->toBe(Asset::KIND_UNKNOWN);
- expect(AssetsHelper::getFileKindLabel('nonexistent_kind'))->toBe(Asset::KIND_UNKNOWN);
+ expect(AssetsHelper::getFileKindLabel('Raaa'))->toBe(FileKind::Unknown->value);
+ expect(AssetsHelper::getFileKindLabel('nonexistent_kind'))->toBe(FileKind::Unknown->value);
});
});
@@ -372,16 +372,16 @@
expect($kinds)->toHaveKey($kind);
})->with([
- 'image' => [Asset::KIND_IMAGE],
- 'audio' => [Asset::KIND_AUDIO],
- 'video' => [Asset::KIND_VIDEO],
- 'pdf' => [Asset::KIND_PDF],
- 'json' => [Asset::KIND_JSON],
- 'xml' => [Asset::KIND_XML],
- 'compressed' => [Asset::KIND_COMPRESSED],
- 'excel' => [Asset::KIND_EXCEL],
- 'word' => [Asset::KIND_WORD],
- 'powerpoint' => [Asset::KIND_POWERPOINT],
+ 'image' => [FileKind::Image->value],
+ 'audio' => [FileKind::Audio->value],
+ 'video' => [FileKind::Video->value],
+ 'pdf' => [FileKind::Pdf->value],
+ 'json' => [FileKind::Json->value],
+ 'xml' => [FileKind::Xml->value],
+ 'compressed' => [FileKind::Compressed->value],
+ 'excel' => [FileKind::Excel->value],
+ 'word' => [FileKind::Word->value],
+ 'powerpoint' => [FileKind::Powerpoint->value],
]);
test('results are sorted by label', function () {
@@ -393,6 +393,19 @@
expect($labels)->toBe($sorted);
});
+
+ test('it merges in extraFileKinds', function () {
+ AssetsHelper::clear();
+
+ Cms::config()->extraFileKinds = [
+ 'stylesheet' => [
+ 'label' => 'Stylesheet',
+ 'extensions' => ['css', 'less', 'pcss', 'sass', 'scss', 'styl'],
+ ],
+ ];
+
+ expect(AssetsHelper::getFileKinds())->toHaveKey('stylesheet');
+ });
});
describe('getAllowedFileKinds', function () {
@@ -427,7 +440,7 @@
test('image kind is allowed by default', function () {
$allowed = AssetsHelper::getAllowedFileKinds();
- expect($allowed)->toHaveKey(Asset::KIND_IMAGE);
+ expect($allowed)->toHaveKey(FileKind::Image->value);
});
});
diff --git a/tests/Unit/Cp/FormFieldsTest.php b/tests/Unit/Cp/FormFieldsTest.php
index 95d9353ba2c..23a55cb820c 100644
--- a/tests/Unit/Cp/FormFieldsTest.php
+++ b/tests/Unit/Cp/FormFieldsTest.php
@@ -2,9 +2,24 @@
declare(strict_types=1);
+use CraftCms\Cms\Address\Addresses;
+use CraftCms\Cms\Address\Elements\Address;
+use CraftCms\Cms\Address\Validation\AddressRules;
use CraftCms\Cms\Cp\FormFields;
+use CraftCms\Cms\FieldLayout\FieldLayout;
use CraftCms\Cms\Support\Facades\Sites;
use CraftCms\Cms\Twig\Exceptions\TemplateLoaderException;
+use CraftCms\RulesetValidation\Attributes\Ruleset;
+
+#[Ruleset(AddressRules::class)]
+class TestAddressForFormFields extends Address
+{
+ #[Override]
+ public function getFieldLayout(): FieldLayout
+ {
+ return app(Addresses::class)->getFieldLayout();
+ }
+}
describe('fieldHtml', function () {
it('renders the field container and optional label', function () {
@@ -82,3 +97,16 @@
['