diff --git a/composer.json b/composer.json index 4b3ba25fd19..c93ea2e1014 100644 --- a/composer.json +++ b/composer.json @@ -39,6 +39,7 @@ "composer/semver": "^3.3.2", "craftcms/laravel-aliases": "^2.0", "craftcms/laravel-dependency-aware-cache": "^1.1", + "craftcms/laravel-ruleset-validation": "^1.0.1", "craftcms/plugin-installer": "~1.6.0", "craftcms/server-check": "~5.1.0", "craftcms/yii2-adapter": "self.version", diff --git a/composer.lock b/composer.lock index e377d02a76c..17b0f71ec9a 100644 --- a/composer.lock +++ b/composer.lock @@ -4,7 +4,7 @@ "Read more about it at https://getcomposer.org/doc/01-basic-usage.md#installing-dependencies", "This file is @generated automatically" ], - "content-hash": "2c6fc4f0684a0080927ddac26949c945", + "content-hash": "f021fb13ae5803c1ef5aa53145b4e877", "packages": [ { "name": "bacon/bacon-qr-code", @@ -615,6 +615,70 @@ }, "time": "2026-03-17T14:55:31+00:00" }, + { + "name": "craftcms/laravel-ruleset-validation", + "version": "1.0.1", + "source": { + "type": "git", + "url": "https://github.com/craftcms/laravel-ruleset-validation.git", + "reference": "66867ade9c09c6c8165bd62c0ebb7e4843399aab" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/craftcms/laravel-ruleset-validation/zipball/66867ade9c09c6c8165bd62c0ebb7e4843399aab", + "reference": "66867ade9c09c6c8165bd62c0ebb7e4843399aab", + "shasum": "" + }, + "require": { + "illuminate/contracts": "^13.0", + "illuminate/support": "^13.0", + "illuminate/validation": "^13.0", + "php": "^8.4" + }, + "require-dev": { + "larastan/larastan": "^3.0", + "laravel/pint": "^v1.29", + "nunomaduro/collision": "^8.1.1", + "orchestra/testbench": "^11.0", + "pestphp/pest": "^4.0" + }, + "type": "library", + "extra": { + "laravel": { + "providers": [ + "CraftCms\\RulesetValidation\\RulesetValidationServiceProvider" + ] + } + }, + "autoload": { + "psr-4": { + "CraftCms\\RulesetValidation\\": "src/" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Pixel & Tonic", + "homepage": "https://pixelandtonic.com/" + } + ], + "description": "Validate requests and objects with reusable Laravel rulesets.", + "homepage": "https://github.com/craftcms/laravel-ruleset-validation", + "keywords": [ + "craftcms", + "laravel", + "rulesets", + "validation" + ], + "support": { + "issues": "https://github.com/craftcms/laravel-ruleset-validation/issues", + "source": "https://github.com/craftcms/laravel-ruleset-validation/tree/1.0.1" + }, + "time": "2026-04-21T07:56:55+00:00" + }, { "name": "craftcms/plugin-installer", "version": "1.6.0", diff --git a/database/Factories/Concerns/HasFieldFactory.php b/database/Factories/Concerns/HasFieldFactory.php index ed313541d19..634a98ca794 100644 --- a/database/Factories/Concerns/HasFieldFactory.php +++ b/database/Factories/Concerns/HasFieldFactory.php @@ -108,7 +108,7 @@ public function createElementWithFields(array $attributes = [], bool $save = tru $factory->refreshFieldCaches(); $element = $factory->queryElement($model->id); - $element->setScenario($factory->elementScenario ?? Element::SCENARIO_DEFAULT); + $element->ruleset->useScenario($factory->elementScenario ?? Element::SCENARIO_DEFAULT); $element->title = $element->title ?: 'Test entry'; foreach ($factory->fieldConfigs as $config) { diff --git a/src/Address/Elements/Address.php b/src/Address/Elements/Address.php index 13d2e8fb863..85d7a803f50 100644 --- a/src/Address/Elements/Address.php +++ b/src/Address/Elements/Address.php @@ -24,7 +24,7 @@ use CraftCms\Cms\Shared\Concerns\HasNames; use CraftCms\Cms\Twig\Attributes\AllowedInSandbox; use CraftCms\Cms\User\Elements\User; -use CraftCms\Cms\Validation\Attributes\Ruleset; +use CraftCms\RulesetValidation\Attributes\Ruleset; use Deprecated; use Override; use yii\base\InvalidConfigException; @@ -32,6 +32,9 @@ use function CraftCms\Cms\t; +/** + * @property AddressRules $ruleset + */ #[Ruleset(AddressRules::class)] class Address extends Element implements AddressInterface, NestedElementInterface { @@ -471,7 +474,7 @@ public function shouldValidateTitle(): bool } #[Override] - public function beforeValidate(): bool + public function prepareForValidation(): void { $usedFields = array_unique([ ...app(Addresses::class)->getUsedFields($this->countryCode), @@ -490,7 +493,7 @@ public function beforeValidate(): bool $this->$field = null; } - return parent::beforeValidate(); + parent::prepareForValidation(); } #[Override] diff --git a/src/Address/Validation/AddressRules.php b/src/Address/Validation/AddressRules.php index ea0a4300296..7d16d0753b9 100644 --- a/src/Address/Validation/AddressRules.php +++ b/src/Address/Validation/AddressRules.php @@ -7,7 +7,6 @@ use CraftCms\Cms\Address\Addresses; use CraftCms\Cms\Address\Elements\Address; use CraftCms\Cms\Cms; -use CraftCms\Cms\Element\Element; use CraftCms\Cms\Element\Validation\ElementRules; use CraftCms\Cms\FieldLayout\LayoutElements\addresses\LatLongField; use CraftCms\Cms\FieldLayout\LayoutElements\addresses\OrganizationField; @@ -22,7 +21,7 @@ /** * @extends ElementRules
* - * @property Address $component + * @property Address $subject */ class AddressRules extends ElementRules { @@ -56,27 +55,27 @@ class AddressRules extends ElementRules ]; #[Override] - public function prepareForValidation(?array $attributeNames = null): void + public function prepareForValidation(): void { - parent::prepareForValidation($attributeNames); + parent::prepareForValidation(); - $attributesToTrim = is_null($attributeNames) + $attributesToTrim = is_null($this->validationAttributes) ? self::TRIMMABLE_ATTRIBUTES - : array_intersect(self::TRIMMABLE_ATTRIBUTES, $attributeNames); + : array_intersect(self::TRIMMABLE_ATTRIBUTES, $this->validationAttributes); foreach ($attributesToTrim as $attribute) { - $value = $this->component->{$attribute}; + $value = $this->subject->{$attribute}; if (is_string($value)) { - $this->component->{$attribute} = trim($value); + $this->subject->{$attribute} = trim($value); } } } #[Override] - protected function defineRules(): array + public function rules(): array { - $rules = parent::defineRules(); + $rules = parent::rules(); $rules = $this->addAddressAttributeRules($rules); $rules = $this->addCountryCodeValidation($rules); @@ -135,24 +134,24 @@ private function addAddressFormatRequirements(array $rules): array private function isRequiredByAddressFormat(string $attribute): bool { - if (! $this->component->inScenarios(Element::SCENARIO_LIVE)) { + if (! $this->inScenarios(self::SCENARIO_LIVE)) { return false; } $formatter = app(Addresses::class) ->getAddressFormatRepository() - ->get($this->component->countryCode); + ->get($this->subject->countryCode); return in_array($attribute, $formatter->getRequiredFields()); } private function addFieldLayoutRequirements(array $rules): array { - $fieldLayout = $this->component->getFieldLayout(); + $fieldLayout = $this->subject->getFieldLayout(); foreach (self::REQUIRABLE_NATIVE_FIELDS as $fieldClass) { /** @var BaseNativeField|null $field */ - $field = $fieldLayout->getFirstVisibleElementByType($fieldClass, $this->component); + $field = $fieldLayout->getFirstVisibleElementByType($fieldClass, $this->subject); if (! $field?->required) { continue; @@ -160,7 +159,7 @@ private function addFieldLayoutRequirements(array $rules): array foreach ($this->resolveFieldAttributes($field) as $attribute) { $rules[$attribute] ??= []; - $rules[$attribute][] = Rule::requiredIf($this->component->inScenarios(Element::SCENARIO_LIVE)); + $rules[$attribute][] = Rule::requiredIf($this->inScenarios(self::SCENARIO_LIVE)); } } @@ -185,7 +184,7 @@ private function resolveFieldAttributes(BaseNativeField $field): array private function addCoordinateValidation(array $rules): array { - $coordinateScenarios = $this->component->inScenarios(Element::SCENARIO_LIVE, Element::SCENARIO_DEFAULT); + $coordinateScenarios = $this->inScenarios(self::SCENARIO_LIVE, self::SCENARIO_DEFAULT); $rules['latitude'][] = Rule::when($coordinateScenarios, [ 'numeric', diff --git a/src/Asset/AssetIndexer.php b/src/Asset/AssetIndexer.php index 26d49a70881..16039536304 100644 --- a/src/Asset/AssetIndexer.php +++ b/src/Asset/AssetIndexer.php @@ -16,6 +16,7 @@ use CraftCms\Cms\Asset\Exceptions\MissingVolumeFolderException; use CraftCms\Cms\Asset\Exceptions\VolumeException; use CraftCms\Cms\Asset\Models\AssetIndexingSession as AssetIndexingSessionModel; +use CraftCms\Cms\Asset\Validation\AssetRules; use CraftCms\Cms\Cms; use CraftCms\Cms\Database\Table; use CraftCms\Cms\Element\Elements; @@ -630,7 +631,7 @@ public function indexFileByEntry( $asset->size = $indexEntry->size; $timeModified = $indexEntry->timestamp; - $asset->setScenario(Asset::SCENARIO_INDEX); + $asset->ruleset->useScenario(AssetRules::SCENARIO_INDEX); try { if ($isLocalFs) { diff --git a/src/Asset/Assets.php b/src/Asset/Assets.php index be0f453677b..1a09af591aa 100644 --- a/src/Asset/Assets.php +++ b/src/Asset/Assets.php @@ -18,6 +18,7 @@ use CraftCms\Cms\Asset\PreviewHandlers\Pdf; use CraftCms\Cms\Asset\PreviewHandlers\Text; use CraftCms\Cms\Asset\PreviewHandlers\Video; +use CraftCms\Cms\Asset\Validation\AssetRules; use CraftCms\Cms\Cms; use CraftCms\Cms\Database\Table; use CraftCms\Cms\Element\Elements; @@ -94,7 +95,7 @@ public function replaceAssetFile(Asset $asset, string $pathOnServer, string $fil $asset->setMimeType(File::getMimeType($pathOnServer, checkExtension: false) ?? $mimeType); $asset->uploaderId = Auth::user()?->id; $asset->avoidFilenameConflicts = true; - $asset->setScenario(Asset::SCENARIO_REPLACE); + $asset->ruleset->useScenario(AssetRules::SCENARIO_REPLACE); $this->elements->saveElement($asset); event(new AfterReplaceAsset( @@ -118,9 +119,9 @@ public function moveAsset(Asset $asset, VolumeFolder $folder, string $filename = if ($filenameChanging) { $asset->newFilename = $filename; - $asset->setScenario(Asset::SCENARIO_FILEOPS); + $asset->ruleset->useScenario(AssetRules::SCENARIO_FILEOPS); } else { - $asset->setScenario(Asset::SCENARIO_MOVE); + $asset->ruleset->useScenario(AssetRules::SCENARIO_MOVE); } return $this->elements->saveElement($asset); diff --git a/src/Asset/Data/Volume.php b/src/Asset/Data/Volume.php index 32d1978d960..df7143f0a5a 100644 --- a/src/Asset/Data/Volume.php +++ b/src/Asset/Data/Volume.php @@ -19,7 +19,7 @@ use CraftCms\Cms\Support\Env; use CraftCms\Cms\Support\Facades\Filesystems; use CraftCms\Cms\Support\Url; -use CraftCms\Cms\Validation\Attributes\Ruleset; +use CraftCms\RulesetValidation\Attributes\Ruleset; use Illuminate\Filesystem\FilesystemAdapter; use Illuminate\Support\Facades\Auth; use Illuminate\Support\Facades\Log; @@ -123,19 +123,7 @@ public function __construct(array|object $config = []) } #[Override] - public function attributes(): array - { - return array_values(array_unique(array_merge(parent::attributes(), [ - 'fieldLayout', - 'fsHandle', - 'subpath', - 'transformFsHandle', - 'transformSubpath', - ]))); - } - - #[Override] - public function getAttributes(): array + public function validationData(): array { if (is_string($this->name)) { $this->name = trim($this->name); @@ -151,7 +139,7 @@ public function getAttributes(): array $fieldLayout = null; } - return array_merge(parent::getAttributes(), [ + return array_merge(parent::validationData(), [ 'fieldLayout' => $fieldLayout, 'fsHandle' => $this->getFsHandle(false), 'subpath' => $this->getSubpath(ensureTrailing: false, parse: false), diff --git a/src/Asset/Data/VolumeFolder.php b/src/Asset/Data/VolumeFolder.php index 2225f650239..c97792edc7c 100644 --- a/src/Asset/Data/VolumeFolder.php +++ b/src/Asset/Data/VolumeFolder.php @@ -68,20 +68,7 @@ public function getRules(): array } #[Override] - public function attributes(): array - { - return [ - 'id', - 'parentId', - 'volumeId', - 'name', - 'path', - 'uid', - ]; - } - - #[Override] - public function getAttributes(): array + public function validationData(): array { return [ 'id' => $this->id, diff --git a/src/Asset/Elements/Asset.php b/src/Asset/Elements/Asset.php index c96aad600e3..56e39942093 100644 --- a/src/Asset/Elements/Asset.php +++ b/src/Asset/Elements/Asset.php @@ -82,7 +82,7 @@ use CraftCms\Cms\Support\Url; use CraftCms\Cms\Twig\Attributes\AllowedInSandbox; use CraftCms\Cms\User\Elements\User; -use CraftCms\Cms\Validation\Attributes\Ruleset; +use CraftCms\RulesetValidation\Attributes\Ruleset; use DateInterval; use DateTime; use GraphQL\Type\Definition\Type; @@ -147,34 +147,6 @@ class Asset extends Element public const string ERROR_FILENAME_CONFLICT = 'filename_conflict'; - // Validation scenarios - // ------------------------------------------------------------------------- - - /** - * Validation scenario that should be used when the asset is only getting *moved*; not renamed. - */ - public const string SCENARIO_MOVE = 'move'; - - public const string SCENARIO_FILEOPS = 'fileOperations'; - - public const string SCENARIO_INDEX = 'index'; - - public const string SCENARIO_CREATE = 'create'; - - public const string SCENARIO_REPLACE = 'replace'; - - #[Override] - public function scenarios(): array - { - return array_merge(parent::scenarios(), [ - self::SCENARIO_MOVE => null, - self::SCENARIO_FILEOPS => null, - self::SCENARIO_INDEX => [], - self::SCENARIO_CREATE => null, - self::SCENARIO_REPLACE => null, - ]); - } - // File kinds // ------------------------------------------------------------------------- @@ -2943,7 +2915,7 @@ public function afterSave(bool $isNew): void // Are we uploading an image that needs to be sanitized? if ( isset($this->tempFilePath) && - in_array($this->getScenario(), [self::SCENARIO_REPLACE, self::SCENARIO_CREATE], true) && + in_array($this->ruleset->getScenario(), [AssetRules::SCENARIO_REPLACE, AssetRules::SCENARIO_CREATE], true) && AssetsHelper::getFileKindByExtension($this->tempFilePath) === self::KIND_IMAGE && ($this->sanitizeOnUpload ?? ( ! request()->isCpRequest() || @@ -2959,7 +2931,7 @@ public function afterSave(bool $isNew): void $fallbackHeight = null; if ( isset($this->tempFilePath) && - in_array($this->getScenario(), [self::SCENARIO_REPLACE, self::SCENARIO_CREATE], true) && + in_array($this->ruleset->getScenario(), [AssetRules::SCENARIO_REPLACE, AssetRules::SCENARIO_CREATE], true) && AssetsHelper::getFileKindByExtension($this->tempFilePath) === self::KIND_IMAGE ) { $imageSize = getimagesize($this->tempFilePath); @@ -3176,7 +3148,7 @@ private function _dimensions(mixed $transform = null): array if (! $this->_width || ! $this->_height) { if ( $this->kind === self::KIND_IMAGE && - $this->getScenario() !== self::SCENARIO_CREATE + $this->ruleset->getScenario() !== AssetRules::SCENARIO_CREATE ) { Log::warning("Asset $this->id is missing its width or height", [__METHOD__]); } diff --git a/src/Asset/Validation/AssetRules.php b/src/Asset/Validation/AssetRules.php index bd3e5bd78cc..2275e3be49a 100644 --- a/src/Asset/Validation/AssetRules.php +++ b/src/Asset/Validation/AssetRules.php @@ -15,18 +15,35 @@ /** * @extends ElementRules * - * @property Asset $component + * @property Asset $subject */ class AssetRules extends ElementRules { + /** + * Validation scenario that should be used when the asset is only getting *moved*; not renamed. + */ + public const string SCENARIO_MOVE = 'move'; + + public const string SCENARIO_FILEOPS = 'fileOperations'; + + public const string SCENARIO_INDEX = 'index'; + + public const string SCENARIO_CREATE = 'create'; + + public const string SCENARIO_REPLACE = 'replace'; + #[Override] - protected function defineRules(): array + public function rules(): array { - $rules = parent::defineRules(); + if ($this->inScenarios(self::SCENARIO_INDEX)) { + return []; + } + + $rules = parent::rules(); $rules['title'] = [ 'nullable', - Rule::when($this->component->inScenarios(Asset::SCENARIO_CREATE), [ + Rule::when($this->inScenarios(self::SCENARIO_CREATE), [ 'string', 'max:255', new DisallowMb4, @@ -43,30 +60,30 @@ protected function defineRules(): array $rules['newFilename'] = ['nullable']; $rules['kind'] = ['required', 'string', 'max:50']; $rules['alt'] = ['nullable', Rule::requiredIf(function () { - if (! $this->component->inScenarios(Asset::SCENARIO_LIVE)) { + if (! $this->inScenarios(self::SCENARIO_LIVE)) { return false; } - return $this->component + return $this->subject ->getFieldLayout() - ?->getFirstVisibleElementByType(AltField::class, $this->component) + ?->getFirstVisibleElementByType(AltField::class, $this->subject) ->required ?? false; })]; $rules['newLocation'] = [ 'nullable', - Rule::requiredIf($this->component->inScenarios(Asset::SCENARIO_CREATE, Asset::SCENARIO_MOVE, Asset::SCENARIO_FILEOPS)), - Rule::when(! $this->component->inScenarios(Asset::SCENARIO_MOVE), [ - new AssetLocationRule($this->component), + Rule::requiredIf($this->inScenarios(self::SCENARIO_CREATE, self::SCENARIO_MOVE, self::SCENARIO_FILEOPS)), + Rule::when(! $this->inScenarios(self::SCENARIO_MOVE), [ + new AssetLocationRule($this->subject), ]), - Rule::when($this->component->inScenarios(Asset::SCENARIO_MOVE), [ - new AssetLocationRule($this->component, allowedExtensions: '*'), + Rule::when($this->inScenarios(self::SCENARIO_MOVE), [ + new AssetLocationRule($this->subject, allowedExtensions: '*'), ]), ]; $rules['tempFilePath'] = [ 'nullable', - Rule::requiredIf($this->component->inScenarios(Asset::SCENARIO_CREATE, Asset::SCENARIO_REPLACE)), + Rule::requiredIf($this->inScenarios(self::SCENARIO_CREATE, self::SCENARIO_REPLACE)), ]; return $rules; diff --git a/src/Asset/Validation/VolumeRules.php b/src/Asset/Validation/VolumeRules.php index 4455f99d9a8..e0d28660154 100644 --- a/src/Asset/Validation/VolumeRules.php +++ b/src/Asset/Validation/VolumeRules.php @@ -16,21 +16,21 @@ use Illuminate\Database\Eloquent\Builder; use Illuminate\Support\Collection; use Illuminate\Validation\Rule; +use Override; use function CraftCms\Cms\t; /** @extends Ruleset */ class VolumeRules extends Ruleset { - #[\Override] - public function defineRules(): array + public function rules(): array { $rules = [ 'id' => ['nullable', 'integer'], 'fieldLayoutId' => ['nullable', 'integer'], 'name' => [ 'required', - Rule::unique(Table::VOLUMES, 'name')->ignore($this->component->id)->withoutTrashed('dateDeleted'), + Rule::unique(Table::VOLUMES, 'name')->ignore($this->subject->id)->withoutTrashed('dateDeleted'), ], 'handle' => [ 'required', @@ -43,9 +43,9 @@ public function defineRules(): array 'title', 'uid', ]), - Rule::unique(Table::VOLUMES, 'handle')->ignore($this->component->id)->withoutTrashed('dateDeleted'), + Rule::unique(Table::VOLUMES, 'handle')->ignore($this->subject->id)->withoutTrashed('dateDeleted'), ], - 'fieldLayout' => [fn (string $attribute, mixed $value, Closure $fail) => $this->component->validateFieldLayout()], + 'fieldLayout' => [fn (string $attribute, mixed $value, Closure $fail) => $this->subject->validateFieldLayout()], 'fsHandle' => [fn (string $attribute, mixed $value, Closure $fail) => $this->validateFilesystemHandle($attribute, $fail)], 'transformFsHandle' => ['nullable', fn (string $attribute, mixed $value, Closure $fail) => $this->validateFilesystemHandle($attribute, $fail)], 'subpath' => [ @@ -54,7 +54,7 @@ public function defineRules(): array ], ]; - $tempAssetUploadTarget = $this->component->resolveStorageTargetKey(Cms::config()->tempAssetUploadFs); + $tempAssetUploadTarget = $this->subject->resolveStorageTargetKey(Cms::config()->tempAssetUploadFs); if ($tempAssetUploadTarget !== null) { $rules['fsHandle'][] = fn (string $attribute, mixed $value, Closure $fail) => $this->validateReservedTempUploadFilesystem($attribute, $tempAssetUploadTarget, $fail); @@ -64,7 +64,7 @@ public function defineRules(): array return $rules; } - #[\Override] + #[Override] public function messages(): array { return [ @@ -79,7 +79,7 @@ private function validateUniqueSubpath(string $attribute, ?Closure $fail = null) return; } - $subpath = $this->component->getSubpath(ensureTrailing: false, parse: false); + $subpath = $this->subject->getSubpath(ensureTrailing: false, parse: false); if ($subpath === '') { return; } @@ -108,15 +108,15 @@ private function subpathRequired(): bool */ private function volumesSharingStorageTarget(): Collection { - $storageTarget = $this->component->resolveStorageTargetKey($this->component->getFsHandle(false)); + $storageTarget = $this->subject->resolveStorageTargetKey($this->subject->getFsHandle(false)); if ($storageTarget === null) { return collect(); } return VolumeModel::query() - ->when($this->component->id !== null, fn (Builder $query) => $query->whereNot('id', $this->component->id)) + ->when($this->subject->id !== null, fn (Builder $query) => $query->whereNot('id', $this->subject->id)) ->get() - ->filter(fn (VolumeModel $record): bool => $this->component->resolveStorageTargetKey($record->fs) === $storageTarget) + ->filter(fn (VolumeModel $record): bool => $this->subject->resolveStorageTargetKey($record->fs) === $storageTarget) ->values(); } @@ -127,7 +127,7 @@ private function validateFilesystemHandle(string $attribute, ?Closure $fail = nu if ($handle === null || $handle === '') { if ($attribute === 'fsHandle') { $this->pushValidationError($attribute, t('{attribute} cannot be blank.', [ - 'attribute' => $this->component->getAttributeLabel($attribute), + 'attribute' => $this->subject->getAttributeLabel($attribute), ]), $fail); } @@ -144,7 +144,7 @@ private function validateFilesystemHandle(string $attribute, ?Closure $fail = nu return; } - if ($this->component->resolveStorageTargetKey($handle) === null) { + if ($this->subject->resolveStorageTargetKey($handle) === null) { $this->pushValidationError($attribute, t('This filesystem reference is invalid.'), $fail); } } @@ -157,7 +157,7 @@ private function validateReservedTempUploadFilesystem(string $attribute, string return; } - $target = $this->component->resolveStorageTargetKey($handle); + $target = $this->subject->resolveStorageTargetKey($handle); if ($target !== null && $target === $tempUploadTarget) { $this->pushValidationError( $attribute, @@ -170,8 +170,8 @@ private function validateReservedTempUploadFilesystem(string $attribute, string private function storageHandleForAttribute(string $attribute): ?string { return match ($attribute) { - 'fsHandle' => $this->component->getFsHandle(false), - 'transformFsHandle' => $this->component->getTransformFsHandle(false), + 'fsHandle' => $this->subject->getFsHandle(false), + 'transformFsHandle' => $this->subject->getTransformFsHandle(false), default => null, }; } @@ -184,7 +184,7 @@ private function pushValidationError(string $attribute, string $message, ?Closur return; } - $this->component->errors()->add($attribute, $message); + $this->subject->errors()->add($attribute, $message); } private function isInternalDiskReference(string $value): bool diff --git a/src/Component/Component.php b/src/Component/Component.php index bc2100aa2c5..bae3d69cc2f 100644 --- a/src/Component/Component.php +++ b/src/Component/Component.php @@ -7,17 +7,21 @@ use CraftCms\Cms\Component\Contracts\ComponentInterface; use CraftCms\Cms\Component\Exceptions\InvalidCallException; use CraftCms\Cms\Component\Exceptions\UnknownPropertyException; +use CraftCms\Cms\Support\Arr; use CraftCms\Cms\Support\Concerns\MacroableMagicMethods; use CraftCms\Cms\Support\DateTimeHelper; use CraftCms\Cms\Support\Typecast; +use CraftCms\Cms\Validation\ComponentRules; use CraftCms\Cms\Validation\Concerns\Validates; use CraftCms\Cms\Validation\Contracts\Validatable; +use CraftCms\RulesetValidation\Attributes\Ruleset; use DateTimeInterface; use Illuminate\Contracts\Support\Arrayable; use Illuminate\Support\Traits\Macroable; use Yiisoft\Arrays\ArrayableInterface; use Yiisoft\Arrays\ArrayableTrait; +#[Ruleset(ComponentRules::class)] abstract class Component implements Arrayable, ArrayableInterface, ComponentInterface, Validatable { use ArrayableTrait { @@ -67,7 +71,7 @@ public static function isSelectable(): bool public function fields(): array { - $fields = $this->traitFields(); + $fields = Arr::except($this->traitFields(), ['ruleset']); foreach ($fields as $field => $definition) { if (! is_string($definition)) { @@ -97,6 +101,19 @@ public function fields(): array return $fields; } + public function setAttributes($values): void + { + Typecast::properties(static::class, $values); + + foreach ($values as $name => $value) { + try { + $this->$name = $value; + } catch (UnknownPropertyException|InvalidCallException|\yii\base\UnknownPropertyException) { + // Property or setter doesn't exist + } + } + } + public function __get(string $name) { $getter = $this->resolveMagicMethod('get', $name); @@ -153,4 +170,14 @@ public function __unset(string $name): void throw new InvalidCallException('Unsetting an unknown or read-only property: '.static::class.'::'.$name); } + + public function __serialize(): array + { + return $this->toArray(); + } + + public function __unserialize(array $data): void + { + self::configure($this, $data); + } } diff --git a/src/Condition/BaseElementSelectConditionRule.php b/src/Condition/BaseElementSelectConditionRule.php index dd9963ae9a1..6701fddbaff 100644 --- a/src/Condition/BaseElementSelectConditionRule.php +++ b/src/Condition/BaseElementSelectConditionRule.php @@ -51,7 +51,7 @@ public function setAttributes($values, $safeOnly = true): void $values['elementIds'] = Arr::pull($values, 'elementId'); } - parent::setAttributes($values, $safeOnly); + parent::setAttributes($values); } /** diff --git a/src/Cp/FormFields.php b/src/Cp/FormFields.php index a7e41e2187a..0a51f755dd8 100644 --- a/src/Cp/FormFields.php +++ b/src/Cp/FormFields.php @@ -9,7 +9,7 @@ use CraftCms\Cms\Address\Elements\Address; use CraftCms\Cms\Cp\Html\ContentHtml; use CraftCms\Cms\Cp\Html\MenuHtml; -use CraftCms\Cms\Element\Element; +use CraftCms\Cms\Element\Validation\ElementRules; use CraftCms\Cms\Support\Arr; use CraftCms\Cms\Support\Facades\I18N; use CraftCms\Cms\Support\Facades\Markdown; @@ -563,10 +563,10 @@ public static function autosuggestFieldHtml(array $config): string public static function addressFieldsHtml(Address $address, bool $static = false): string { $requiredFields = []; - $scenario = $address->getScenario(); - $address->setScenario(Element::SCENARIO_LIVE); + $scenario = $address->ruleset->getScenario(); + $address->ruleset->useScenario(ElementRules::SCENARIO_LIVE); $activeValidators = $address->getActiveValidators(); - $address->setScenario($scenario); + $address->ruleset->useScenario($scenario); $belongsToCurrentUser = $address->getBelongsToCurrentUser(); foreach ($activeValidators as $validator) { diff --git a/src/Dashboard/Widgets/Widget.php b/src/Dashboard/Widgets/Widget.php index 2fc093755e8..22f1bcf3798 100644 --- a/src/Dashboard/Widgets/Widget.php +++ b/src/Dashboard/Widgets/Widget.php @@ -127,17 +127,11 @@ public function getBodyHtml(): ?string } #[Override] - public function getAttributes(): array + public function validationData(): array { return $this->getSettings(); } - #[Override] - public function attributes(): array - { - return array_keys($this->getSettings()); - } - public static function fromConfig(array|WidgetModel $config): WidgetInterface { if ($config instanceof WidgetModel) { diff --git a/src/Element/Actions/SetStatus.php b/src/Element/Actions/SetStatus.php index 94d24184333..d51cb090c06 100644 --- a/src/Element/Actions/SetStatus.php +++ b/src/Element/Actions/SetStatus.php @@ -7,6 +7,7 @@ use craft\base\ElementInterface; use CraftCms\Cms\Element\Element; use CraftCms\Cms\Element\Queries\Contracts\ElementQueryInterface; +use CraftCms\Cms\Element\Validation\ElementRules; use CraftCms\Cms\Support\Facades\Elements; use CraftCms\Cms\Support\Facades\HtmlStack; use CraftCms\Cms\Support\Facades\Sites; @@ -87,7 +88,7 @@ public function performAction(ElementQueryInterface $query): bool $element->enabled = true; $element->setEnabledForSite(true); - $element->setScenario(Element::SCENARIO_LIVE); + $element->ruleset->useScenario(ElementRules::SCENARIO_LIVE); break; case self::DISABLED: diff --git a/src/Element/Commands/Resave/ResaveCommand.php b/src/Element/Commands/Resave/ResaveCommand.php index dc75f08d127..27591b6943f 100644 --- a/src/Element/Commands/Resave/ResaveCommand.php +++ b/src/Element/Commands/Resave/ResaveCommand.php @@ -15,6 +15,7 @@ use CraftCms\Cms\Element\Exceptions\InvalidElementException; use CraftCms\Cms\Element\Jobs\ResaveElements as ResaveElementsJob; use CraftCms\Cms\Element\Queries\Contracts\ElementQueryInterface; +use CraftCms\Cms\Element\Validation\ElementRules; use CraftCms\Cms\Field\Fields; use CraftCms\Cms\FieldLayout\FieldLayout; use CraftCms\Cms\Support\Facades\Elements; @@ -364,7 +365,7 @@ private function runResaveLoop(ElementQueryInterface $query): int $shouldSet = false; } } elseif ($ifInvalid) { - $element->setScenario(Element::SCENARIO_LIVE); + $element->ruleset->useScenario(ElementRules::SCENARIO_LIVE); if ($element->validate($set) && $element->validate("field:$set")) { $shouldSet = false; diff --git a/src/Element/Concerns/DisplayedInIndex.php b/src/Element/Concerns/DisplayedInIndex.php index 29a2979830f..16f47461760 100644 --- a/src/Element/Concerns/DisplayedInIndex.php +++ b/src/Element/Concerns/DisplayedInIndex.php @@ -20,6 +20,7 @@ use CraftCms\Cms\Element\Queries\Contracts\ElementQueryInterface; use CraftCms\Cms\Element\Queries\ElementQuery; use CraftCms\Cms\Element\Queries\ExcludeDescendantIdsExpression; +use CraftCms\Cms\Element\Validation\ElementRules; use CraftCms\Cms\FieldLayout\FieldLayout; use CraftCms\Cms\Support\Arr; use CraftCms\Cms\Support\Facades\ElementSources; @@ -236,7 +237,7 @@ public static function indexHtml( if (request()->boolean('prevalidate')) { foreach ($elements as $element) { if ($element->enabled && $element->getEnabledForSite()) { - $element->setScenario(Element::SCENARIO_LIVE); + $element->ruleset->useScenario(ElementRules::SCENARIO_LIVE); } $element->validate(); } diff --git a/src/Element/Concerns/Eagerloadable.php b/src/Element/Concerns/Eagerloadable.php index d46557beb94..58cb43e1a9e 100644 --- a/src/Element/Concerns/Eagerloadable.php +++ b/src/Element/Concerns/Eagerloadable.php @@ -17,8 +17,8 @@ use CraftCms\Cms\User\Elements\User; use Illuminate\Database\Query\Builder; use Illuminate\Support\Facades\DB; +use Throwable; use Tpetry\QueryExpressions\Language\Alias; -use yii\base\InvalidConfigException; /** * Eagerloadable provides eager loading functionality for elements. @@ -678,7 +678,7 @@ private function providerHandle(): ?string { try { return $this->getFieldLayout()?->provider?->getHandle(); - } catch (InvalidConfigException) { + } catch (Throwable) { return null; } } diff --git a/src/Element/Drafts.php b/src/Element/Drafts.php index 2e91c0ce085..c837d69318e 100644 --- a/src/Element/Drafts.php +++ b/src/Element/Drafts.php @@ -14,6 +14,7 @@ use CraftCms\Cms\Element\Events\DraftCreated; use CraftCms\Cms\Element\Exceptions\InvalidElementException; use CraftCms\Cms\Element\Queries\Contracts\ElementQueryInterface; +use CraftCms\Cms\Element\Validation\ElementRules; use CraftCms\Cms\Support\Arr; use CraftCms\Cms\Support\Facades\Structures; use CraftCms\Cms\User\Elements\User; @@ -317,7 +318,7 @@ public function removeDraftData(ElementInterface $draft): void $draft->firstSave = true; // We still need to validate so the SlugValidator gets run - $draft->setScenario(Element::SCENARIO_ESSENTIALS); + $draft->ruleset->useScenario(ElementRules::SCENARIO_ESSENTIALS); $draft->validate(); // If there are any errors on the URI, re-validate as disabled diff --git a/src/Element/Element.php b/src/Element/Element.php index 121a2d02ca5..886423c48c9 100644 --- a/src/Element/Element.php +++ b/src/Element/Element.php @@ -16,8 +16,8 @@ use CraftCms\Cms\Support\Str; use CraftCms\Cms\Support\Utils; use CraftCms\Cms\Twig\Attributes\AllowedInSandbox; -use CraftCms\Cms\Validation\Attributes\Ruleset; use CraftCms\Cms\Validation\Concerns\Validates; +use CraftCms\RulesetValidation\Attributes\Ruleset; use DateTime; use Illuminate\Support\Facades\Validator as ValidatorFacade; use Illuminate\Support\Traits\Macroable; @@ -33,6 +33,8 @@ /** * Element is the base class for classes representing elements in terms of objects. + * + * @property ElementRules $ruleset */ #[Ruleset(ElementRules::class)] abstract class Element extends Component implements ElementInterface @@ -74,15 +76,6 @@ abstract class Element extends Component implements ElementInterface */ public const string HOMEPAGE_URI = '__home__'; - // Validation scenarios - // ------------------------------------------------------------------------- - - public const string SCENARIO_DEFAULT = 'default'; - - public const string SCENARIO_ESSENTIALS = 'essentials'; - - public const string SCENARIO_LIVE = 'live'; - /** * @var int|null The element's ID */ @@ -186,19 +179,6 @@ abstract class Element extends Component implements ElementInterface */ public bool $hardDelete = false; - /** - * @return array|null> - */ - #[Override] - public function scenarios(): array - { - return [ - self::SCENARIO_DEFAULT => null, - self::SCENARIO_LIVE => null, - self::SCENARIO_ESSENTIALS => null, - ]; - } - #[Override] public static function displayName(): string { @@ -409,7 +389,7 @@ public function init(): void * @TODO: Remove parameters once Element no longer extends Yii Model */ #[Override] - public function getAttributes($names = null, $except = []): array + public function validationData($names = null, $except = []): array { $attributes = $this->attributes(); $values = []; @@ -628,7 +608,7 @@ protected function validateCustomFields(): void return; } - $scenario = $this->getScenario(); + $scenario = $this->ruleset->getScenario(); $layoutElements = $fieldLayout->getEditableCustomFieldElements($this); foreach ($layoutElements as $layoutElement) { @@ -642,7 +622,7 @@ protected function validateCustomFields(): void $isEmpty = fn () => $field->isValueEmpty($this->getFieldValue($field->handle), $this); $rules = []; - if ($scenario === self::SCENARIO_LIVE && $layoutElement->required) { + if ($scenario === ElementRules::SCENARIO_LIVE && $layoutElement->required) { $rules[] = function ($attribute, $value, $fail) use ($isEmpty) { if ($isEmpty()) { $fail(t('validation.required')); @@ -730,6 +710,6 @@ public function setAttributesFromRequest(array $values): void #[Override] public function safeAttributes(): array { - return array_keys($this->getRuleset()->rules()); + return array_keys($this->ruleset->rules()); } } diff --git a/src/Element/Jobs/ApplyNewPropagationMethod.php b/src/Element/Jobs/ApplyNewPropagationMethod.php index b09e23bc622..879dbfb33b2 100644 --- a/src/Element/Jobs/ApplyNewPropagationMethod.php +++ b/src/Element/Jobs/ApplyNewPropagationMethod.php @@ -8,6 +8,7 @@ use CraftCms\Cms\Database\Table; use CraftCms\Cms\Element\Element; use CraftCms\Cms\Element\ElementHelper; +use CraftCms\Cms\Element\Validation\ElementRules; use CraftCms\Cms\Queue\BatchedJob; use CraftCms\Cms\Structure\Enums\Mode; use CraftCms\Cms\Support\Facades\Elements; @@ -190,7 +191,7 @@ protected function defaultDescription(): string private function resaveItem(ElementInterface $item): void { - $item->setScenario(Element::SCENARIO_ESSENTIALS); + $item->ruleset->useScenario(ElementRules::SCENARIO_ESSENTIALS); $item->resaving = true; try { diff --git a/src/Element/Jobs/PropagateElements.php b/src/Element/Jobs/PropagateElements.php index 9bee7fa109a..ff802f41719 100644 --- a/src/Element/Jobs/PropagateElements.php +++ b/src/Element/Jobs/PropagateElements.php @@ -7,6 +7,7 @@ use craft\base\ElementInterface; use CraftCms\Cms\Element\Element; use CraftCms\Cms\Element\ElementHelper; +use CraftCms\Cms\Element\Validation\ElementRules; use CraftCms\Cms\Queue\BatchedElementJob; use CraftCms\Cms\Support\Arr; use CraftCms\Cms\Support\Facades\Elements; @@ -70,7 +71,7 @@ protected function getQuery(): Builder protected function processElement(ElementInterface $element): void { - $element->setScenario(Element::SCENARIO_ESSENTIALS); + $element->ruleset->useScenario(ElementRules::SCENARIO_ESSENTIALS); $element->newSiteIds = []; $element->isNewSite = $this->isNewSite; $supportedSiteIds = array_map(fn ($siteInfo) => $siteInfo['siteId'], ElementHelper::supportedSitesForElement($element)); diff --git a/src/Element/Jobs/ResaveElements.php b/src/Element/Jobs/ResaveElements.php index d5c28f34c67..e637b7dce13 100644 --- a/src/Element/Jobs/ResaveElements.php +++ b/src/Element/Jobs/ResaveElements.php @@ -8,6 +8,7 @@ use CraftCms\Cms\Element\Commands\Resave\ResaveCommand; use CraftCms\Cms\Element\Element; use CraftCms\Cms\Element\ElementHelper; +use CraftCms\Cms\Element\Validation\ElementRules; use CraftCms\Cms\Queue\BatchedElementJob; use CraftCms\Cms\Support\Facades\Elements; use CraftCms\Cms\Support\Facades\I18N; @@ -53,7 +54,7 @@ protected function processElement(ElementInterface $element): void $set = false; } } elseif ($this->ifInvalid) { - $element->setScenario(Element::SCENARIO_LIVE); + $element->ruleset->useScenario(ElementRules::SCENARIO_LIVE); if ($element->validate($this->set) && $element->validate("field:$this->set")) { $set = false; @@ -66,7 +67,7 @@ protected function processElement(ElementInterface $element): void } } - $element->setScenario(Element::SCENARIO_ESSENTIALS); + $element->ruleset->useScenario(ElementRules::SCENARIO_ESSENTIALS); $element->resaving = true; try { diff --git a/src/Element/NestedElementManager.php b/src/Element/NestedElementManager.php index f4150ba04e4..5537ac9dd72 100644 --- a/src/Element/NestedElementManager.php +++ b/src/Element/NestedElementManager.php @@ -21,6 +21,7 @@ use CraftCms\Cms\Element\Events\CreateNestedElementRevisions; use CraftCms\Cms\Element\Events\DuplicateNestedElementsEvent; use CraftCms\Cms\Element\Queries\Contracts\ElementQueryInterface; +use CraftCms\Cms\Element\Validation\ElementRules; use CraftCms\Cms\Field\Contracts\FieldInterface; use CraftCms\Cms\Shared\Enums\Color; use CraftCms\Cms\Site\Data\Site; @@ -319,7 +320,7 @@ function (string $id, array $config, $attribute, &$settings) use ($owner) { if ($this->hasErrors($owner)) { foreach ($elements as $element) { if ($element->enabled && $element->getEnabledForSite()) { - $element->setScenario(Element::SCENARIO_LIVE); + $element->ruleset->useScenario(ElementRules::SCENARIO_LIVE); } $element->validate(); } diff --git a/src/Element/Operations/ElementDeletions.php b/src/Element/Operations/ElementDeletions.php index b4d5d54beb4..2ad9859e046 100644 --- a/src/Element/Operations/ElementDeletions.php +++ b/src/Element/Operations/ElementDeletions.php @@ -7,7 +7,6 @@ use craft\base\ElementInterface; use craft\behaviors\CustomFieldBehavior; use CraftCms\Cms\Database\Table; -use CraftCms\Cms\Element\Element; use CraftCms\Cms\Element\ElementCaches; use CraftCms\Cms\Element\ElementCollection; use CraftCms\Cms\Element\ElementHelper; @@ -21,6 +20,7 @@ use CraftCms\Cms\Element\Events\BeforeRestoreElement; use CraftCms\Cms\Element\Exceptions\UnsupportedSiteException; use CraftCms\Cms\Element\Queries\Exceptions\ElementNotFoundException; +use CraftCms\Cms\Element\Validation\ElementRules; use CraftCms\Cms\Field\BaseRelationField; use CraftCms\Cms\Search\Jobs\FindAndReplace; use CraftCms\Cms\Search\Search; @@ -402,7 +402,7 @@ public function restoreElements(array $elements): bool $siteElements = []; } - $element->setScenario(Element::SCENARIO_ESSENTIALS); + $element->ruleset->useScenario(ElementRules::SCENARIO_ESSENTIALS); if (! $element->validate()) { Log::warning("Unable to restore element $element->id: doesn't pass essential validation: ".print_r($element->errors, true), [__METHOD__]); DB::rollBack(); @@ -415,7 +415,7 @@ public function restoreElements(array $elements): bool continue; } - $siteElement->setScenario(Element::SCENARIO_ESSENTIALS); + $siteElement->ruleset->useScenario(ElementRules::SCENARIO_ESSENTIALS); if (! $siteElement->validate()) { Log::warning("Unable to restore element $element->id: doesn't pass essential validation for site $element->siteId: ".print_r($element->errors, true), [__METHOD__]); diff --git a/src/Element/Operations/ElementDuplicates.php b/src/Element/Operations/ElementDuplicates.php index 8afb29e964a..d3142c9a152 100644 --- a/src/Element/Operations/ElementDuplicates.php +++ b/src/Element/Operations/ElementDuplicates.php @@ -7,10 +7,10 @@ use craft\base\ElementInterface; use CraftCms\Cms\Database\Table; use CraftCms\Cms\Element\Drafts; -use CraftCms\Cms\Element\Element; use CraftCms\Cms\Element\ElementHelper; use CraftCms\Cms\Element\Exceptions\InvalidElementException; use CraftCms\Cms\Element\Exceptions\UnsupportedSiteException; +use CraftCms\Cms\Element\Validation\ElementRules; use CraftCms\Cms\Shared\Exceptions\OperationAbortedException; use CraftCms\Cms\Structure\Enums\Mode; use CraftCms\Cms\Structure\Structures; @@ -129,7 +129,7 @@ public function duplicateElement( ); } - $mainClone->setScenario(Element::SCENARIO_ESSENTIALS); + $mainClone->ruleset->useScenario(ElementRules::SCENARIO_ESSENTIALS); $mainClone->validate(); if ($mainClone->errors()->has('uri') && $mainClone->enabled) { diff --git a/src/Element/Operations/ElementWrites.php b/src/Element/Operations/ElementWrites.php index 41946ca626a..6400971d0b8 100644 --- a/src/Element/Operations/ElementWrites.php +++ b/src/Element/Operations/ElementWrites.php @@ -7,7 +7,6 @@ use craft\base\ElementInterface; use craft\base\NestedElementInterface; use CraftCms\Cms\Database\Table; -use CraftCms\Cms\Element\Element; use CraftCms\Cms\Element\ElementCaches; use CraftCms\Cms\Element\ElementHelper; use CraftCms\Cms\Element\Elements; @@ -30,6 +29,7 @@ use CraftCms\Cms\Element\Queries\Contracts\ElementQueryInterface; use CraftCms\Cms\Element\Queries\Exceptions\ElementNotFoundException; use CraftCms\Cms\Element\Queries\Exceptions\QueryAbortedException; +use CraftCms\Cms\Element\Validation\ElementRules; use CraftCms\Cms\FieldLayout\FieldLayout; use CraftCms\Cms\Search\Search; use CraftCms\Cms\Shared\Exceptions\OperationAbortedException; @@ -142,7 +142,7 @@ public function resaveElements( $query->each(function (ElementInterface $element) use ($continueOnError, $query, &$position, $skipRevisions, $touch, $updateSearchIndex) { $position++; - $element->setScenario(Element::SCENARIO_ESSENTIALS); + $element->ruleset->useScenario(ElementRules::SCENARIO_ESSENTIALS); $element->resaving = true; $throwable = null; @@ -207,7 +207,7 @@ public function propagateElements( event(new BeforePropagateElement($query, $element, $position)); - $element->setScenario(Element::SCENARIO_ESSENTIALS); + $element->ruleset->useScenario(ElementRules::SCENARIO_ESSENTIALS); $supportedSites = Arr::keyBy(ElementHelper::supportedSitesForElement($element), 'siteId'); $supportedSiteIds = array_keys($supportedSites); $elementSiteIds = $siteIds !== null ? array_intersect($siteIds, @@ -308,401 +308,406 @@ protected function saveInternal( ?ElementSiteSettings &$siteSettingsRecord = null, ?bool $inheritedUpdateSearchIndex = null, ): bool { - $isNewElement = ! $element->id; - $trackChanges = ElementHelper::shouldTrackChanges($element); - - $propagate = $propagate && $element::isLocalized() && $this->sites->isMultiSite(); - $originalPropagateAll = $element->propagateAll; - $originalFirstSave = $element->firstSave; - $originalIsNewForSite = $element->isNewForSite; - $originalDateUpdated = $element->dateUpdated; - $dirtyAttributes = []; - - $element->firstSave = ( - ! $element->getIsDraft() && - ! $element->getIsRevision() && - ($element->firstSave || $isNewElement) - ); + $originalScenario = $element->ruleset->getScenario(); + try { + $isNewElement = ! $element->id; + $trackChanges = ElementHelper::shouldTrackChanges($element); + + $propagate = $propagate && $element::isLocalized() && $this->sites->isMultiSite(); + $originalPropagateAll = $element->propagateAll; + $originalFirstSave = $element->firstSave; + $originalIsNewForSite = $element->isNewForSite; + $originalDateUpdated = $element->dateUpdated; + $dirtyAttributes = []; + + $element->firstSave = ( + ! $element->getIsDraft() && + ! $element->getIsRevision() && + ($element->firstSave || $isNewElement) + ); - if ($isNewElement) { - $element->uid ??= Str::uuid()->toString(); + if ($isNewElement) { + $element->uid ??= Str::uuid()->toString(); - if (! $element->getIsDraft() && ! $element->getIsRevision()) { - $element->propagateAll = true; + if (! $element->getIsDraft() && ! $element->getIsRevision()) { + $element->propagateAll = true; + } } - } - event($event = new BeforeSaveElement($element, $isNewElement)); + event($event = new BeforeSaveElement($element, $isNewElement)); - if (! $event->isValid || ! $element->beforeSave($isNewElement)) { - $this->resetElement($element, $originalFirstSave, $originalIsNewForSite, $originalPropagateAll); - - return false; - } - - $supportedSites ??= Arr::keyBy(ElementHelper::supportedSitesForElement($element), 'siteId'); - - if (! isset($supportedSites[$element->siteId])) { - $this->resetElement($element, $originalFirstSave, $originalIsNewForSite, $originalPropagateAll); + if (! $event->isValid || ! $element->beforeSave($isNewElement)) { + $this->resetElement($element, $originalFirstSave, $originalIsNewForSite, $originalPropagateAll); - throw new UnsupportedSiteException($element, $element->siteId, - 'Attempting to save an element in an unsupported site.'); - } + return false; + } - if (count($supportedSites) === 1 && ! $element->getEnabledForSite()) { - $element->enabled = false; - $element->setEnabledForSite(true); - } + $supportedSites ??= Arr::keyBy(ElementHelper::supportedSitesForElement($element), 'siteId'); - if (! $runValidation && $element::hasTitles()) { - $element->validate('title'); + if (! isset($supportedSites[$element->siteId])) { + $this->resetElement($element, $originalFirstSave, $originalIsNewForSite, $originalPropagateAll); - if ($element->errors()->has('title')) { - $element->title = $isNewElement - ? t('New {type}', ['type' => $element::displayName()]) - : $element::displayName().' '.$element->id; + throw new UnsupportedSiteException($element, $element->siteId, + 'Attempting to save an element in an unsupported site.'); } - } - $fieldLayout = $element->getFieldLayout(); - $dirtyFields = $element->getDirtyFields(); - - if (! $isNewElement && ! $element->isNewForSite) { - $siteSettingsRecord = ElementSiteSettings::query() - ->where('elementId', $element->id) - ->where('siteId', $element->siteId) - ->first(); - } - - $element->isNewForSite = $siteSettingsRecord === null; - - if ($runValidation) { - if ($element->propagating && ! ( - $element->getIsDerivative() && - $element->getIsDraft() && - $element->getEnabledForSite() && - ! $element->getCanonical()->getEnabledForSite() - )) { - $names = array_map( - fn (string $handle) => "field:$handle", - array_unique(array_merge($dirtyFields, $element->getModifiedFields())), - ); - } else { - $names = null; + if (count($supportedSites) === 1 && ! $element->getEnabledForSite()) { + $element->enabled = false; + $element->setEnabledForSite(true); } - if (($names === null || ! empty($names)) && ! $element->validate($names)) { - Log::info('Element not saved due to validation error: '.print_r($element->errors, true), [__METHOD__]); - $this->resetElement($element, $originalFirstSave, $originalIsNewForSite, $originalPropagateAll); + if (! $runValidation && $element::hasTitles()) { + $element->validate('title'); - return false; + if ($element->errors()->has('title')) { + $element->title = $isNewElement + ? t('New {type}', ['type' => $element::displayName()]) + : $element::displayName().' '.$element->id; + } } - } - - $success = BulkOps::ensure(function () use ( - $element, - $isNewElement, - $forceTouch, - $saveContent, - $updateSearchIndex, - $fieldLayout, - $propagate, - $supportedSites, - $crossSiteValidate, - $runValidation, - $dirtyFields, - $trackChanges, - $originalFirstSave, - $originalIsNewForSite, - $originalPropagateAll, - $originalDateUpdated, - $inheritedUpdateSearchIndex, - &$dirtyAttributes, - &$siteSettingsRecord, - ) { - $resolvedUpdateSearchIndex = $updateSearchIndex ?? $inheritedUpdateSearchIndex ?? true; - $newSiteIds = $element->newSiteIds; - $element->newSiteIds = []; - DB::beginTransaction(); + $fieldLayout = $element->getFieldLayout(); + $dirtyFields = $element->getDirtyFields(); - try { - $this->updateModel($element, $isNewElement, $forceTouch, $fieldLayout, $trackChanges, $dirtyAttributes); + if (! $isNewElement && ! $element->isNewForSite) { + $siteSettingsRecord = ElementSiteSettings::query() + ->where('elementId', $element->id) + ->where('siteId', $element->siteId) + ->first(); + } - if ($siteSettingsRecord === null) { - $siteSettingsRecord = new ElementSiteSettings; - $siteSettingsRecord->elementId = $element->id; - $siteSettingsRecord->siteId = $element->siteId; + $element->isNewForSite = $siteSettingsRecord === null; + + if ($runValidation) { + if ($element->propagating && ! ( + $element->getIsDerivative() && + $element->getIsDraft() && + $element->getEnabledForSite() && + ! $element->getCanonical()->getEnabledForSite() + )) { + $names = array_map( + fn (string $handle) => "field:$handle", + array_unique(array_merge($dirtyFields, $element->getModifiedFields())), + ); + } else { + $names = null; } - $title = $element::hasTitles() ? $element->title : null; - $siteSettingsRecord->title = $title !== null && $title !== '' ? $title : null; - $siteSettingsRecord->slug = $element->slug; - $siteSettingsRecord->uri = $element->uri; + if (($names === null || ! empty($names)) && ! $element->validate($names)) { + Log::info('Element not saved due to validation error: '.print_r($element->errors, true), [__METHOD__]); + $this->resetElement($element, $originalFirstSave, $originalIsNewForSite, $originalPropagateAll); - $enabledForSite = $element->getEnabledForSite(); - if (! $siteSettingsRecord->exists || $siteSettingsRecord->enabled !== $enabledForSite) { - $siteSettingsRecord->enabled = $enabledForSite; + return false; } + } - if ($trackChanges && ! $element->isNewForSite) { - array_push($dirtyAttributes, ...array_keys(Arr::only($siteSettingsRecord->getDirty(), [ - 'slug', - 'uri', - ]))); - if ($siteSettingsRecord->isDirty('enabled')) { - $dirtyAttributes[] = 'enabledForSite'; + $success = BulkOps::ensure(function () use ( + $element, + $isNewElement, + $forceTouch, + $saveContent, + $updateSearchIndex, + $fieldLayout, + $propagate, + $supportedSites, + $crossSiteValidate, + $runValidation, + $dirtyFields, + $trackChanges, + $originalFirstSave, + $originalIsNewForSite, + $originalPropagateAll, + $originalDateUpdated, + $inheritedUpdateSearchIndex, + &$dirtyAttributes, + &$siteSettingsRecord, + ) { + $resolvedUpdateSearchIndex = $updateSearchIndex ?? $inheritedUpdateSearchIndex ?? true; + $newSiteIds = $element->newSiteIds; + $element->newSiteIds = []; + + DB::beginTransaction(); + + try { + $this->updateModel($element, $isNewElement, $forceTouch, $fieldLayout, $trackChanges, $dirtyAttributes); + + if ($siteSettingsRecord === null) { + $siteSettingsRecord = new ElementSiteSettings; + $siteSettingsRecord->elementId = $element->id; + $siteSettingsRecord->siteId = $element->siteId; } - } - $saveContent = $saveContent || $element->isNewForSite; - $generatedFields = $fieldLayout?->getGeneratedFields() ?? []; + $title = $element::hasTitles() ? $element->title : null; + $siteSettingsRecord->title = $title !== null && $title !== '' ? $title : null; + $siteSettingsRecord->slug = $element->slug; + $siteSettingsRecord->uri = $element->uri; - if ($saveContent || ! empty($dirtyFields) || ! empty($generatedFields)) { - $oldContent = $siteSettingsRecord->content ?? []; - if (is_string($oldContent)) { - $oldContent = $oldContent !== '' ? Json::decode($oldContent) : []; + $enabledForSite = $element->getEnabledForSite(); + if (! $siteSettingsRecord->exists || $siteSettingsRecord->enabled !== $enabledForSite) { + $siteSettingsRecord->enabled = $enabledForSite; } - $content = []; - $validUids = []; + if ($trackChanges && ! $element->isNewForSite) { + array_push($dirtyAttributes, ...array_keys(Arr::only($siteSettingsRecord->getDirty(), [ + 'slug', + 'uri', + ]))); + if ($siteSettingsRecord->isDirty('enabled')) { + $dirtyAttributes[] = 'enabledForSite'; + } + } - if ($fieldLayout) { - foreach ($fieldLayout->getCustomFields() as $field) { - $validUids[$field->layoutElement->uid] = true; + $saveContent = $saveContent || $element->isNewForSite; + $generatedFields = $fieldLayout?->getGeneratedFields() ?? []; - if (($saveContent || in_array($field->handle, $dirtyFields)) && $field::dbType() !== null) { - $value = $element->getFieldValue($field->handle); - if ($element->isNewForSite && $field->isValueEmpty($value, $element)) { - continue; - } - $serializedValue = $field->serializeValueForDb($value, $element); - if ($serializedValue !== null) { - $content[$field->layoutElement->uid] = $serializedValue; - } elseif (! $saveContent) { - unset($oldContent[$field->layoutElement->uid]); + if ($saveContent || ! empty($dirtyFields) || ! empty($generatedFields)) { + $oldContent = $siteSettingsRecord->content ?? []; + if (is_string($oldContent)) { + $oldContent = $oldContent !== '' ? Json::decode($oldContent) : []; + } + + $content = []; + $validUids = []; + + if ($fieldLayout) { + foreach ($fieldLayout->getCustomFields() as $field) { + $validUids[$field->layoutElement->uid] = true; + + if (($saveContent || in_array($field->handle, $dirtyFields)) && $field::dbType() !== null) { + $value = $element->getFieldValue($field->handle); + if ($element->isNewForSite && $field->isValueEmpty($value, $element)) { + continue; + } + $serializedValue = $field->serializeValueForDb($value, $element); + if ($serializedValue !== null) { + $content[$field->layoutElement->uid] = $serializedValue; + } elseif (! $saveContent) { + unset($oldContent[$field->layoutElement->uid]); + } } } - } - if ($oldContent) { - foreach ($generatedFields as $field) { - if (isset($oldContent[$field['uid']])) { - $content[$field['uid']] = $oldContent[$field['uid']]; + if ($oldContent) { + foreach ($generatedFields as $field) { + if (isset($oldContent[$field['uid']])) { + $content[$field['uid']] = $oldContent[$field['uid']]; + } } } } - } - if (! $saveContent && $oldContent) { - foreach ($oldContent as $uid => $value) { - if (! isset($content[$uid]) && isset($validUids[$uid])) { - $content[$uid] = $value; + if (! $saveContent && $oldContent) { + foreach ($oldContent as $uid => $value) { + if (! isset($content[$uid]) && isset($validUids[$uid])) { + $content[$uid] = $value; + } } } + + $siteSettingsRecord->content = $content ?: null; } - $siteSettingsRecord->content = $content ?: null; - } + if (! $siteSettingsRecord->save()) { + $this->resetElement($element, $originalFirstSave, $originalIsNewForSite, $originalPropagateAll); - if (! $siteSettingsRecord->save()) { - $this->resetElement($element, $originalFirstSave, $originalIsNewForSite, $originalPropagateAll); + throw new Exception('Couldn’t save elements’ site settings record.'); + } - throw new Exception('Couldn’t save elements’ site settings record.'); - } + $element->siteSettingsId = $siteSettingsRecord->id; - $element->siteSettingsId = $siteSettingsRecord->id; + if ($trackChanges) { + array_push($dirtyAttributes, ...$element->getDirtyAttributes()); + $element->setDirtyAttributes($dirtyAttributes, false); + } - if ($trackChanges) { - array_push($dirtyAttributes, ...$element->getDirtyAttributes()); - $element->setDirtyAttributes($dirtyAttributes, false); - } + $element->afterSave($isNewElement); - $element->afterSave($isNewElement); + $dirtyAttributes = $element->getDirtyAttributes(); - $dirtyAttributes = $element->getDirtyAttributes(); + $siteElements = []; + $siteSettingsRecords = []; - $siteElements = []; - $siteSettingsRecords = []; + if ($propagate) { + $otherSiteIds = array_keys(Arr::except($supportedSites, $element->siteId)); - if ($propagate) { - $otherSiteIds = array_keys(Arr::except($supportedSites, $element->siteId)); + if (! empty($otherSiteIds)) { + if (! $isNewElement) { + $siteElements = $element->getLocalizedQuery() + ->siteId($otherSiteIds) + ->status(null) + ->indexBy('siteId') + ->all(); + } - if (! empty($otherSiteIds)) { - if (! $isNewElement) { - $siteElements = $element->getLocalizedQuery() - ->siteId($otherSiteIds) - ->status(null) - ->indexBy('siteId') - ->all(); - } + foreach (array_keys($supportedSites) as $siteId) { + if ($siteId === $element->siteId) { + continue; + } - foreach (array_keys($supportedSites) as $siteId) { - if ($siteId === $element->siteId) { - continue; - } + $siteElement = $siteElements[$siteId] ?? false; + $siteElementRecord = null; + if (! $this->propagateInternal( + $element, + $supportedSites, + $siteId, + $siteElement, + crossSiteValidate: $runValidation && $crossSiteValidate, + siteSettingsRecord: $siteElementRecord, + inheritedUpdateSearchIndex: $resolvedUpdateSearchIndex, + )) { + throw new InvalidArgumentException; + } - $siteElement = $siteElements[$siteId] ?? false; - $siteElementRecord = null; - if (! $this->propagateInternal( - $element, - $supportedSites, - $siteId, - $siteElement, - crossSiteValidate: $runValidation && $crossSiteValidate, - siteSettingsRecord: $siteElementRecord, - inheritedUpdateSearchIndex: $resolvedUpdateSearchIndex, - )) { - throw new InvalidArgumentException; + $siteElements[$siteId] = $siteElement; + $siteSettingsRecords[$siteId] = $siteElementRecord; } - - $siteElements[$siteId] = $siteElement; - $siteSettingsRecords[$siteId] = $siteElementRecord; } } - } - - if (! $element->propagating && ! empty($generatedFields)) { - $siteElements[$element->siteId] = $element; - $siteSettingsRecords[$element->siteId] = $siteSettingsRecord; - Event::listen(function (AfterPropagate $event) use ($element, $generatedFields, $siteElements, $siteSettingsRecords) { - if ($event->element->id !== $element->id) { - return; - } + if (! $element->propagating && ! empty($generatedFields)) { + $siteElements[$element->siteId] = $element; + $siteSettingsRecords[$element->siteId] = $siteSettingsRecord; - foreach ($siteElements as $siteId => $siteElement) { - $siteSettingsRecord = $siteSettingsRecords[$siteId]; - $content = $siteSettingsRecord->content ?? []; - if (is_string($content)) { - $content = $content !== '' ? Json::decode($content) : []; + Event::listen(function (AfterPropagate $event) use ($element, $generatedFields, $siteElements, $siteSettingsRecords) { + if ($event->element->id !== $element->id) { + return; } - $generatedFieldValues = []; - $updated = false; - - foreach ($generatedFields as $field) { - $value = renderObjectTemplate($field['template'] ?? '', $siteElement); - $value = normalizeValue($value) ?? ''; - if ($value !== ($content[$field['uid']] ?? '')) { - $updated = true; + foreach ($siteElements as $siteId => $siteElement) { + $siteSettingsRecord = $siteSettingsRecords[$siteId]; + $content = $siteSettingsRecord->content ?? []; + if (is_string($content)) { + $content = $content !== '' ? Json::decode($content) : []; } - if ($value !== '') { - $content[$field['uid']] = $value; - if (($field['handle'] ?? '') !== '') { - $generatedFieldValues[$field['handle']] = $value; + $generatedFieldValues = []; + $updated = false; + + foreach ($generatedFields as $field) { + $value = renderObjectTemplate($field['template'] ?? '', $siteElement); + $value = normalizeValue($value) ?? ''; + + if ($value !== ($content[$field['uid']] ?? '')) { + $updated = true; + } + if ($value !== '') { + $content[$field['uid']] = $value; + if (($field['handle'] ?? '') !== '') { + $generatedFieldValues[$field['handle']] = $value; + } + } else { + unset($content[$field['uid']]); } - } else { - unset($content[$field['uid']]); } - } - if ($updated) { - $siteSettingsRecord->content = $content; - $siteSettingsRecord->save(); - $siteElement->setGeneratedFieldValues($generatedFieldValues); + if ($updated) { + $siteSettingsRecord->content = $content; + $siteSettingsRecord->save(); + $siteElement->setGeneratedFieldValues($generatedFieldValues); + } } - } - }); - } + }); + } - if ( - ! $element->propagating && - ! $element->duplicateOf && - ! $element->mergingCanonicalChanges - ) { - $element->afterPropagate($isNewElement); - BulkOps::trackElement($element); - } + if ( + ! $element->propagating && + ! $element->duplicateOf && + ! $element->mergingCanonicalChanges + ) { + $element->afterPropagate($isNewElement); + BulkOps::trackElement($element); + } - DB::commit(); - } catch (Throwable $throwable) { - DB::rollBack(); + DB::commit(); + } catch (Throwable $throwable) { + DB::rollBack(); - $this->resetElement($element, $originalFirstSave, $originalIsNewForSite, $originalPropagateAll); - $element->dateUpdated = $originalDateUpdated; + $this->resetElement($element, $originalFirstSave, $originalIsNewForSite, $originalPropagateAll); + $element->dateUpdated = $originalDateUpdated; - if ($throwable instanceof InvalidArgumentException) { - return false; + if ($throwable instanceof InvalidArgumentException) { + return false; + } + + throw $throwable; + } finally { + $element->newSiteIds = $newSiteIds; } - throw $throwable; - } finally { - $element->newSiteIds = $newSiteIds; - } + if (! $element->propagating) { + if (! $isNewElement) { + $deleteCondition = fn (Builder $query) => $query + ->where('elementId', $element->id) + ->whereNotIn('siteId', array_keys($supportedSites)); - if (! $element->propagating) { - if (! $isNewElement) { - $deleteCondition = fn (Builder $query) => $query - ->where('elementId', $element->id) - ->whereNotIn('siteId', array_keys($supportedSites)); + DB::table(Table::ELEMENTS_SITES)->where($deleteCondition)->delete(); + DB::table(Table::SEARCHINDEX)->where($deleteCondition)->delete(); + DB::table(Table::SEARCHINDEXQUEUE)->where($deleteCondition)->delete(); + } - DB::table(Table::ELEMENTS_SITES)->where($deleteCondition)->delete(); - DB::table(Table::SEARCHINDEX)->where($deleteCondition)->delete(); - DB::table(Table::SEARCHINDEXQUEUE)->where($deleteCondition)->delete(); + $this->elementCaches->invalidateForElement($element); } - $this->elementCaches->invalidateForElement($element); - } - - if ($resolvedUpdateSearchIndex && ! $element->getIsRevision() && ! ElementHelper::isRevision($element)) { - $searchableDirtyFields = array_filter( - $dirtyFields, - fn (string $handle) => $fieldLayout?->getFieldByHandle($handle)?->searchable, - ); - - if ( - ! $trackChanges || - ! empty($searchableDirtyFields) || - ! empty(array_intersect($dirtyAttributes, ElementHelper::searchableAttributes($element))) - ) { - event($event = new BeforeUpdateSearchIndex($element)); - - if ($event->isValid) { - $this->updateElementSearchIndex($element, $searchableDirtyFields, $propagate); + if ($resolvedUpdateSearchIndex && ! $element->getIsRevision() && ! ElementHelper::isRevision($element)) { + $searchableDirtyFields = array_filter( + $dirtyFields, + fn (string $handle) => $fieldLayout?->getFieldByHandle($handle)?->searchable, + ); + + if ( + ! $trackChanges || + ! empty($searchableDirtyFields) || + ! empty(array_intersect($dirtyAttributes, ElementHelper::searchableAttributes($element))) + ) { + event($event = new BeforeUpdateSearchIndex($element)); + + if ($event->isValid) { + $this->updateElementSearchIndex($element, $searchableDirtyFields, $propagate); + } } } - } - if ($trackChanges) { - $userId = Auth::user()?->id; - $timestamp = now(); - - foreach ($dirtyAttributes as $attributeName) { - DB::table(Table::CHANGEDATTRIBUTES) - ->upsert([ - 'elementId' => $element->id, - 'siteId' => $element->siteId, - 'attribute' => $attributeName, - 'dateUpdated' => $timestamp, - 'propagated' => $element->propagating, - 'userId' => $userId, - ], ['elementId', 'siteId', 'attribute']); - } + if ($trackChanges) { + $userId = Auth::user()?->id; + $timestamp = now(); + + foreach ($dirtyAttributes as $attributeName) { + DB::table(Table::CHANGEDATTRIBUTES) + ->upsert([ + 'elementId' => $element->id, + 'siteId' => $element->siteId, + 'attribute' => $attributeName, + 'dateUpdated' => $timestamp, + 'propagated' => $element->propagating, + 'userId' => $userId, + ], ['elementId', 'siteId', 'attribute']); + } - if ($fieldLayout) { - foreach ($dirtyFields as $fieldHandle) { - if (($field = $fieldLayout->getFieldByHandle($fieldHandle)) !== null) { - DB::table(Table::CHANGEDFIELDS) - ->upsert([ - 'elementId' => $element->id, - 'siteId' => $element->siteId, - 'fieldId' => $field->id, - 'layoutElementUid' => $field->layoutElement->uid, - 'dateUpdated' => $timestamp, - 'propagated' => $element->propagating, - 'userId' => $userId, - ], ['elementId', 'siteId', 'fieldId', 'layoutElementUid']); + if ($fieldLayout) { + foreach ($dirtyFields as $fieldHandle) { + if (($field = $fieldLayout->getFieldByHandle($fieldHandle)) !== null) { + DB::table(Table::CHANGEDFIELDS) + ->upsert([ + 'elementId' => $element->id, + 'siteId' => $element->siteId, + 'fieldId' => $field->id, + 'layoutElementUid' => $field->layoutElement->uid, + 'dateUpdated' => $timestamp, + 'propagated' => $element->propagating, + 'userId' => $userId, + ], ['elementId', 'siteId', 'fieldId', 'layoutElementUid']); + } } } } - } - return true; - }); + return true; + }); - if (! $success) { - return false; + if (! $success) { + return false; + } + } finally { + $element->ruleset->useScenario($originalScenario); } event(new AfterSaveElement($element, $isNewElement)); @@ -799,14 +804,14 @@ protected function propagateInternal( } } - $siteElement->setScenario(Element::SCENARIO_ESSENTIALS); + $siteElement->ruleset->useScenario(ElementRules::SCENARIO_ESSENTIALS); if ( ($crossSiteValidate || $element->propagateRequired) && $siteElement->enabled && $siteElement->getEnabledForSite() ) { - $siteElement->setScenario(Element::SCENARIO_LIVE); + $siteElement->ruleset->useScenario(ElementRules::SCENARIO_LIVE); } $siteElement->setDirtyAttributes(array_filter($element->getDirtyAttributes(), diff --git a/src/Element/Validation/ElementRules.php b/src/Element/Validation/ElementRules.php index eb4e678b94d..d8b8cbdaa95 100644 --- a/src/Element/Validation/ElementRules.php +++ b/src/Element/Validation/ElementRules.php @@ -26,28 +26,34 @@ /** * @template T of Element * - * @property Element $component + * @property Element $subject * * @extends Ruleset */ -abstract class ElementRules extends Ruleset +class ElementRules extends Ruleset { + public const string SCENARIO_DEFAULT = 'default'; + + public const string SCENARIO_ESSENTIALS = 'essentials'; + + public const string SCENARIO_LIVE = 'live'; + private ?string $uriPreparationError = null; #[Override] - public function prepareForValidation(?array $attributeNames = null): void + public function prepareForValidation(): void { - $shouldPrepare = fn (string $attribute) => is_null($attributeNames) || in_array($attribute, $attributeNames, true); + $shouldPrepare = fn (string $attribute) => is_null($this->validationAttributes) || in_array($attribute, $this->validationAttributes, true); if ($shouldPrepare('title')) { $this->prepareTitle(); } - if ($shouldPrepare('slug') && $this->component->hasUris()) { - $this->prepareSlug($this->component->getSite()->language); + if ($shouldPrepare('slug') && $this->subject->hasUris()) { + $this->prepareSlug($this->subject->getSite()->language); } - if ($shouldPrepare('uri') && $this->component->hasUris()) { + if ($shouldPrepare('uri') && $this->subject->hasUris()) { $this->prepareUri(); } } @@ -59,10 +65,9 @@ public function prepareForValidation(?array $attributeNames = null): void * * @return array */ - #[Override] - protected function defineRules(): array + public function rules(): array { - $int = Rule::when($this->component->inScenarios(Element::SCENARIO_DEFAULT, Element::SCENARIO_LIVE), ['nullable', 'integer']); + $int = Rule::when($this->inScenarios(self::SCENARIO_DEFAULT, self::SCENARIO_LIVE), ['nullable', 'integer']); $rules = [ 'id' => $int, @@ -70,10 +75,10 @@ protected function defineRules(): array 'root' => $int, 'lft' => $int, 'rgt' => $int, - 'level' => Rule::when($this->component->inScenarios(Element::SCENARIO_DEFAULT, Element::SCENARIO_LIVE, Element::SCENARIO_ESSENTIALS), ['nullable', 'integer']), + 'level' => Rule::when($this->inScenarios(self::SCENARIO_DEFAULT, self::SCENARIO_LIVE, self::SCENARIO_ESSENTIALS), ['nullable', 'integer']), 'title' => ['string', 'max:255', new DisallowMb4], 'siteId' => Rule::when( - $this->component->inScenarios(Element::SCENARIO_DEFAULT, Element::SCENARIO_LIVE, Element::SCENARIO_ESSENTIALS), + $this->inScenarios(self::SCENARIO_DEFAULT, self::SCENARIO_LIVE, self::SCENARIO_ESSENTIALS), [ 'nullable', new SiteIdRule(allowDisabled: true), @@ -92,7 +97,7 @@ protected function defineRules(): array private function addTitleRules(array $rules): array { try { - if ($this->component->inScenarios(Element::SCENARIO_DEFAULT, Element::SCENARIO_LIVE) && $this->component->shouldValidateTitle()) { + if ($this->inScenarios(self::SCENARIO_DEFAULT, self::SCENARIO_LIVE) && $this->subject->shouldValidateTitle()) { array_unshift($rules['title'], 'required'); } else { array_unshift($rules['title'], 'nullable'); @@ -107,18 +112,18 @@ private function addTitleRules(array $rules): array private function addUriRules(array $rules): array { - if (! $this->component->hasUris()) { + if (! $this->subject->hasUris()) { return $rules; } - $rules['slug'] = [Rule::when($this->component->inScenarios(Element::SCENARIO_DEFAULT, Element::SCENARIO_LIVE, Element::SCENARIO_ESSENTIALS), [ + $rules['slug'] = [Rule::when($this->inScenarios(self::SCENARIO_DEFAULT, self::SCENARIO_LIVE, self::SCENARIO_ESSENTIALS), [ 'max:255', ])]; try { - $uriFormat = $this->component->getUriFormat() ?? ''; + $uriFormat = $this->subject->getUriFormat() ?? ''; - if ($this->component->inScenarios(Element::SCENARIO_DEFAULT, Element::SCENARIO_LIVE) + if ($this->inScenarios(self::SCENARIO_DEFAULT, self::SCENARIO_LIVE) && preg_match('/\bslug\b/', $uriFormat) ) { array_unshift($rules['slug'], 'required', 'string'); @@ -130,7 +135,7 @@ private function addUriRules(array $rules): array } $rules['uri'] = Rule::when( - $this->component->inScenarios(Element::SCENARIO_DEFAULT, Element::SCENARIO_LIVE, Element::SCENARIO_ESSENTIALS), + $this->inScenarios(self::SCENARIO_DEFAULT, self::SCENARIO_LIVE, self::SCENARIO_ESSENTIALS), [ 'bail', // Fail if we have an uriPreparationError @@ -150,24 +155,24 @@ function (?string $attribute, mixed $value, Closure $fail) { private function prepareTitle(): void { - $title = $this->component->title; + $title = $this->subject->title; if (! is_string($title)) { return; } - $this->component->title = trim(Str::convertLineBreaks($title)); + $this->subject->title = trim(Str::convertLineBreaks($title)); } private function prepareSlug(string $language): void { - $slug = (string) $this->component->slug; + $slug = (string) $this->subject->slug; $isTemp = ElementHelper::isTempSlug($slug); - $element = $this->component; + $element = $this->subject; $isDraft = $element instanceof ElementInterface && $element->getIsDraft(); - if ($isDraft && ! in_array($element->getScenario(), [Element::SCENARIO_LIVE, 'default'], true)) { + if ($isDraft && ! in_array($element->ruleset->getScenario(), [self::SCENARIO_LIVE, self::SCENARIO_DEFAULT], true)) { if ($isTemp) { return; } @@ -202,20 +207,20 @@ private function setSlugOnElement(?ElementInterface $element, string $slug): voi private function prepareUri(): void { - if ($this->component->getIsRevision()) { + if ($this->subject->getIsRevision()) { return; } - if ($this->component->getIsDraft() && ! $this->component->getIsUnpublishedDraft()) { - if (! $this->component->inScenarios(Element::SCENARIO_LIVE)) { + if ($this->subject->getIsDraft() && ! $this->subject->getIsUnpublishedDraft()) { + if (! $this->inScenarios(self::SCENARIO_LIVE)) { return; } - $canonical = $this->component->getCanonical(); + $canonical = $this->subject->getCanonical(); if ( - $canonical !== $this->component && - $this->component->uri === $canonical->uri && + $canonical !== $this->subject && + $this->subject->uri === $canonical->uri && $canonical->enabled && $canonical->getEnabledForSite() ) { @@ -224,12 +229,12 @@ private function prepareUri(): void } try { - Elements::setElementUri($this->component); + Elements::setElementUri($this->subject); } catch (OperationAbortedException) { if ( - $this->component->enabled && - $this->component->getEnabledForSite() && - (! $this->component->getIsUnpublishedDraft() || $this->component->inScenarios(Element::SCENARIO_LIVE)) + $this->subject->enabled && + $this->subject->getEnabledForSite() && + (! $this->subject->getIsUnpublishedDraft() || $this->inScenarios(self::SCENARIO_LIVE)) ) { $this->uriPreparationError = t('Could not generate a unique URI based on the URI format.'); } diff --git a/src/Entry/Commands/UpdateStatusesCommand.php b/src/Entry/Commands/UpdateStatusesCommand.php index 39272849a6e..6409256811b 100644 --- a/src/Entry/Commands/UpdateStatusesCommand.php +++ b/src/Entry/Commands/UpdateStatusesCommand.php @@ -5,7 +5,6 @@ namespace CraftCms\Cms\Entry\Commands; use CraftCms\Cms\Console\CraftCommand; -use CraftCms\Cms\Element\Element; use CraftCms\Cms\Element\ElementHelper; use CraftCms\Cms\Element\Events\AfterResaveElement; use CraftCms\Cms\Element\Events\AfterResaveElements; @@ -13,6 +12,7 @@ use CraftCms\Cms\Element\Events\BeforeResaveElements; use CraftCms\Cms\Element\Exceptions\InvalidElementException; use CraftCms\Cms\Element\Queries\EntryQuery; +use CraftCms\Cms\Element\Validation\ElementRules; use CraftCms\Cms\Entry\Elements\Entry; use CraftCms\Cms\Support\DateTimeHelper; use CraftCms\Cms\Support\Facades\Elements; @@ -139,7 +139,7 @@ private function resaveQuery(EntryQuery $query): void foreach ($query->cursor() as $entry) { $position++; - $entry->setScenario(Element::SCENARIO_ESSENTIALS); + $entry->ruleset->useScenario(ElementRules::SCENARIO_ESSENTIALS); $entry->resaving = true; event(new BeforeResaveElement($query, $entry, $position)); diff --git a/src/Entry/Conditions/TypeConditionRule.php b/src/Entry/Conditions/TypeConditionRule.php index 7e3bcf88059..3915a118f5f 100644 --- a/src/Entry/Conditions/TypeConditionRule.php +++ b/src/Entry/Conditions/TypeConditionRule.php @@ -43,7 +43,7 @@ public function setAttributes($values, $safeOnly = true): void unset($values['entryTypeUid'], $values['sectionUid']); } - parent::setAttributes($values, $safeOnly); + parent::setAttributes($values); } protected function options(): array diff --git a/src/Entry/Data/EntryType.php b/src/Entry/Data/EntryType.php index 5abef586586..e84871634bc 100644 --- a/src/Entry/Data/EntryType.php +++ b/src/Entry/Data/EntryType.php @@ -28,7 +28,7 @@ use CraftCms\Cms\Support\Facades\InputNamespace; use CraftCms\Cms\Support\Facades\Sections; use CraftCms\Cms\Support\Url; -use CraftCms\Cms\Validation\Attributes\Ruleset; +use CraftCms\RulesetValidation\Attributes\Ruleset; use Illuminate\Support\Facades\Auth; use Stringable; diff --git a/src/Entry/Elements/Entry.php b/src/Entry/Elements/Entry.php index be16ee4c8f9..28995a12fe8 100644 --- a/src/Entry/Elements/Entry.php +++ b/src/Entry/Elements/Entry.php @@ -33,6 +33,7 @@ use CraftCms\Cms\Element\Queries\Contracts\ElementQueryInterface; use CraftCms\Cms\Element\Queries\EntryQuery; use CraftCms\Cms\Element\Revisions; +use CraftCms\Cms\Element\Validation\ElementRules; use CraftCms\Cms\Entry\Actions\MoveToSection; use CraftCms\Cms\Entry\Actions\NewChild; use CraftCms\Cms\Entry\Actions\NewSiblingAfter; @@ -81,7 +82,7 @@ use CraftCms\Cms\Support\Url; use CraftCms\Cms\Twig\Attributes\AllowedInSandbox; use CraftCms\Cms\User\Elements\User; -use CraftCms\Cms\Validation\Attributes\Ruleset; +use CraftCms\RulesetValidation\Attributes\Ruleset; use DateInterval; use DateTime; use GraphQL\Type\Definition\Type; @@ -2524,7 +2525,7 @@ private function maybeSetDefaultAttributes(): void if ( ! $this->_userPostDate() && ( - in_array($this->scenario, [self::SCENARIO_LIVE, self::SCENARIO_DEFAULT]) || + in_array($this->scenario, [ElementRules::SCENARIO_LIVE, ElementRules::SCENARIO_DEFAULT]) || ! $this->getIsDraft() ) ) { diff --git a/src/Entry/Entries.php b/src/Entry/Entries.php index f11530db776..7229707dcca 100644 --- a/src/Entry/Entries.php +++ b/src/Entry/Entries.php @@ -9,6 +9,7 @@ use CraftCms\Cms\Element\Elements; use CraftCms\Cms\Element\Exceptions\InvalidElementException; use CraftCms\Cms\Element\Exceptions\UnsupportedSiteException; +use CraftCms\Cms\Element\Validation\ElementRules; use CraftCms\Cms\Entry\Elements\Entry; use CraftCms\Cms\Entry\Events\EntryMovedToSection; use CraftCms\Cms\Entry\Events\MovingEntryToSection; @@ -172,7 +173,7 @@ public function moveEntryToSection(Entry $entry, Section $section): bool $entry->sectionId = $section->id; // Validate - $entry->setScenario(Element::SCENARIO_ESSENTIALS); + $entry->ruleset->useScenario(ElementRules::SCENARIO_ESSENTIALS); $entry->validate(); // If there are any errors on the URI, re-validate as disabled diff --git a/src/Entry/Validation/EntryRules.php b/src/Entry/Validation/EntryRules.php index 6ed21613714..fc7f5f363b9 100644 --- a/src/Entry/Validation/EntryRules.php +++ b/src/Entry/Validation/EntryRules.php @@ -16,18 +16,18 @@ /** * @extends ElementRules * - * @property Entry $component + * @property Entry $subject */ class EntryRules extends ElementRules { #[Override] - protected function defineRules(): array + public function rules(): array { - $rules = parent::defineRules(); + $rules = parent::rules(); $rules['sectionId'] = ['nullable', 'integer', 'required_without:fieldId']; $rules['fieldId'] = ['nullable', 'integer', function (string $attribute, mixed $value, Closure $fail) { - if ($this->component->sectionId && $this->component->fieldId) { + if ($this->subject->sectionId && $this->subject->fieldId) { $fail(t('`sectionId` and `fieldId` cannot both be set on an entry.')); } }]; @@ -35,36 +35,36 @@ protected function defineRules(): array $rules['primaryOwnerId'] = ['nullable', 'integer']; $rules['sortOrder'] = ['nullable', 'integer']; $rules['placeInStructure'] = ['bool']; - $rules['postDate'] = ['nullable', 'date', Rule::when($this->component->inScenarios(Entry::SCENARIO_LIVE) && ! is_null($this->component->expiryDate), ['before:expiryDate'])]; + $rules['postDate'] = ['nullable', 'date', Rule::when($this->inScenarios(self::SCENARIO_LIVE) && ! is_null($this->subject->expiryDate), ['before:expiryDate'])]; $rules['expiryDate'] = ['nullable', 'date']; $rules['typeId'] = [ 'required', 'integer', - Rule::when($this->component->inScenarios(Entry::SCENARIO_DEFAULT, Entry::SCENARIO_LIVE), [ + Rule::when($this->inScenarios(self::SCENARIO_DEFAULT, self::SCENARIO_LIVE), [ function (string $attribute, int $value, Closure $fail) { - $typeId = $this->component->getType()->id; + $typeId = $this->subject->getType()->id; - if (array_any($this->component->getAvailableEntryTypes(), fn ($entryType) => $entryType->id === $typeId)) { + if (array_any($this->subject->getAvailableEntryTypes(), fn ($entryType) => $entryType->id === $typeId)) { return; } $fail(t('{attribute} is invalid.', [ - 'attribute' => $this->component->getAttributeLabel($attribute), + 'attribute' => $this->subject->getAttributeLabel($attribute), ])); }, ]), - Rule::when($this->component->inScenarios(Entry::SCENARIO_LIVE), [ + Rule::when($this->inScenarios(self::SCENARIO_LIVE), [ function (string $attribute, int $value, Closure $fail) { - if (! $this->component->getIsCanonical()) { + if (! $this->subject->getIsCanonical()) { return; } - if ($this->component->isEntryTypeAllowed()) { + if ($this->subject->isEntryTypeAllowed()) { return; } $fail(t('{type} entries are no longer allowed in this section. Please choose a different entry type.', [ - 'type' => $this->component->getType()->getUiLabel(), + 'type' => $this->subject->getType()->getUiLabel(), ])); }, ]), @@ -73,11 +73,11 @@ function (string $attribute, int $value, Closure $fail) { 'nullable', 'array', Rule::when(function () { - if (! $this->component->inScenarios(Entry::SCENARIO_DEFAULT, Entry::SCENARIO_LIVE)) { + if (! $this->inScenarios(self::SCENARIO_DEFAULT, self::SCENARIO_LIVE)) { return false; } - $section = $this->component->getSection(); + $section = $this->subject->getSection(); return $section && @@ -86,7 +86,7 @@ function (string $attribute, int $value, Closure $fail) { $section->maxAuthors !== 0; }, [ function (string $attribute, array $value, Closure $fail) { - $section = $this->component->getSection(); + $section = $this->subject->getSection(); if (is_null($section)) { return; @@ -98,12 +98,12 @@ function (string $attribute, array $value, Closure $fail) { ])); } - $authors = $this->component->getAuthors(); - if ($this->component->getOldAuthorIds() !== null) { + $authors = $this->subject->getAuthors(); + if ($this->subject->getOldAuthorIds() !== null) { foreach ($authors as $author) { if ( - ! in_array($author->id, $this->component->getOldAuthorIds()) && - ! $author->can(sprintf('viewEntries:%s', $this->component->getSection()->uid)) + ! in_array($author->id, $this->subject->getOldAuthorIds()) && + ! $author->can(sprintf('viewEntries:%s', $this->subject->getSection()->uid)) ) { $fail(t('This user doesn’t have permission to author entries in this section.')); break; @@ -113,11 +113,11 @@ function (string $attribute, array $value, Closure $fail) { }, ]), Rule::requiredIf(function () { - if (! $this->component->inScenarios(Entry::SCENARIO_LIVE)) { + if (! $this->inScenarios(self::SCENARIO_LIVE)) { return false; } - $section = $this->component->getSection(); + $section = $this->subject->getSection(); if (! $section) { return false; diff --git a/src/Entry/Validation/EntryTypeRules.php b/src/Entry/Validation/EntryTypeRules.php index 3fcd1c7b645..fef609369e3 100644 --- a/src/Entry/Validation/EntryTypeRules.php +++ b/src/Entry/Validation/EntryTypeRules.php @@ -17,8 +17,7 @@ /** @extends Ruleset */ class EntryTypeRules extends Ruleset { - #[\Override] - public function defineRules(): array + public function rules(): array { $rules = [ 'id' => ['nullable', 'integer'], @@ -52,8 +51,8 @@ public function defineRules(): array ], ]; - if ($this->component->validateHandleUniqueness) { - $rules['handle'][] = Rule::unique(Table::ENTRYTYPES, 'handle')->ignore($this->component->id)->withoutTrashed('dateDeleted'); + if ($this->subject->validateHandleUniqueness) { + $rules['handle'][] = Rule::unique(Table::ENTRYTYPES, 'handle')->ignore($this->subject->id)->withoutTrashed('dateDeleted'); } return $rules; diff --git a/src/Field/Addresses.php b/src/Field/Addresses.php index 2574ace6cf7..a8eaff5f3c0 100644 --- a/src/Field/Addresses.php +++ b/src/Field/Addresses.php @@ -15,6 +15,7 @@ use CraftCms\Cms\Element\NestedElementManager; use CraftCms\Cms\Element\Queries\AddressQuery; use CraftCms\Cms\Element\Queries\Contracts\ElementQueryInterface; +use CraftCms\Cms\Element\Validation\ElementRules; use CraftCms\Cms\Field\Conditions\EmptyFieldConditionRule; use CraftCms\Cms\Field\Contracts\EagerLoadingFieldInterface; use CraftCms\Cms\Field\Contracts\ElementContainerFieldInterface; @@ -621,7 +622,7 @@ private function inputHtmlInternal(?ElementInterface $owner, bool $static = fals #[Override] public function getElementRules(ElementInterface $element): array { - if (! $element->inScenarios(Element::SCENARIO_ESSENTIALS, Element::SCENARIO_DEFAULT, Element::SCENARIO_LIVE)) { + if (! $element->ruleset->inScenarios(ElementRules::SCENARIO_ESSENTIALS, ElementRules::SCENARIO_DEFAULT, ElementRules::SCENARIO_LIVE)) { return []; } @@ -648,15 +649,15 @@ private function validateAddresses(ElementInterface $element, AddressQuery|Eleme ->all(); $invalidAddressIds = []; - $scenario = $element->getScenario(); + $scenario = $element->ruleset->getScenario(); foreach ($addresses as $address) { /** @var Address $address */ if ( - $scenario === Element::SCENARIO_ESSENTIALS || - ($address->enabled && $scenario === Element::SCENARIO_LIVE) + $scenario === ElementRules::SCENARIO_ESSENTIALS || + ($address->enabled && $scenario === ElementRules::SCENARIO_LIVE) ) { - $address->setScenario($scenario); + $address->ruleset->useScenario($scenario); } if (! $address->validate()) { @@ -680,7 +681,7 @@ private function validateAddresses(ElementInterface $element, AddressQuery|Eleme } if ( - $element->inScenarios(Element::SCENARIO_LIVE) && + $element->ruleset->inScenarios(ElementRules::SCENARIO_LIVE) && ($this->minAddresses || $this->maxAddresses) ) { $rules = [ diff --git a/src/Field/Assets.php b/src/Field/Assets.php index 89fe826d8ca..27fc80b734b 100644 --- a/src/Field/Assets.php +++ b/src/Field/Assets.php @@ -10,6 +10,7 @@ use CraftCms\Cms\Asset\Data\Volume; use CraftCms\Cms\Asset\Data\VolumeFolder; use CraftCms\Cms\Asset\Elements\Asset; +use CraftCms\Cms\Asset\Validation\AssetRules; use CraftCms\Cms\Auth\SessionAuth; use CraftCms\Cms\Cms; use CraftCms\Cms\Cp\Html\PreviewHtml; @@ -495,7 +496,7 @@ public function beforeElementSave(ElementInterface $element, bool $isNew): bool $asset->setVolumeId($uploadFolder->volumeId); $asset->uploaderId = Auth::id(); $asset->avoidFilenameConflicts = true; - $asset->setScenario(Asset::SCENARIO_CREATE); + $asset->ruleset->useScenario(AssetRules::SCENARIO_CREATE); if (Elements::saveElement($asset)) { $assetIds[] = $asset->id; diff --git a/src/Field/BaseRelationField.php b/src/Field/BaseRelationField.php index 3b328572466..565bc03a799 100644 --- a/src/Field/BaseRelationField.php +++ b/src/Field/BaseRelationField.php @@ -26,6 +26,7 @@ use CraftCms\Cms\Element\Jobs\LocalizeRelations; use CraftCms\Cms\Element\Queries\Contracts\ElementQueryInterface; use CraftCms\Cms\Element\Queries\ElementQuery; +use CraftCms\Cms\Element\Validation\ElementRules; use CraftCms\Cms\Field\Conditions\RelationalFieldConditionRule; use CraftCms\Cms\Field\Contracts\CrossSiteCopyableFieldInterface; use CraftCms\Cms\Field\Contracts\EagerLoadingFieldInterface; @@ -563,7 +564,7 @@ public function getSettingsHtml(): string #[Override] public function getElementRules(ElementInterface $element): array { - if (! $element->inScenarios(Element::SCENARIO_LIVE)) { + if (! $element->ruleset->inScenarios(ElementRules::SCENARIO_LIVE)) { return []; } @@ -692,7 +693,7 @@ private static function _validateRelatedElement(ElementInterface $source, Elemen // Prevent relational fields on this element from enforcing related element validation self::$validatingRelatedElements = true; - $target->setScenario(Element::SCENARIO_LIVE); + $target->ruleset->useScenario(ElementRules::SCENARIO_LIVE); $validates = $target->validate(); self::$validatingRelatedElements = false; diff --git a/src/Field/Conditions/MoneyFieldConditionRule.php b/src/Field/Conditions/MoneyFieldConditionRule.php index ca4130bb414..d56e171ef05 100644 --- a/src/Field/Conditions/MoneyFieldConditionRule.php +++ b/src/Field/Conditions/MoneyFieldConditionRule.php @@ -13,6 +13,7 @@ use CraftCms\Cms\Support\Money as MoneyHelper; use Money\Currency; use Money\Money as MoneyLibrary; +use Override; use yii\base\InvalidConfigException; use function CraftCms\Cms\t; @@ -21,7 +22,7 @@ class MoneyFieldConditionRule extends BaseNumberConditionRule implements FieldCo { use FieldConditionRuleTrait; - #[\Override] + #[Override] public function setAttributes($values, $safeOnly = true): void { // Hold setting of the value attribute until we have all the info we need @@ -35,7 +36,7 @@ public function setAttributes($values, $safeOnly = true): void $maxValue = Arr::pull($values, 'maxValue'); } - parent::setAttributes($values, $safeOnly); + parent::setAttributes($values); $field = $this->field(); @@ -59,7 +60,7 @@ public function setAttributes($values, $safeOnly = true): void } } - #[\Override] + #[Override] protected function inputHtml(): string { $field = $this->field(); @@ -95,7 +96,7 @@ protected function inputHtml(): string return FormFields::moneyInputHtml($this->inputOptions()); } - #[\Override] + #[Override] protected function inputOptions(): array { /** @var Money $field */ diff --git a/src/Field/ContentBlock.php b/src/Field/ContentBlock.php index d8ad32061c4..8b799244bca 100644 --- a/src/Field/ContentBlock.php +++ b/src/Field/ContentBlock.php @@ -14,6 +14,7 @@ use CraftCms\Cms\Element\NestedElementManager; use CraftCms\Cms\Element\Queries\ContentBlockQuery; use CraftCms\Cms\Element\Queries\Contracts\ElementQueryInterface; +use CraftCms\Cms\Element\Validation\ElementRules; use CraftCms\Cms\Field\Contracts\ElementContainerFieldInterface; use CraftCms\Cms\Field\Elements\ContentBlock as ContentBlockElement; use CraftCms\Cms\Field\Enums\TranslationMethod; @@ -649,7 +650,7 @@ private function inputHtmlInternal(mixed $value, ?ElementInterface $element, boo #[Override] public function getElementRules(ElementInterface $element): array { - if (! $element->inScenarios(Element::SCENARIO_ESSENTIALS, Element::SCENARIO_DEFAULT, Element::SCENARIO_LIVE)) { + if (! $element->ruleset->inScenarios(ElementRules::SCENARIO_ESSENTIALS, ElementRules::SCENARIO_DEFAULT, ElementRules::SCENARIO_LIVE)) { return []; } @@ -662,8 +663,8 @@ private function validateContentBlock(ElementInterface $element, ContentBlockEle { $value->setOwner($element); - if ($element->inScenarios(Element::SCENARIO_ESSENTIALS, Element::SCENARIO_LIVE)) { - $value->setScenario($element->getScenario()); + if ($element->ruleset->inScenarios(ElementRules::SCENARIO_ESSENTIALS, ElementRules::SCENARIO_LIVE)) { + $value->ruleset->useScenario($element->ruleset->getScenario()); } if (! $value->validate()) { diff --git a/src/Field/Contracts/FieldInterface.php b/src/Field/Contracts/FieldInterface.php index c6383acc369..c45b6a6ac5a 100644 --- a/src/Field/Contracts/FieldInterface.php +++ b/src/Field/Contracts/FieldInterface.php @@ -377,7 +377,7 @@ public function prepareForElementValidation(mixed $value): mixed; * [ * 'string', * 'min:3', - * Rule::when($element->inScenarios(self::SCENARIO_LIVE), ['max:12']), + * Rule::when($element->ruleset->inScenarios(\CraftCms\Cms\Element\Validation\ElementRules::SCENARIO_LIVE), ['max:12']), * ] * ``` */ diff --git a/src/Field/Elements/ContentBlock.php b/src/Field/Elements/ContentBlock.php index 991fbf8d8ee..459a3627dd2 100644 --- a/src/Field/Elements/ContentBlock.php +++ b/src/Field/Elements/ContentBlock.php @@ -14,7 +14,7 @@ use CraftCms\Cms\Field\Models\ContentBlock as ContentBlockModel; use CraftCms\Cms\FieldLayout\FieldLayout; use CraftCms\Cms\Gql\Interfaces\Elements\ContentBlock as ContentBlockInterface; -use CraftCms\Cms\Validation\Attributes\Ruleset; +use CraftCms\RulesetValidation\Attributes\Ruleset; use GraphQL\Type\Definition\Type; use Override; diff --git a/src/Field/Elements/ContentBlockRules.php b/src/Field/Elements/ContentBlockRules.php index 90543563509..54e1f4f6f2c 100644 --- a/src/Field/Elements/ContentBlockRules.php +++ b/src/Field/Elements/ContentBlockRules.php @@ -13,9 +13,9 @@ class ContentBlockRules extends ElementRules { #[Override] - protected function defineRules(): array + public function rules(): array { - return array_merge(parent::defineRules(), [ + return array_merge(parent::rules(), [ 'fieldId' => ['nullable', 'integer'], 'ownerId' => ['nullable', 'integer'], 'primaryOwnerId' => ['nullable', 'integer'], diff --git a/src/Field/Field.php b/src/Field/Field.php index 663bcebcef0..d35dd02a62d 100644 --- a/src/Field/Field.php +++ b/src/Field/Field.php @@ -48,7 +48,6 @@ use GraphQL\Type\Definition\Type; use Illuminate\Contracts\Database\Query\Builder; use Illuminate\Contracts\Database\Query\Expression; -use Illuminate\Support\Collection; use Illuminate\Support\Facades\Auth; use Illuminate\Support\Facades\DB; use Illuminate\Support\Facades\Event; @@ -488,18 +487,6 @@ public function __toString(): string return t($this->name, category: 'site') ?: static::class; } - #[Override] - public function attributes(): array - { - return Collection::make($this->settingsAttributes()) - ->reject(fn ($name): bool => in_array($name, [ - 'validateHandleUniqueness', - 'layoutElement', - 'static', - ])) - ->all(); - } - #[Override] public function attributeLabels(): array { diff --git a/src/Field/Matrix.php b/src/Field/Matrix.php index b64238fb45d..1749be7d71f 100644 --- a/src/Field/Matrix.php +++ b/src/Field/Matrix.php @@ -22,6 +22,7 @@ use CraftCms\Cms\Element\NestedElementManager; use CraftCms\Cms\Element\Queries\Contracts\ElementQueryInterface; use CraftCms\Cms\Element\Queries\EntryQuery; +use CraftCms\Cms\Element\Validation\ElementRules; use CraftCms\Cms\Entry\Data\EntryType; use CraftCms\Cms\Entry\Elements\Entry; use CraftCms\Cms\Entry\EntryTypes; @@ -1267,7 +1268,7 @@ private function defaultCreateButtonLabel(): string #[Override] public function getElementRules(ElementInterface $element): array { - if (! $element->inScenarios(Element::SCENARIO_ESSENTIALS, Element::SCENARIO_DEFAULT, Element::SCENARIO_LIVE)) { + if (! $element->ruleset->inScenarios(ElementRules::SCENARIO_ESSENTIALS, ElementRules::SCENARIO_DEFAULT, ElementRules::SCENARIO_LIVE)) { return []; } @@ -1301,15 +1302,15 @@ private function validateEntries(ElementInterface $element, string $attribute, E ->all(); $invalidEntryIds = []; - $scenario = $element->getScenario(); + $scenario = $element->ruleset->getScenario(); foreach ($entries as $entry) { $entry->setOwner($element); if (! $entry->enabled) { - $entry->setScenario(Element::SCENARIO_ESSENTIALS); + $entry->ruleset->useScenario(ElementRules::SCENARIO_ESSENTIALS); } else { - $entry->setScenario($scenario); + $entry->ruleset->useScenario($scenario); } if (! $entry->validate()) { @@ -1341,7 +1342,7 @@ private function validateEntries(ElementInterface $element, string $attribute, E } if ( - $element->inScenarios(Element::SCENARIO_LIVE) && + $element->ruleset->inScenarios(ElementRules::SCENARIO_LIVE) && ($this->minEntries || $this->maxEntries) ) { $rules = array_filter([ diff --git a/src/FieldLayout/FieldLayout.php b/src/FieldLayout/FieldLayout.php index 170fa1043ee..dcc1ed4cddc 100644 --- a/src/FieldLayout/FieldLayout.php +++ b/src/FieldLayout/FieldLayout.php @@ -46,7 +46,7 @@ class FieldLayout extends Component { use Validates { - getAttributes as traitGetAttributes; + validationData as traitValidationData; } public ?int $id = null; @@ -188,9 +188,9 @@ public function getRules(): array } #[Override] - public function getAttributes(): array + public function validationData(): array { - return array_merge($this->traitGetAttributes(), [ + return array_merge($this->traitValidationData(), [ 'customFields' => $this->getCustomFields(), ]); } diff --git a/src/Gql/Resolvers/ElementMutationResolver.php b/src/Gql/Resolvers/ElementMutationResolver.php index 48a5ab0f363..be84af5e28a 100644 --- a/src/Gql/Resolvers/ElementMutationResolver.php +++ b/src/Gql/Resolvers/ElementMutationResolver.php @@ -6,6 +6,7 @@ use craft\base\ElementInterface; use CraftCms\Cms\Element\Element; +use CraftCms\Cms\Element\Validation\ElementRules; use CraftCms\Cms\Gql\Events\AfterPopulateElement; use CraftCms\Cms\Gql\Events\BeforePopulateElement; use CraftCms\Cms\Gql\Exceptions\GqlException; @@ -95,8 +96,8 @@ protected function populateElementWithData(ElementInterface $element, array $arg protected function saveElement(ElementInterface $element): ElementInterface { /** @var Element $element */ - if ($element->enabled && $element->inScenarios(Element::SCENARIO_DEFAULT)) { - $element->setScenario(Element::SCENARIO_LIVE); + if ($element->enabled && $element->ruleset->inScenarios(ElementRules::SCENARIO_DEFAULT)) { + $element->ruleset->useScenario(ElementRules::SCENARIO_LIVE); } $isNotNew = $element->id; diff --git a/src/Gql/Resolvers/Mutations/Asset.php b/src/Gql/Resolvers/Mutations/Asset.php index a53e0be7332..2ca2aed3150 100644 --- a/src/Gql/Resolvers/Mutations/Asset.php +++ b/src/Gql/Resolvers/Mutations/Asset.php @@ -11,6 +11,7 @@ use CraftCms\Cms\Asset\Events\AfterReplaceAsset; use CraftCms\Cms\Asset\Events\BeforeReplaceAsset; use CraftCms\Cms\Asset\Exceptions\AssetDisallowedExtensionException; +use CraftCms\Cms\Asset\Validation\AssetRules; use CraftCms\Cms\Cms; use CraftCms\Cms\Database\Table; use CraftCms\Cms\Gql\Resolvers\ElementMutationResolver; @@ -99,7 +100,7 @@ public function saveAsset(mixed $source, array $arguments, mixed $context, Resol $asset = $this->populateElementWithData($asset, $arguments, $resolveInfo); - $triggerReplaceEvents = $asset->getScenario() === AssetElement::SCENARIO_REPLACE; + $triggerReplaceEvents = $asset->ruleset->getScenario() === AssetRules::SCENARIO_REPLACE; if ($triggerReplaceEvents) { event($event = new BeforeReplaceAsset( @@ -152,9 +153,9 @@ protected function populateElementWithData(ElementInterface $element, array $arg $element = parent::populateElementWithData($element, $arguments, $resolveInfo); if (! empty($fileInformation) && $this->handleUpload($element, $fileInformation)) { - $element->setScenario($element->id - ? AssetElement::SCENARIO_REPLACE - : AssetElement::SCENARIO_CREATE + $element->ruleset->useScenario($element->id + ? AssetRules::SCENARIO_REPLACE + : AssetRules::SCENARIO_CREATE ); } diff --git a/src/Gql/Resolvers/Mutations/Entry.php b/src/Gql/Resolvers/Mutations/Entry.php index 4d722344fcd..3e9bf35e557 100644 --- a/src/Gql/Resolvers/Mutations/Entry.php +++ b/src/Gql/Resolvers/Mutations/Entry.php @@ -6,6 +6,7 @@ use CraftCms\Cms\Element\Element; use CraftCms\Cms\Element\Queries\EntryQuery; +use CraftCms\Cms\Element\Validation\ElementRules; use CraftCms\Cms\Entry\Data\EntryType; use CraftCms\Cms\Entry\Elements\Entry as EntryElement; use CraftCms\Cms\Field\Contracts\ElementContainerFieldInterface; @@ -71,7 +72,7 @@ public function saveEntry(mixed $source, array $arguments, mixed $context, Resol // TODO refactor saving draft to its own method in 4.0 if (array_key_exists('draftId', $arguments)) { - $entry->setScenario(Element::SCENARIO_ESSENTIALS); + $entry->ruleset->useScenario(ElementRules::SCENARIO_ESSENTIALS); } $canIdentify = ! empty($arguments['id']) || ! empty($arguments['uid']) || ! empty($arguments['draftId']); @@ -79,7 +80,7 @@ public function saveEntry(mixed $source, array $arguments, mixed $context, Resol $entry = $this->populateElementWithData($entry, $arguments, $resolveInfo); if (array_key_exists('asUnpublishedDraft', $arguments) && $arguments['asUnpublishedDraft']) { - $entry->setScenario(Element::SCENARIO_ESSENTIALS); + $entry->ruleset->useScenario(ElementRules::SCENARIO_ESSENTIALS); Drafts::saveElementAsDraft( $entry, creatorId: Auth::id(), diff --git a/src/Http/Controllers/Assets/ImageEditorController.php b/src/Http/Controllers/Assets/ImageEditorController.php index b3beabe9503..9c44fc5565f 100644 --- a/src/Http/Controllers/Assets/ImageEditorController.php +++ b/src/Http/Controllers/Assets/ImageEditorController.php @@ -7,6 +7,7 @@ use CraftCms\Cms\Asset\Assets; use CraftCms\Cms\Asset\Concerns\EnforcesVolumePermissions; use CraftCms\Cms\Asset\Elements\Asset; +use CraftCms\Cms\Asset\Validation\AssetRules; use CraftCms\Cms\Cms; use CraftCms\Cms\Element\Elements; use CraftCms\Cms\Http\RespondsWithFlash; @@ -220,7 +221,7 @@ public function save(Request $request, Elements $elements): Response $newAsset = new Asset; $newAsset->avoidFilenameConflicts = true; - $newAsset->setScenario(Asset::SCENARIO_CREATE); + $newAsset->ruleset->useScenario(AssetRules::SCENARIO_CREATE); $newAsset->tempFilePath = $finalImage; $newAsset->setFilename($asset->getFilename()); diff --git a/src/Http/Controllers/Assets/UploadController.php b/src/Http/Controllers/Assets/UploadController.php index 15fae9a5530..a938a819b02 100644 --- a/src/Http/Controllers/Assets/UploadController.php +++ b/src/Http/Controllers/Assets/UploadController.php @@ -11,6 +11,7 @@ use CraftCms\Cms\Asset\Exceptions\AssetDisallowedExtensionException; use CraftCms\Cms\Asset\Exceptions\UploadFailedException; use CraftCms\Cms\Asset\Folders; +use CraftCms\Cms\Asset\Validation\AssetRules; use CraftCms\Cms\Cms; use CraftCms\Cms\Element\Conditions\ElementCondition; use CraftCms\Cms\Element\Elements; @@ -114,7 +115,7 @@ public function upload(Request $request): Response $asset->title = AssetsHelper::filename2Title(pathinfo($originalFilename, PATHINFO_FILENAME)); } - $asset->setScenario(Asset::SCENARIO_CREATE); + $asset->ruleset->useScenario(AssetRules::SCENARIO_CREATE); $result = $this->elements->saveElement($asset); // In case of error, let user know about it. @@ -136,7 +137,7 @@ public function upload(Request $request): Response // move it into the original target destination $asset->newFilename = $originalFilename; $asset->newFolderId = $originalFolder->id; - $asset->setScenario(Asset::SCENARIO_MOVE); + $asset->ruleset->useScenario(AssetRules::SCENARIO_MOVE); if (! $this->elements->saveElement($asset)) { return $this->asModelFailure($asset); diff --git a/src/Http/Controllers/Auth/SetPasswordController.php b/src/Http/Controllers/Auth/SetPasswordController.php index 9a6e5ac19c2..f06a74ec770 100644 --- a/src/Http/Controllers/Auth/SetPasswordController.php +++ b/src/Http/Controllers/Auth/SetPasswordController.php @@ -13,6 +13,7 @@ use CraftCms\Cms\Support\Url; use CraftCms\Cms\User\Elements\User; use CraftCms\Cms\User\Users; +use CraftCms\Cms\User\Validation\UserRules; use Illuminate\Contracts\View\View; use Illuminate\Http\Request; use Illuminate\Support\Facades\Password as PasswordFacade; @@ -72,7 +73,7 @@ public function store(Request $request, Users $users, Elements $elements): Respo ], function (User $user, string $password) use ($elements) { $user->newPassword = $password; - $user->setScenario(User::SCENARIO_PASSWORD); + $user->ruleset->useScenario(UserRules::SCENARIO_PASSWORD); if (! $elements->saveElement($user)) { throw new RuntimeException('Couldn’t update password.'); diff --git a/src/Http/Controllers/Entries/CreateEntryController.php b/src/Http/Controllers/Entries/CreateEntryController.php index 4cc6b778733..3f1fad7b98f 100644 --- a/src/Http/Controllers/Entries/CreateEntryController.php +++ b/src/Http/Controllers/Entries/CreateEntryController.php @@ -6,9 +6,9 @@ use CraftCms\Cms\Cp\RequestedSite; use CraftCms\Cms\Element\Drafts; -use CraftCms\Cms\Element\Element; use CraftCms\Cms\Element\ElementHelper; use CraftCms\Cms\Element\Enums\PropagationMethod; +use CraftCms\Cms\Element\Validation\ElementRules; use CraftCms\Cms\Entry\Elements\Entry; use CraftCms\Cms\Entry\Entries; use CraftCms\Cms\Http\RespondsWithFlash; @@ -95,7 +95,7 @@ public function __invoke(Drafts $drafts, Users $users): Response } // Save it - $entry->setScenario(Element::SCENARIO_ESSENTIALS); + $entry->ruleset->useScenario(ElementRules::SCENARIO_ESSENTIALS); $success = $drafts->saveElementAsDraft($entry, $user->id, markAsSaved: false); // Resume time diff --git a/src/Http/Controllers/Entries/StoreEntryController.php b/src/Http/Controllers/Entries/StoreEntryController.php index 7f5b8acce12..8c91b2cf519 100644 --- a/src/Http/Controllers/Entries/StoreEntryController.php +++ b/src/Http/Controllers/Entries/StoreEntryController.php @@ -6,10 +6,10 @@ use CraftCms\Cms\Auth\Concerns\EnforcesPermissions; use CraftCms\Cms\Cp\Html\ElementHtml; -use CraftCms\Cms\Element\Element; use CraftCms\Cms\Element\Elements; use CraftCms\Cms\Element\Exceptions\InvalidElementException; use CraftCms\Cms\Element\Exceptions\UnsupportedSiteException; +use CraftCms\Cms\Element\Validation\ElementRules; use CraftCms\Cms\Entry\Elements\Entry; use CraftCms\Cms\Entry\Entries; use CraftCms\Cms\Http\RespondsWithFlash; @@ -63,7 +63,7 @@ public function __invoke(): Response // Save the entry (finally!) if ($entry->enabled && $entry->getEnabledForSite()) { - $entry->setScenario(Element::SCENARIO_LIVE); + $entry->ruleset->useScenario(ElementRules::SCENARIO_LIVE); } $isNotNew = (bool) $entry->id; diff --git a/src/Http/Controllers/MatrixController.php b/src/Http/Controllers/MatrixController.php index 2832071e7f4..22465d301cf 100644 --- a/src/Http/Controllers/MatrixController.php +++ b/src/Http/Controllers/MatrixController.php @@ -5,12 +5,12 @@ namespace CraftCms\Cms\Http\Controllers; use CraftCms\Cms\Element\Drafts; -use CraftCms\Cms\Element\Element; use CraftCms\Cms\Element\ElementCollection; use CraftCms\Cms\Element\ElementHelper; use CraftCms\Cms\Element\Elements; use CraftCms\Cms\Element\Exceptions\InvalidElementException; use CraftCms\Cms\Element\Queries\EntryQuery; +use CraftCms\Cms\Element\Validation\ElementRules; use CraftCms\Cms\Entry\Elements\Entry; use CraftCms\Cms\Entry\EntryTypes; use CraftCms\Cms\Field\Matrix; @@ -134,7 +134,7 @@ public function createEntry(Request $request): Response Gate::authorize('save', $entry); - $entry->setScenario(Element::SCENARIO_ESSENTIALS); + $entry->ruleset->useScenario(ElementRules::SCENARIO_ESSENTIALS); if (! $this->drafts->saveElementAsDraft($entry, $request->user()->id, markAsSaved: false)) { return $this->asFailure(mb_ucfirst(t('Couldn’t create {type}.', [ diff --git a/src/Http/Controllers/Users/AddressesController.php b/src/Http/Controllers/Users/AddressesController.php index 6b02c6ff37f..192507ad492 100644 --- a/src/Http/Controllers/Users/AddressesController.php +++ b/src/Http/Controllers/Users/AddressesController.php @@ -7,6 +7,7 @@ use CraftCms\Cms\Address\Elements\Address; use CraftCms\Cms\Element\Element; use CraftCms\Cms\Element\Elements; +use CraftCms\Cms\Element\Validation\ElementRules; use CraftCms\Cms\Http\RespondsWithFlash; use CraftCms\Cms\Http\Responses\CpScreenResponse; use CraftCms\Cms\Support\Html; @@ -68,14 +69,14 @@ public function store(Request $request, Elements $elements): Response Gate::authorize('save', $address); // Addresses have no status, and the default element save controller also sets the address scenario to live - $address->setScenario(Element::SCENARIO_LIVE); + $address->ruleset->useScenario(ElementRules::SCENARIO_LIVE); // Name attributes $this->populateNameAttributes($request, $address); // All safe attributes $safeAttributes = []; - foreach ($address->safeAttributes() as $name) { + foreach (array_keys($address->ruleset->rules()) as $name) { if (in_array($name, ['id', 'uid', 'ownerId'])) { continue; } diff --git a/src/Http/Controllers/Users/PasswordController.php b/src/Http/Controllers/Users/PasswordController.php index f76221ce530..3f92a98dea4 100644 --- a/src/Http/Controllers/Users/PasswordController.php +++ b/src/Http/Controllers/Users/PasswordController.php @@ -13,6 +13,7 @@ use CraftCms\Cms\Http\Responses\CpScreenResponse; use CraftCms\Cms\User\Elements\User; use CraftCms\Cms\User\Users; +use CraftCms\Cms\User\Validation\UserRules; use CraftCms\Cms\View\LegacyAssets\AuthMethodSetupAsset; use CraftCms\Cms\View\LegacyAssets\InternalAssetRegistry; use Exception; @@ -65,7 +66,7 @@ public function store(Request $request, Elements $elements): Response } $user->newPassword = $validated['newPassword']; - $user->setScenario(User::SCENARIO_PASSWORD); + $user->ruleset->useScenario(UserRules::SCENARIO_PASSWORD); if (! $elements->saveElement($user)) { return $this->asFailure( diff --git a/src/Http/Controllers/Users/SaveUserController.php b/src/Http/Controllers/Users/SaveUserController.php index b98894d8eed..3bf0a89d357 100644 --- a/src/Http/Controllers/Users/SaveUserController.php +++ b/src/Http/Controllers/Users/SaveUserController.php @@ -10,8 +10,8 @@ use CraftCms\Cms\Auth\Concerns\ConfirmsPasswords; use CraftCms\Cms\Config\GeneralConfig; use CraftCms\Cms\Edition; -use CraftCms\Cms\Element\Element; use CraftCms\Cms\Element\Elements; +use CraftCms\Cms\Element\Validation\ElementRules; use CraftCms\Cms\Http\RespondsWithFlash; use CraftCms\Cms\Image\ImageHelper; use CraftCms\Cms\ProjectConfig\ProjectConfig; @@ -23,6 +23,7 @@ use CraftCms\Cms\Support\Url; use CraftCms\Cms\User\Elements\User; use CraftCms\Cms\User\Users; +use CraftCms\Cms\User\Validation\UserRules; use Illuminate\Http\Request; use Illuminate\Support\Facades\Gate; use Illuminate\Support\Facades\Log; @@ -257,9 +258,9 @@ public function __invoke(Request $request): Response // Don't validate required custom fields if it's public registration if (! $isPublicRegistration || ($userSettings['validateOnPublicRegistration'] ?? false)) { - $user->setScenario(Element::SCENARIO_LIVE); + $user->ruleset->useScenario(ElementRules::SCENARIO_LIVE); } else { - $user->setScenario(User::SCENARIO_REGISTRATION); + $user->ruleset->useScenario(UserRules::SCENARIO_REGISTRATION); } // Manually validate the user so we can pass $clearErrors=false diff --git a/src/Http/Controllers/Users/UsersController.php b/src/Http/Controllers/Users/UsersController.php index f3c18211f60..81eb56d3936 100644 --- a/src/Http/Controllers/Users/UsersController.php +++ b/src/Http/Controllers/Users/UsersController.php @@ -9,8 +9,8 @@ use CraftCms\Cms\Auth\Concerns\EnforcesPermissions; use CraftCms\Cms\Edition; use CraftCms\Cms\Element\Drafts; -use CraftCms\Cms\Element\Element; use CraftCms\Cms\Element\Elements; +use CraftCms\Cms\Element\Validation\ElementRules; use CraftCms\Cms\Entry\Elements\Entry; use CraftCms\Cms\Http\RespondsWithFlash; use CraftCms\Cms\Http\Responses\CpScreenResponse; @@ -59,7 +59,7 @@ public function create(Request $request, Drafts $drafts): Response $this->authorize('save', $user); - $user->setScenario(Element::SCENARIO_ESSENTIALS); + $user->ruleset->useScenario(ElementRules::SCENARIO_ESSENTIALS); if (! $drafts->saveElementAsDraft($user, $request->user()->id, markAsSaved: false)) { return $this->asModelFailure($user, mb_ucfirst(t('Couldn’t create {type}.', [ 'type' => User::lowerDisplayName(), diff --git a/src/Image/ImageTransformHelper.php b/src/Image/ImageTransformHelper.php index f20af732c0e..ba2cf888e41 100644 --- a/src/Image/ImageTransformHelper.php +++ b/src/Image/ImageTransformHelper.php @@ -18,6 +18,7 @@ use CraftCms\Cms\Support\Facades\Path; use CraftCms\Cms\Support\File; use CraftCms\Cms\Support\Str; +use CraftCms\Cms\Support\Utils; use CraftCms\Cms\Validation\Rules\ColorRule; use Illuminate\Filesystem\LocalFilesystemAdapter; use Illuminate\Support\Facades\Log; @@ -98,7 +99,7 @@ public static function extendTransform(ImageTransform $transform, array $paramet // Don't change the same transform $transform = clone $transform; - $attributes = $transform->attributes(); + $attributes = Utils::getPublicAttributes($transform); $nullables = [ 'id', diff --git a/src/Plugin/PluginSettings.php b/src/Plugin/PluginSettings.php index 2d006c9fefa..80bba19c89a 100644 --- a/src/Plugin/PluginSettings.php +++ b/src/Plugin/PluginSettings.php @@ -4,10 +4,6 @@ namespace CraftCms\Cms\Plugin; -use CraftCms\Cms\Validation\Concerns\Validates; -use CraftCms\Cms\Validation\Contracts\Validatable; +use CraftCms\Cms\Component\Component; -abstract class PluginSettings implements Validatable -{ - use Validates; -} +abstract class PluginSettings extends Component {} diff --git a/src/Plugin/Plugins.php b/src/Plugin/Plugins.php index b12ec83862d..1db95415e3e 100644 --- a/src/Plugin/Plugins.php +++ b/src/Plugin/Plugins.php @@ -686,7 +686,7 @@ public function savePluginSettings(PluginInterface $plugin, array $settings): bo } // Update the plugin’s settings in the project config - $pluginSettings = ProjectConfigHelper::packAssociativeArrays($pluginSettings->getAttributes()); + $pluginSettings = ProjectConfigHelper::packAssociativeArrays($pluginSettings->validationData()); app(ProjectConfig::class)->set( path: ProjectConfig::PATH_PLUGINS.'.'.$plugin->handle.'.settings', value: $pluginSettings, diff --git a/src/Section/Data/Section.php b/src/Section/Data/Section.php index 5e3a3ddaf9a..05995ed1c2b 100644 --- a/src/Section/Data/Section.php +++ b/src/Section/Data/Section.php @@ -22,7 +22,7 @@ use CraftCms\Cms\Support\Facades\Sites; use CraftCms\Cms\Support\Str; use CraftCms\Cms\Support\Url; -use CraftCms\Cms\Validation\Attributes\Ruleset; +use CraftCms\RulesetValidation\Attributes\Ruleset; use Illuminate\Support\Facades\Auth; use Illuminate\Support\Facades\DB; use Stringable; diff --git a/src/Section/Data/SectionSiteSettings.php b/src/Section/Data/SectionSiteSettings.php index 45bb8d63e5d..4f21c303069 100644 --- a/src/Section/Data/SectionSiteSettings.php +++ b/src/Section/Data/SectionSiteSettings.php @@ -9,7 +9,7 @@ use CraftCms\Cms\Site\Data\Site; use CraftCms\Cms\Support\Facades\Sections; use CraftCms\Cms\Support\Facades\Sites; -use CraftCms\Cms\Validation\Attributes\Ruleset; +use CraftCms\RulesetValidation\Attributes\Ruleset; use RuntimeException; #[Ruleset(SectionSiteSettingsRules::class)] diff --git a/src/Section/Sections.php b/src/Section/Sections.php index 5d4ef8f4d25..408dc31f184 100644 --- a/src/Section/Sections.php +++ b/src/Section/Sections.php @@ -14,6 +14,7 @@ use CraftCms\Cms\Element\Enums\PropagationMethod; use CraftCms\Cms\Element\Jobs\ApplyNewPropagationMethod; use CraftCms\Cms\Element\Jobs\ResaveElements; +use CraftCms\Cms\Element\Validation\ElementRules; use CraftCms\Cms\Entry\Data\EntryType; use CraftCms\Cms\Entry\Elements\Entry; use CraftCms\Cms\ProjectConfig\Events\ConfigEvent; @@ -904,7 +905,7 @@ private function ensureSingleEntry(Section $section, ?array $siteSettings = null } // Validate first - $entry->setScenario(Element::SCENARIO_ESSENTIALS); + $entry->ruleset->useScenario(ElementRules::SCENARIO_ESSENTIALS); $entry->validate(); // If there are any errors on the URI, re-validate as disabled diff --git a/src/Section/Validation/SectionRules.php b/src/Section/Validation/SectionRules.php index 6a236744617..bc78859e209 100644 --- a/src/Section/Validation/SectionRules.php +++ b/src/Section/Validation/SectionRules.php @@ -14,15 +14,13 @@ use CraftCms\Cms\Validation\Ruleset; use Illuminate\Support\Facades\DB; use Illuminate\Validation\Rule; -use Override; use function CraftCms\Cms\t; /** @extends Ruleset
*/ class SectionRules extends Ruleset { - #[Override] - public function defineRules(): array + public function rules(): array { return [ 'id' => ['nullable', 'integer'], @@ -35,7 +33,7 @@ public function defineRules(): array 'string', 'max:255', new HandleRule(['id', 'dateCreated', 'dateUpdated', 'uid', 'title']), - Rule::unique(Table::SECTIONS)->ignore($this->component->id)->withoutTrashed('dateDeleted'), + Rule::unique(Table::SECTIONS)->ignore($this->subject->id)->withoutTrashed('dateDeleted'), ], 'entryTypes' => ['required'], 'type' => ['required', Rule::enum(SectionType::class)], @@ -62,25 +60,25 @@ private function validateSiteSettings(Closure $fail): void { // If this is an existing section, make sure they aren't moving it to a // completely different set of sites in one fell swoop - if ($this->component->id) { + if ($this->subject->id) { $currentSiteIds = DB::table(Table::SECTIONS_SITES) - ->where('sectionId', $this->component->id) + ->where('sectionId', $this->subject->id) ->pluck('siteId') ->all(); - if (empty(array_intersect($currentSiteIds, array_keys($this->component->getSiteSettings())))) { + if (empty(array_intersect($currentSiteIds, array_keys($this->subject->getSiteSettings())))) { $fail(t('At least one currently-enabled site must remain enabled.')); } } - foreach ($this->component->getSiteSettings() as $i => $siteSettings) { + foreach ($this->subject->getSiteSettings() as $i => $siteSettings) { if ($siteSettings->validate()) { continue; } foreach ($siteSettings->errors()->getMessages() as $a => $errors) { foreach ($errors as $error) { - $this->component->errors()->add("siteSettings[$i].$a", $error); + $this->subject->errors()->add("siteSettings[$i].$a", $error); } } } diff --git a/src/Section/Validation/SectionSiteSettingsRules.php b/src/Section/Validation/SectionSiteSettingsRules.php index b7a3b1e50bc..fb5a29fa8c4 100644 --- a/src/Section/Validation/SectionSiteSettingsRules.php +++ b/src/Section/Validation/SectionSiteSettingsRules.php @@ -10,18 +10,16 @@ use CraftCms\Cms\Validation\Rules\SiteIdRule; use CraftCms\Cms\Validation\Rules\UriFormatRule; use CraftCms\Cms\Validation\Ruleset; -use Override; use Throwable; /** @extends Ruleset */ class SectionSiteSettingsRules extends Ruleset { - #[Override] - public function defineRules(): array + public function rules(): array { $section = null; try { - $section = $this->component->getSection(); + $section = $this->subject->getSection(); } catch (Throwable) { } diff --git a/src/Site/Data/Site.php b/src/Site/Data/Site.php index 79f0ffd2e72..e32a929e4ba 100644 --- a/src/Site/Data/Site.php +++ b/src/Site/Data/Site.php @@ -13,7 +13,7 @@ use CraftCms\Cms\Support\Facades\Sites; use CraftCms\Cms\Translation\Locale; use CraftCms\Cms\Twig\Attributes\AllowedInSandbox; -use CraftCms\Cms\Validation\Attributes\Ruleset; +use CraftCms\RulesetValidation\Attributes\Ruleset; use DateTimeInterface; use Override; use RuntimeException; diff --git a/src/Site/Validation/SiteRules.php b/src/Site/Validation/SiteRules.php index 2eba8d31afc..4d099af2f72 100644 --- a/src/Site/Validation/SiteRules.php +++ b/src/Site/Validation/SiteRules.php @@ -16,8 +16,7 @@ /** @extends Ruleset */ class SiteRules extends Ruleset { - #[\Override] - public function defineRules(): array + public function rules(): array { return [ 'language' => [ @@ -30,7 +29,7 @@ public function defineRules(): array new HandleRule(['id', 'dateCreated', 'dateUpdated', 'uid', 'title']), Rule::when( Cms::isInstalled(), - [new Unique(Table::SITES, 'handle')->ignore($this->component->id)->withoutTrashed('dateDeleted')], + [new Unique(Table::SITES, 'handle')->ignore($this->subject->id)->withoutTrashed('dateDeleted')], ), ], 'name' => [ @@ -38,7 +37,7 @@ public function defineRules(): array 'string', Rule::when( Cms::isInstalled(), - [new Unique(Table::SITES, 'name')->ignore($this->component->id)->withoutTrashed('dateDeleted')], + [new Unique(Table::SITES, 'name')->ignore($this->subject->id)->withoutTrashed('dateDeleted')], ), ], ]; diff --git a/src/User/Commands/SetPasswordCommand.php b/src/User/Commands/SetPasswordCommand.php index bc9b31973bf..c093f8384d3 100644 --- a/src/User/Commands/SetPasswordCommand.php +++ b/src/User/Commands/SetPasswordCommand.php @@ -6,7 +6,7 @@ use CraftCms\Cms\Console\CraftCommand; use CraftCms\Cms\Element\Elements; -use CraftCms\Cms\User\Elements\User; +use CraftCms\Cms\User\Validation\UserRules; use Illuminate\Console\Command; use Illuminate\Contracts\Console\PromptsForMissingInput; use Override; @@ -31,7 +31,7 @@ public function handle(Elements $elements): int return self::FAILURE; } - $user->setScenario(User::SCENARIO_PASSWORD); + $user->ruleset->useScenario(UserRules::SCENARIO_PASSWORD); $user->newPassword = $this->argument('password'); $this->components->task( diff --git a/src/User/Elements/User.php b/src/User/Elements/User.php index f23e0451d7c..e7f3d1ce37a 100644 --- a/src/User/Elements/User.php +++ b/src/User/Elements/User.php @@ -60,7 +60,7 @@ use CraftCms\Cms\User\Notifications\ResetPasswordNotification; use CraftCms\Cms\User\Notifications\VerifyEmailNotification; use CraftCms\Cms\User\Validation\UserRules; -use CraftCms\Cms\Validation\Attributes\Ruleset; +use CraftCms\RulesetValidation\Attributes\Ruleset; use DateInterval; use DateTime; use DateTimeZone; @@ -159,28 +159,6 @@ class User extends Element implements AuthenticatableContract, AuthorizableContr public const string STATUS_LOCKED = 'locked'; - // Validation scenarios - // ------------------------------------------------------------------------- - - /** - * @since 4.4.8 - */ - public const string SCENARIO_ACTIVATION = 'activation'; - - public const string SCENARIO_REGISTRATION = 'registration'; - - public const string SCENARIO_PASSWORD = 'password'; - - #[Override] - public function scenarios(): array - { - return array_merge(parent::scenarios(), [ - self::SCENARIO_PASSWORD => ['newPassword'], - self::SCENARIO_REGISTRATION => ['username', 'email', 'newPassword'], - self::SCENARIO_ACTIVATION => ['username', 'email'], - ]); - } - public function getAuthIdentifierName(): string { return 'id'; diff --git a/src/User/Users.php b/src/User/Users.php index f430e81a8c2..9ad0a7307c0 100644 --- a/src/User/Users.php +++ b/src/User/Users.php @@ -9,6 +9,7 @@ use CraftCms\Cms\Asset\Elements\Asset; use CraftCms\Cms\Asset\Exceptions\ImageException; use CraftCms\Cms\Asset\Exceptions\VolumeException; +use CraftCms\Cms\Asset\Validation\AssetRules; use CraftCms\Cms\Auth\Enums\CpAuthPath; use CraftCms\Cms\Cms; use CraftCms\Cms\Database\Table; @@ -56,6 +57,7 @@ use CraftCms\Cms\User\Events\UserUnlocked; use CraftCms\Cms\User\Events\UserUnsuspended; use CraftCms\Cms\User\Models\User as UserModel; +use CraftCms\Cms\User\Validation\UserRules; use CraftCms\DependencyAwareCache\Dependency\TagDependency; use DateTime; use Illuminate\Auth\Passwords\PasswordBroker; @@ -364,7 +366,7 @@ public function saveUserPhoto( $filename = AssetsService::getNameReplacementInFolder($filename, $folderId); $photo = new Asset; - $photo->setScenario(Asset::SCENARIO_CREATE); + $photo->ruleset->useScenario(AssetRules::SCENARIO_CREATE); $photo->tempFilePath = $fileLocation; $photo->setFilename($filename); $photo->setMimeType(File::getMimeType($fileLocation, checkExtension: false) ?? $mimeType); @@ -397,7 +399,7 @@ public function relocateUserPhoto(User $user): void return; } - $photo->setScenario(Asset::SCENARIO_MOVE); + $photo->ruleset->useScenario(AssetRules::SCENARIO_MOVE); $photo->avoidFilenameConflicts = true; $photo->newFolderId = $folderId; $this->elements->saveElement($photo); @@ -576,7 +578,7 @@ public function activateUser(User $user): void } $originalUser = clone $user; - $user->setScenario(User::SCENARIO_ACTIVATION); + $user->ruleset->useScenario(UserRules::SCENARIO_ACTIVATION); $user->active = true; $user->pending = false; $user->locked = false; @@ -927,7 +929,7 @@ public function setVerificationCodeOnUser(User $user): string $userModel->save(); $originalUser = clone $user; - $user->setScenario(User::SCENARIO_ACTIVATION); + $user->ruleset->useScenario(UserRules::SCENARIO_ACTIVATION); $user->pending = $userModel->pending; if (! $user->validate()) { diff --git a/src/User/Validation/UserRules.php b/src/User/Validation/UserRules.php index 604abf13570..b612301db1f 100644 --- a/src/User/Validation/UserRules.php +++ b/src/User/Validation/UserRules.php @@ -8,6 +8,7 @@ use CraftCms\Cms\Database\Table; use CraftCms\Cms\Element\Validation\ElementRules; 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; use CraftCms\Cms\User\Validation\Rules\UserPasswordRule; @@ -22,10 +23,19 @@ /** * @extends ElementRules * - * @property User $component + * @property User $subject */ class UserRules extends ElementRules { + /** + * @since 4.4.8 + */ + public const string SCENARIO_ACTIVATION = 'activation'; + + public const string SCENARIO_REGISTRATION = 'registration'; + + public const string SCENARIO_PASSWORD = 'password'; + private const array TRIMMABLE_ATTRIBUTES = [ 'email', 'unverifiedEmail', @@ -36,31 +46,31 @@ class UserRules extends ElementRules ]; #[Override] - public function prepareForValidation(?array $attributeNames = null): void + public function prepareForValidation(): void { - parent::prepareForValidation($attributeNames); + parent::prepareForValidation(); - $attributesToTrim = is_null($attributeNames) + $attributesToTrim = is_null($this->validationAttributes) ? self::TRIMMABLE_ATTRIBUTES - : array_intersect(self::TRIMMABLE_ATTRIBUTES, $attributeNames); + : array_intersect(self::TRIMMABLE_ATTRIBUTES, $this->validationAttributes); foreach ($attributesToTrim as $attribute) { - $value = $this->component->{$attribute}; + $value = $this->subject->{$attribute}; if (is_string($value)) { - $this->component->{$attribute} = trim($value); + $this->subject->{$attribute} = trim($value); } } } #[Override] - protected function defineRules(): array + public function rules(): array { - $rules = parent::defineRules(); + $rules = parent::rules(); - $treatAsActive = $this->component->getIsCredentialed() || $this->component->inScenarios( - User::SCENARIO_REGISTRATION, - User::SCENARIO_ACTIVATION, + $treatAsActive = $this->subject->getIsCredentialed() || $this->inScenarios( + self::SCENARIO_REGISTRATION, + self::SCENARIO_ACTIVATION, ); $unique = fn (string $column) => new UniqueCaseInsensitiveRule(Table::USERS, $column) @@ -68,7 +78,7 @@ protected function defineRules(): array ->where('active', true) ->orWhere('pending', true) ) - ->ignore($this->component->id); + ->ignore($this->subject->id); $noProtocol = function ($attribute, $value, $fail) { if (str_contains($value, '://')) { @@ -88,7 +98,7 @@ protected function defineRules(): array 'nullable', 'string', 'max:255', - Rule::requiredIf(fn () => ! $this->component->getIsDraft()), + Rule::requiredIf(fn () => ! $this->subject->getIsDraft()), 'email', ], 'unverifiedEmail' => [ @@ -106,12 +116,12 @@ protected function defineRules(): array ]); $requiredNameField = (fn (bool $requiredWhenFirstAndLastNameFields) => Cms::config()->showFirstAndLastNameFields === $requiredWhenFirstAndLastNameFields - && ($this->component + && ($this->subject ->getFieldLayout() - ->getFirstVisibleElementByType(FullNameField::class, $this->component) + ->getFirstVisibleElementByType(FullNameField::class, $this->subject) ->required ?? false)); - if ($this->component->inScenarios(User::SCENARIO_LIVE)) { + if ($this->inScenarios(self::SCENARIO_LIVE)) { $rules['firstName'][] = Rule::requiredIf($requiredNameField(true)); $rules['lastName'][] = Rule::requiredIf($requiredNameField(true)); $rules['fullName'][] = Rule::requiredIf($requiredNameField(false)); @@ -135,23 +145,43 @@ protected function defineRules(): array $currentPassword = null; - if (isset($this->component->id) && $this->component->passwordResetRequired) { + if (isset($this->subject->id) && $this->subject->passwordResetRequired) { $currentPassword = DB::table(Table::USERS) - ->where('id', $this->component->id) + ->where('id', $this->subject->id) ->value('password'); } $rules['newPassword'] = [ Rule::requiredIf( ! Cms::config()->deferPublicRegistrationPassword - && $this->component->inScenarios(User::SCENARIO_PASSWORD, User::SCENARIO_REGISTRATION) + && $this->inScenarios(self::SCENARIO_PASSWORD, self::SCENARIO_REGISTRATION) ), new UserPasswordRule( - forceDifferent: $this->component->passwordResetRequired, + forceDifferent: $this->subject->passwordResetRequired, currentPassword: $currentPassword, ), ]; return $rules; } + + #[Override] + protected function validationRules(): array + { + $rules = parent::validationRules(); + + if ($this->inScenarios(self::SCENARIO_PASSWORD)) { + return Arr::only($rules, ['newPassword']); + } + + if ($this->inScenarios(self::SCENARIO_REGISTRATION)) { + return Arr::only($rules, ['username', 'email', 'newPassword']); + } + + if ($this->inScenarios(self::SCENARIO_ACTIVATION)) { + return Arr::only($rules, ['username', 'email']); + } + + return $rules; + } } diff --git a/src/Validation/Attributes/Ruleset.php b/src/Validation/Attributes/Ruleset.php deleted file mode 100644 index f488a83886d..00000000000 --- a/src/Validation/Attributes/Ruleset.php +++ /dev/null @@ -1,16 +0,0 @@ - */ - public string $class, - ) {} -} diff --git a/src/Validation/ComponentRules.php b/src/Validation/ComponentRules.php new file mode 100644 index 00000000000..3b5cce39123 --- /dev/null +++ b/src/Validation/ComponentRules.php @@ -0,0 +1,43 @@ + + */ +class ComponentRules extends Ruleset +{ + public function rules(): array + { + return $this->subject->getRules(); + } + + #[Override] + public function attributes(): array + { + return $this->subject->attributeLabels(); + } + + #[Override] + public function messages(): array + { + return $this->subject->getMessages(); + } + + #[Override] + protected function prepareForValidation(): void + { + $this->subject->prepareForValidation(); + } + + #[Override] + protected function passedValidation(): void + { + $this->subject->passedValidation(); + } +} diff --git a/src/Validation/Concerns/HasScenarios.php b/src/Validation/Concerns/HasScenarios.php deleted file mode 100644 index ac8e6a50b51..00000000000 --- a/src/Validation/Concerns/HasScenarios.php +++ /dev/null @@ -1,30 +0,0 @@ -_scenario = $scenario; - } - - public function getScenario(): string - { - return $this->_scenario; - } - - public function scenarios(): array - { - return []; - } - - public function inScenarios(string ...$scenarios): bool - { - return in_array($this->_scenario, $scenarios, true); - } -} diff --git a/src/Validation/Concerns/InteractsWithValidator.php b/src/Validation/Concerns/InteractsWithValidator.php deleted file mode 100644 index 56b53bc3e5e..00000000000 --- a/src/Validation/Concerns/InteractsWithValidator.php +++ /dev/null @@ -1,75 +0,0 @@ - Arr::first($messages), $this->errors()->getMessages()); - } - - public function errors(): MessageBag - { - return $this->errors ??= new MessageBag; - } - - /** - * TODO: Add types to method signature once components no longer rely - * on craft/base/Model - * - * @param array|string|null $attributeNames - * @param bool $clearErrors - */ - public function validate($attributeNames = null, $clearErrors = true, bool $throw = false): bool - { - if ($clearErrors) { - $this->errors = new MessageBag; - } - - if (is_string($attributeNames)) { - $attributeNames = [$attributeNames]; - } - - if ($ruleset = $this->getRuleset()) { - $ruleset->prepareForValidation($attributeNames); - } - - $validator = $this->getValidator($attributeNames) - ->after(fn ($validator) => $this->afterValidate($validator)); - - if ($throw) { - $validator->validate(); - } - - $result = $validator->passes(); - - $this->errors()->merge($validator->errors()->getMessages()); - - return $result && $this->errors()->isEmpty(); - } -} diff --git a/src/Validation/Concerns/Validates.php b/src/Validation/Concerns/Validates.php index 53e2b579dad..99aa90d5f2e 100644 --- a/src/Validation/Concerns/Validates.php +++ b/src/Validation/Concerns/Validates.php @@ -4,60 +4,70 @@ namespace CraftCms\Cms\Validation\Concerns; -use BadMethodCallException; -use CraftCms\Cms\Component\Exceptions\InvalidCallException; -use CraftCms\Cms\Component\Exceptions\UnknownPropertyException; use CraftCms\Cms\Support\Arr; -use CraftCms\Cms\Support\Typecast; use CraftCms\Cms\Support\Utils; -use CraftCms\Cms\Validation\Attributes\Ruleset as RulesetAttribute; -use CraftCms\Cms\Validation\Contracts\Validatable; -use CraftCms\Cms\Validation\Ruleset; -use Illuminate\Support\Facades\Validator as ValidatorFacade; +use CraftCms\RulesetValidation\Concerns\HasRuleset; +use Illuminate\Support\MessageBag; use Illuminate\Validation\Validator; -use ReflectionClass; -/** - * @mixin Validatable - */ trait Validates { - use HasScenarios; - use InteractsWithValidator; + use HasRuleset; - private Ruleset|false|null $ruleset = null; + private ?MessageBag $errors = null; - public function getRules(): array + public function getFirstErrors(): array { - return []; + return array_map(fn (array $messages) => Arr::first($messages), $this->errors()->getMessages()); } - public function getMessages(): array + public function errors(): MessageBag { - return []; + return $this->errors ??= new MessageBag; } - public function setAttributes($values, $safeOnly = true): void + /** + * TODO: Add types to method signature once components no longer rely + * on craft/base/Model + * + * @param array|string|null $attributeNames + * @param bool $clearErrors + */ + public function validate($attributeNames = null, $clearErrors = true, bool $throw = false): bool { - Typecast::properties(static::class, $values); - - foreach ($values as $name => $value) { - try { - $this->$name = $value; - } catch (UnknownPropertyException|InvalidCallException|\yii\base\UnknownPropertyException) { - // Property or setter doesn't exist - } + if ($clearErrors) { + $this->errors = new MessageBag; + } + + if (is_string($attributeNames)) { + $attributeNames = [$attributeNames]; + } + + $ruleset = $this->ruleset; + + if (! is_null($attributeNames)) { + $ruleset->only($attributeNames); + } + + if ($throw) { + $ruleset->validate(); } + + $result = $ruleset->passes(); + + $this->errors()->merge($ruleset->getValidator()->errors()); + + return $result && $this->errors()->isEmpty(); } - public function getAttributes(): array + public function getRules(): array { - return Utils::getPublicProperties($this); + return []; } - public function attributes(): array + public function getMessages(): array { - return Utils::getPublicAttributes($this); + return []; } public function attributeLabels(): array @@ -65,46 +75,14 @@ public function attributeLabels(): array return []; } - public function getRuleset(): Ruleset|false - { - if (isset($this->ruleset)) { - return $this->ruleset; - } + public function prepareForValidation(): void {} - $attributes = new ReflectionClass($this)->getAttributes(RulesetAttribute::class); + public function passedValidation(): void {} - $class = null; - if (isset($attributes[0])) { - $class = $attributes[0]->getArguments()[0]; - } elseif (method_exists($this, 'rulesClass')) { - $class = $this->rulesClass(); - } - - if (is_null($class)) { - return $this->ruleset = false; - } - - if (! is_subclass_of($class, Ruleset::class)) { - throw new BadMethodCallException('The rules class must be an instance of '.Ruleset::class); - } - - return $this->ruleset = app()->make($class, ['component' => $this]); - } + public function afterValidate(?Validator $validator = null): void {} - protected function getValidator(?array $attributeNames = null): Validator + public function validationData(): array { - $ruleset = $this->getRuleset(); - $rules = $ruleset ? $ruleset->rules() : $this->getRules(); - $attributes = $ruleset ? $ruleset->attributes() : $this->attributeLabels(); - $messages = $ruleset ? $ruleset->messages() : $this->getMessages(); - - return ValidatorFacade::make([], []) - ->setData($this->getAttributes()) - ->setCustomMessages($messages) - ->setAttributeNames($attributes) - ->setRules(is_null($attributeNames) - ? $rules - : Arr::only($rules, $attributeNames) - ); + return Arr::except(Utils::getPublicProperties($this), ['ruleset']); } } diff --git a/src/Validation/Contracts/Validatable.php b/src/Validation/Contracts/Validatable.php index c8a40ade287..926b9c23ca4 100644 --- a/src/Validation/Contracts/Validatable.php +++ b/src/Validation/Contracts/Validatable.php @@ -4,10 +4,11 @@ namespace CraftCms\Cms\Validation\Contracts; +use CraftCms\RulesetValidation\Contracts\ValidatesWithRuleset; use Illuminate\Contracts\Support\MessageBag; use Illuminate\Validation\Validator; -interface Validatable +interface Validatable extends ValidatesWithRuleset { /** * Returns the validation rules or ruleset for attributes. @@ -24,13 +25,27 @@ public function getRules(): array; public function getMessages(): array; /** - * This method is invoked before validation starts. - * The default implementation returns true, allowing validation to proceed. - * Override this method to perform pre-validation logic or to conditionally skip validation. + * Returns human-readable labels for attributes. + * These labels are used in validation error messages. For example, given an attribute + * `firstName`, a label `First Name` can be declared for display to end users. + * The default implementation returns an empty array. + * + * @return array + */ + public function attributeLabels(): array; + + /** + * Sets attribute values. * - * @return bool whether the validation should be executed. + * @param array $values attribute values to set (attribute name => value). + */ + public function setAttributes(array $values): void; + + /** + * This method is invoked before validation starts. + * Override this method to perform pre-validation logic. */ - public function beforeValidate(): bool; + public function prepareForValidation(): void; /** * Validates the attributes. @@ -62,84 +77,4 @@ public function getFirstErrors(): array; * Returns the validation error messages. */ public function errors(): MessageBag; - - /** - * Sets attribute values. - * - * @param array $values attribute values to set (attribute name => value). - */ - public function setAttributes(array $values): void; - - /** - * Returns all attribute values. - * By default, this returns all public non-static properties of the class. - * - * @return array - */ - public function getAttributes(): array; - - /** - * Returns the list of attribute names. - * By default, this returns all public non-static property names of the class. - * - * @return string[] list of attribute names. - */ - public function attributes(): array; - - /** - * Returns human-readable labels for attributes. - * These labels are used in validation error messages. For example, given an attribute - * `firstName`, a label `First Name` can be declared for display to end users. - * The default implementation returns an empty array. - * - * @return array - */ - public function attributeLabels(): array; - - /** - * Sets the current validation scenario. - * - * Scenarios allow components to use different validation rules based on context. - * For example, a 'create' scenario might require certain fields, while an 'update' - * scenario might have different requirements. - * - * @param string $scenario The scenario name to set - */ - public function setScenario(string $scenario): void; - - /** - * Returns the current validation scenario. - * - * @return string The active scenario name - */ - public function getScenario(): string; - - /** - * Returns a mapping of scenario names to their active attributes. - * - * Each scenario defines which attributes should be validated. The returned array - * maps scenario names (keys) to either: - * - An array of attribute names that should be validated in that scenario - * - null to indicate all attributes should be validated - * - * Example: - * ```php - * [ - * 'create' => ['title', 'slug', 'body'], - * 'update' => ['title', 'body'], - * 'default' => null, // All attributes - * ] - * ``` - * - * @return array|null> - */ - public function scenarios(): array; - - /** - * Checks if the current scenario matches any of the provided scenarios. - * - * @param string ...$scenarios One or more scenario names to check against - * @return bool True if the current scenario matches any provided scenario - */ - public function inScenarios(string ...$scenarios): bool; } diff --git a/src/Validation/Ruleset.php b/src/Validation/Ruleset.php index c867361f5bb..668e74231f5 100644 --- a/src/Validation/Ruleset.php +++ b/src/Validation/Ruleset.php @@ -6,90 +6,36 @@ use CraftCms\Cms\Element\Validation\Events\DefineValidationRules; use CraftCms\Cms\Validation\Contracts\Validatable; +use Illuminate\Validation\Validator; +use Override; /** * @template T of Validatable + * + * @property T $subject */ -abstract class Ruleset +abstract class Ruleset extends \CraftCms\RulesetValidation\Ruleset { - public function __construct( - /** @var T */ - protected readonly Validatable $component, - ) {} - - /** - * Returns the validation rules for the current scenario. - * - * This method combines the rules defined in defineRules() with any rules - * added by event listeners, then filters them based on the active scenario. - * If a scenario is active, only rules for attributes defined in that scenario - * are returned. - * - * @return array - */ - final public function rules(): array + #[Override] + protected function validationRules(): array { - $rules = $this->defineRules(); - - event($event = new DefineValidationRules($this->component, $rules)); + $rules = parent::validationRules(); - $attributes = $this->component->scenarios()[$this->component->getScenario()] ?? null; + event($event = new DefineValidationRules($this->subject, $rules)); - return collect($event->rules) - ->unless( - is_null($attributes), - fn ($rules) => $rules->filter( - fn ($rule, string $attribute) => in_array($attribute, $attributes, true), - ) - ) - ->filter() - ->all(); - } - - /** - * Returns custom validation error messages. - * - * Override this method to provide custom error messages for specific - * validation rules. - * - * @return array - */ - public function messages(): array - { - return []; + return $event->rules; } - /** - * Get custom attributes for validator errors. - * - * @return array - */ - public function attributes(): array - { - return $this->component->attributeLabels(); - } - - /** - * Prepare the component for validation. - * - * Use this method to normalize or transform attribute values before - * validation runs (e.g., trimming strings, normalizing slugs). - * - * @param array|null $attributeNames The attributes being validated, or null for all - */ - public function prepareForValidation(?array $attributeNames = null): void {} - - /** - * Define the validation rules for this ruleset. - * - * Override this method in subclasses to define the base validation rules - * for the component. Rules can be modified by event listeners before - * being returned by the rules() method. - * - * @return array - */ - protected function defineRules(): array + public function after(): array { - return []; + if (! method_exists($this->subject, 'afterValidate')) { + return []; + } + + return [ + function (Validator $validator) { + $this->subject->afterValidate($validator); + }, + ]; } } diff --git a/tests/ArchTest.php b/tests/ArchTest.php index 1af5576ab63..93fe3a26a8c 100644 --- a/tests/ArchTest.php +++ b/tests/ArchTest.php @@ -36,19 +36,19 @@ arch() ->expect(Arr::class) ->not - ->toBeUsed() + ->toBeUsedIn('src') ->ignoring(CraftCms\Cms\Support\Arr::class); arch() ->expect(Illuminate\Support\Facades\File::class) ->not - ->toBeUsed() + ->toBeUsedIn('src') ->ignoring(File::class); arch() ->expect(Str::class) ->not - ->toBeUsed() + ->toBeUsedIn('src') ->ignoring(CraftCms\Cms\Support\Str::class); arch('Only use JSON helper') diff --git a/tests/Feature/Address/Elements/AddressValidationTest.php b/tests/Feature/Address/Elements/AddressValidationTest.php index 968de7e745f..8fe740152c2 100644 --- a/tests/Feature/Address/Elements/AddressValidationTest.php +++ b/tests/Feature/Address/Elements/AddressValidationTest.php @@ -2,8 +2,8 @@ declare(strict_types=1); -use CraftCms\Cms\Address\Elements\Address; use CraftCms\Cms\Address\Models\Address as AddressModel; +use CraftCms\Cms\Address\Validation\AddressRules; use CraftCms\Cms\Cms; describe('Country code format validation', function () { @@ -134,7 +134,7 @@ describe('Country-specific required fields (SCENARIO_LIVE)', function () { test('US address required fields on SCENARIO_LIVE', function (string $field, bool $expectError) { $address = AddressModel::factory()->createElement(['countryCode' => 'US']); - $address->setScenario(Address::SCENARIO_LIVE); + $address->ruleset->useScenario(AddressRules::SCENARIO_LIVE); $address->{$field} = null; $address->validate([$field]); @@ -149,7 +149,7 @@ test('BE address required fields on SCENARIO_LIVE', function (string $field, bool $expectError) { $address = AddressModel::factory()->createElement(['countryCode' => 'BE']); - $address->setScenario(Address::SCENARIO_LIVE); + $address->ruleset->useScenario(AddressRules::SCENARIO_LIVE); $address->{$field} = null; $address->validate([$field]); @@ -163,7 +163,7 @@ test('fields not required by country pass validation on SCENARIO_LIVE', function () { $address = AddressModel::factory()->createElement(['countryCode' => 'US']); - $address->setScenario(Address::SCENARIO_LIVE); + $address->ruleset->useScenario(AddressRules::SCENARIO_LIVE); $address->dependentLocality = null; $address->sortingCode = null; diff --git a/tests/Feature/Asset/Elements/AssetValidationTest.php b/tests/Feature/Asset/Elements/AssetValidationTest.php index 6f3e988bfc6..56a15382b6e 100644 --- a/tests/Feature/Asset/Elements/AssetValidationTest.php +++ b/tests/Feature/Asset/Elements/AssetValidationTest.php @@ -4,6 +4,7 @@ use CraftCms\Cms\Asset\Elements\Asset; use CraftCms\Cms\Asset\Models\Asset as AssetModel; +use CraftCms\Cms\Asset\Validation\AssetRules; describe('Required field validation', function () { test('filename validation', function (string $value, bool $expectError) { @@ -50,7 +51,7 @@ test('title length validation on create scenario', function (int $length, bool $expectError) { $asset = AssetModel::factory()->createElement(); $asset->title = str_repeat('a', $length); - $asset->setScenario(Asset::SCENARIO_CREATE); + $asset->ruleset->useScenario(AssetRules::SCENARIO_CREATE); $asset->validate(['title']); @@ -75,39 +76,39 @@ describe('Scenario-specific required validation', function () { test('newLocation is required on specific scenarios', function (string $scenario, bool $expectError) { $asset = AssetModel::factory()->createElement(); - $asset->setScenario($scenario); + $asset->ruleset->useScenario($scenario); $asset->newLocation = null; $asset->validate(['newLocation']); expect($asset->errors()->has('newLocation'))->toBe($expectError); })->with([ - 'SCENARIO_CREATE requires newLocation' => [Asset::SCENARIO_CREATE, true], - 'SCENARIO_MOVE requires newLocation' => [Asset::SCENARIO_MOVE, true], - 'SCENARIO_FILEOPS requires newLocation' => [Asset::SCENARIO_FILEOPS, true], - 'default scenario does not require newLocation' => [Asset::SCENARIO_DEFAULT, false], + 'SCENARIO_CREATE requires newLocation' => [AssetRules::SCENARIO_CREATE, true], + 'SCENARIO_MOVE requires newLocation' => [AssetRules::SCENARIO_MOVE, true], + 'SCENARIO_FILEOPS requires newLocation' => [AssetRules::SCENARIO_FILEOPS, true], + 'default scenario does not require newLocation' => [AssetRules::SCENARIO_DEFAULT, false], ]); test('tempFilePath is required on specific scenarios', function (string $scenario, bool $expectError) { $asset = AssetModel::factory()->createElement(); - $asset->setScenario($scenario); + $asset->ruleset->useScenario($scenario); $asset->tempFilePath = null; $asset->validate(['tempFilePath']); expect($asset->errors()->has('tempFilePath'))->toBe($expectError); })->with([ - 'SCENARIO_CREATE requires tempFilePath' => [Asset::SCENARIO_CREATE, true], - 'SCENARIO_REPLACE requires tempFilePath' => [Asset::SCENARIO_REPLACE, true], - 'default scenario does not require tempFilePath' => [Asset::SCENARIO_DEFAULT, false], - 'SCENARIO_MOVE does not require tempFilePath' => [Asset::SCENARIO_MOVE, false], + 'SCENARIO_CREATE requires tempFilePath' => [AssetRules::SCENARIO_CREATE, true], + 'SCENARIO_REPLACE requires tempFilePath' => [AssetRules::SCENARIO_REPLACE, true], + 'default scenario does not require tempFilePath' => [AssetRules::SCENARIO_DEFAULT, false], + 'SCENARIO_MOVE does not require tempFilePath' => [AssetRules::SCENARIO_MOVE, false], ]); }); describe('SCENARIO_INDEX validation', function () { test('SCENARIO_INDEX has empty validation attributes', function () { $asset = AssetModel::factory()->createElement(); - $asset->setScenario(Asset::SCENARIO_INDEX); + $asset->ruleset->useScenario(AssetRules::SCENARIO_INDEX); $activeAttributes = $asset->activeAttributes(); @@ -118,7 +119,7 @@ $asset = AssetModel::factory()->createElement(); $asset->kind = ''; $asset->filename = ''; - $asset->setScenario(Asset::SCENARIO_INDEX); + $asset->ruleset->useScenario(AssetRules::SCENARIO_INDEX); $asset->validate(); diff --git a/tests/Feature/Element/ElementWrites/SaveElementTest.php b/tests/Feature/Element/ElementWrites/SaveElementTest.php index 27d3d3c1d04..231cb348d83 100644 --- a/tests/Feature/Element/ElementWrites/SaveElementTest.php +++ b/tests/Feature/Element/ElementWrites/SaveElementTest.php @@ -11,6 +11,7 @@ use CraftCms\Cms\Element\Operations\ElementWrites; use CraftCms\Cms\Element\Queries\ElementQuery; use CraftCms\Cms\Element\Queries\Exceptions\ElementNotFoundException; +use CraftCms\Cms\Element\Validation\ElementRules; use CraftCms\Cms\Entry\Elements\Entry; use CraftCms\Cms\Entry\Models\Entry as EntryModel; use CraftCms\Cms\Field\PlainText; @@ -346,6 +347,23 @@ function createEntryWithPlainTextField(array $entryAttributes = []): array Event::assertDispatched(fn (AfterSaveElement $event): bool => $event->element->id === $entry->id && $event->isNew === false); }); +it('preserves the caller scenario after save', function (?string $scenario) { + $entry = EntryModel::factory()->createElement(['title' => 'Initial title']); + + if ($scenario !== null) { + $entry->ruleset->useScenario($scenario); + } + + $entry->title = 'Saved title'; + + expect($this->writes->saveElement($entry, updateSearchIndex: false))->toBeTrue() + ->and($entry->ruleset->getScenario())->toBe($scenario ?? ElementRules::SCENARIO_DEFAULT); +})->with([ + 'default scenario' => [null], + 'live scenario' => [ElementRules::SCENARIO_LIVE], + 'essentials scenario' => [ElementRules::SCENARIO_ESSENTIALS], +]); + it('enables the current site when a single-site element is disabled for that site', function () { $element = new TestSaveElementActionElement; $element->siteId = Sites::getPrimarySite()->id; diff --git a/tests/Feature/Element/Elements/ElementValidationTest.php b/tests/Feature/Element/Elements/ElementValidationTest.php index 8c05bb3b0aa..bd8ddd9d563 100644 --- a/tests/Feature/Element/Elements/ElementValidationTest.php +++ b/tests/Feature/Element/Elements/ElementValidationTest.php @@ -3,7 +3,7 @@ declare(strict_types=1); use CraftCms\Cms\Edition; -use CraftCms\Cms\Element\Element; +use CraftCms\Cms\Element\Validation\ElementRules; use CraftCms\Cms\Entry\Models\Entry as EntryModel; use CraftCms\Cms\Support\Facades\Sites; use Illuminate\Support\Facades\DB; @@ -25,7 +25,7 @@ test('siteId is validated on SCENARIO_LIVE', function () { $entry = EntryModel::factory()->createElement(); - $entry->setScenario(Element::SCENARIO_LIVE); + $entry->ruleset->useScenario(ElementRules::SCENARIO_LIVE); $site = Sites::getPrimarySite(); $entry->siteId = $site->id; @@ -36,7 +36,7 @@ test('siteId is validated on SCENARIO_ESSENTIALS', function () { $entry = EntryModel::factory()->createElement(); - $entry->setScenario(Element::SCENARIO_ESSENTIALS); + $entry->ruleset->useScenario(ElementRules::SCENARIO_ESSENTIALS); $site = Sites::getPrimarySite(); $entry->siteId = $site->id; @@ -90,7 +90,7 @@ test('title is required on SCENARIO_LIVE for elements with titles', function () { $entry = EntryModel::factory()->createElement(); - $entry->setScenario(Element::SCENARIO_LIVE); + $entry->ruleset->useScenario(ElementRules::SCENARIO_LIVE); $entry->title = ''; $entry->validate(['title']); @@ -139,7 +139,7 @@ test('slug is validated on SCENARIO_ESSENTIALS', function () { $entry = EntryModel::factory()->createElement(); - $entry->setScenario(Element::SCENARIO_ESSENTIALS); + $entry->ruleset->useScenario(ElementRules::SCENARIO_ESSENTIALS); $entry->slug = str_repeat('a', 256); $entry->validate(['slug']); @@ -159,7 +159,7 @@ test('uri is validated on SCENARIO_ESSENTIALS', function () { $entry = EntryModel::factory()->createElement(); - $entry->setScenario(Element::SCENARIO_ESSENTIALS); + $entry->ruleset->useScenario(ElementRules::SCENARIO_ESSENTIALS); $entry->validate(['uri']); @@ -170,7 +170,7 @@ describe('Scenario validation', function () { test('SCENARIO_LIVE validates title when required', function () { $entry = EntryModel::factory()->createElement(); - $entry->setScenario(Element::SCENARIO_LIVE); + $entry->ruleset->useScenario(ElementRules::SCENARIO_LIVE); $entry->title = ''; $entry->validate(['title']); diff --git a/tests/Feature/Entry/Elements/EntryValidationTest.php b/tests/Feature/Entry/Elements/EntryValidationTest.php index a4525cef8d7..1490cdbcf84 100644 --- a/tests/Feature/Entry/Elements/EntryValidationTest.php +++ b/tests/Feature/Entry/Elements/EntryValidationTest.php @@ -3,7 +3,7 @@ declare(strict_types=1); use CraftCms\Cms\Edition; -use CraftCms\Cms\Element\Element; +use CraftCms\Cms\Element\Validation\ElementRules; use CraftCms\Cms\Entry\Models\Entry as EntryModel; use CraftCms\Cms\Entry\Models\EntryType; use CraftCms\Cms\Section\Enums\SectionType; @@ -112,7 +112,7 @@ bool $expectError ) { $entry = EntryModel::factory()->createElement(); - $entry->setScenario(Element::SCENARIO_LIVE); + $entry->ruleset->useScenario(ElementRules::SCENARIO_LIVE); $entry->postDate = $postDate; $entry->expiryDate = $expiryDate; @@ -158,7 +158,7 @@ ->createElement(); if ($isLiveScenario) { - $entry->setScenario(Element::SCENARIO_LIVE); + $entry->ruleset->useScenario(ElementRules::SCENARIO_LIVE); } $entry->setAuthorIds([]); @@ -236,7 +236,7 @@ $entry->setAuthorIds([$user->id]); - $entry->validate(['authorIds']); + expect($entry->validate(['authorIds']))->toBeFalse(); expect($entry->errors()->has('authorIds'))->toBeTrue(); expect($entry->errors()->first('authorIds'))->toContain('permission'); @@ -306,7 +306,7 @@ describe('Scenario-specific validation', function () { test('SCENARIO_LIVE validates date comparison', function () { $entry = EntryModel::factory()->createElement(); - $entry->setScenario(Element::SCENARIO_LIVE); + $entry->ruleset->useScenario(ElementRules::SCENARIO_LIVE); $entry->postDate = new DateTime('2025-01-01'); $entry->expiryDate = new DateTime('2024-01-01'); @@ -338,7 +338,7 @@ ->forSection($section) ->forEntryType($entryType) ->createElement(); - $entry->setScenario(Element::SCENARIO_LIVE); + $entry->ruleset->useScenario(ElementRules::SCENARIO_LIVE); $entry->setAuthorIds([]); $entry->validate(['authorIds']); diff --git a/tests/Feature/Field/ElementRules/AddressesFieldElementRulesTest.php b/tests/Feature/Field/ElementRules/AddressesFieldElementRulesTest.php index f88e7174020..5d43243748e 100644 --- a/tests/Feature/Field/ElementRules/AddressesFieldElementRulesTest.php +++ b/tests/Feature/Field/ElementRules/AddressesFieldElementRulesTest.php @@ -2,7 +2,7 @@ declare(strict_types=1); -use CraftCms\Cms\Element\Element; +use CraftCms\Cms\Element\Validation\ElementRules; use CraftCms\Cms\Entry\Models\Entry as EntryModel; use CraftCms\Cms\Field\Addresses; @@ -16,7 +16,7 @@ $result = EntryModel::factory() ->withField('addressesField', Addresses::class, [], value: $value) - ->withScenario(Element::SCENARIO_LIVE) + ->withScenario(ElementRules::SCENARIO_LIVE) ->createElementWithFields(save: false); $result->element->validate(); diff --git a/tests/Feature/Field/ElementRules/AssetsFieldElementRulesTest.php b/tests/Feature/Field/ElementRules/AssetsFieldElementRulesTest.php index 24fd6949561..00418196584 100644 --- a/tests/Feature/Field/ElementRules/AssetsFieldElementRulesTest.php +++ b/tests/Feature/Field/ElementRules/AssetsFieldElementRulesTest.php @@ -4,7 +4,7 @@ use CraftCms\Cms\Asset\Elements\Asset as AssetElement; use CraftCms\Cms\Asset\Models\Asset as AssetModel; -use CraftCms\Cms\Element\Element; +use CraftCms\Cms\Element\Validation\ElementRules; use CraftCms\Cms\Entry\Models\Entry as EntryModel; use CraftCms\Cms\Field\Assets; @@ -20,7 +20,7 @@ $validResult = EntryModel::factory() ->withField('allowedAssets', Assets::class, ['restrictFiles' => true, 'allowedKinds' => ['image']], value: AssetElement::find()->id($image->id)) - ->withScenario(Element::SCENARIO_LIVE) + ->withScenario(ElementRules::SCENARIO_LIVE) ->createElementWithFields(save: false); $validResult->element->validate(); @@ -28,7 +28,7 @@ $invalidResult = EntryModel::factory() ->withField('blockedAssets', Assets::class, ['restrictFiles' => true, 'allowedKinds' => ['image']], value: AssetElement::find()->id($pdf->id)) - ->withScenario(Element::SCENARIO_LIVE) + ->withScenario(ElementRules::SCENARIO_LIVE) ->createElementWithFields(save: false); $invalidResult->element->validate(); diff --git a/tests/Feature/Field/ElementRules/ContentBlockFieldElementRulesTest.php b/tests/Feature/Field/ElementRules/ContentBlockFieldElementRulesTest.php index c273cf51b0a..770ff0d387e 100644 --- a/tests/Feature/Field/ElementRules/ContentBlockFieldElementRulesTest.php +++ b/tests/Feature/Field/ElementRules/ContentBlockFieldElementRulesTest.php @@ -2,7 +2,7 @@ declare(strict_types=1); -use CraftCms\Cms\Element\Element; +use CraftCms\Cms\Element\Validation\ElementRules; use CraftCms\Cms\Entry\Models\Entry as EntryModel; use CraftCms\Cms\Entry\Models\EntryType; use CraftCms\Cms\Field\ContentBlock; @@ -48,7 +48,7 @@ $result = EntryModel::factory() ->withField('contentBlock', ContentBlock::class, $contentBlockSettings, value: ['fields' => ['innerText' => null]]) - ->withScenario(Element::SCENARIO_LIVE) + ->withScenario(ElementRules::SCENARIO_LIVE) ->createElementWithFields(save: false); $result->element->validate(); @@ -95,7 +95,7 @@ $result = EntryModel::factory() ->withField('contentBlock', ContentBlock::class, $contentBlockField->getSettings()) - ->withScenario(Element::SCENARIO_LIVE) + ->withScenario(ElementRules::SCENARIO_LIVE) ->createElementWithFields(save: false); $result->element->setFieldValueFromRequest('contentBlock', [ @@ -141,7 +141,7 @@ $result = EntryModel::factory() ->withField('matrixField', Matrix::class, ['entryTypes' => [$matrixEntryType->id]]) - ->withScenario(Element::SCENARIO_LIVE) + ->withScenario(ElementRules::SCENARIO_LIVE) ->createElementWithFields(save: false); $blockUid = Str::uuid()->toString(); diff --git a/tests/Feature/Field/ElementRules/ElementAfterValidateTest.php b/tests/Feature/Field/ElementRules/ElementAfterValidateTest.php index 99e50a65093..e4edd380ef9 100644 --- a/tests/Feature/Field/ElementRules/ElementAfterValidateTest.php +++ b/tests/Feature/Field/ElementRules/ElementAfterValidateTest.php @@ -2,7 +2,7 @@ declare(strict_types=1); -use CraftCms\Cms\Element\Element; +use CraftCms\Cms\Element\Validation\ElementRules; use CraftCms\Cms\Entry\Elements\Entry as EntryElement; use CraftCms\Cms\Field\Email; use CraftCms\Cms\Field\Models\Field; @@ -41,7 +41,7 @@ $entry = new TestEntryWithAfterValidate; $entry->title = 'Test entry'; - $entry->setScenario(Element::SCENARIO_DEFAULT); + $entry->ruleset->useScenario(ElementRules::SCENARIO_DEFAULT); $entry->setMockFieldLayout($layout); $entry->setFieldValue($field->handle, 'not-an-email'); diff --git a/tests/Feature/Field/ElementRules/MatrixFieldElementRulesTest.php b/tests/Feature/Field/ElementRules/MatrixFieldElementRulesTest.php index c6462462bca..52393517744 100644 --- a/tests/Feature/Field/ElementRules/MatrixFieldElementRulesTest.php +++ b/tests/Feature/Field/ElementRules/MatrixFieldElementRulesTest.php @@ -2,7 +2,7 @@ declare(strict_types=1); -use CraftCms\Cms\Element\Element; +use CraftCms\Cms\Element\Validation\ElementRules; use CraftCms\Cms\Entry\Models\Entry as EntryModel; use CraftCms\Cms\Entry\Models\EntryType as EntryTypeModel; use CraftCms\Cms\Field\Matrix; @@ -23,7 +23,7 @@ function createMatrixRulesEntryType(): EntryTypeModel $result = EntryModel::factory() ->withField('matrixField', Matrix::class, ['entryTypes' => [$entryType->id], 'minEntries' => 1], value: '') - ->withScenario(Element::SCENARIO_LIVE) + ->withScenario(ElementRules::SCENARIO_LIVE) ->createElementWithFields(save: false); $result->element->validate(); @@ -45,7 +45,7 @@ function createMatrixRulesEntryType(): EntryTypeModel $result = EntryModel::factory() ->withField('matrixField', Matrix::class, ['entryTypes' => [$entryType->id], 'viewMode' => Matrix::VIEW_MODE_INDEX], value: $value) - ->withScenario(Element::SCENARIO_LIVE) + ->withScenario(ElementRules::SCENARIO_LIVE) ->createElementWithFields(save: false); $result->element->validate(); diff --git a/tests/Feature/Field/ElementRules/RelationFieldElementRulesTest.php b/tests/Feature/Field/ElementRules/RelationFieldElementRulesTest.php index 658ec9e8962..0083510ae81 100644 --- a/tests/Feature/Field/ElementRules/RelationFieldElementRulesTest.php +++ b/tests/Feature/Field/ElementRules/RelationFieldElementRulesTest.php @@ -2,8 +2,8 @@ declare(strict_types=1); -use CraftCms\Cms\Element\Element; use CraftCms\Cms\Element\ElementCollection; +use CraftCms\Cms\Element\Validation\ElementRules; use CraftCms\Cms\Entry\Elements\Entry as EntryElement; use CraftCms\Cms\Entry\Models\Entry as EntryModel; use CraftCms\Cms\Field\Entries; @@ -11,7 +11,7 @@ test('relation fields enforce min relations', function () { $result = EntryModel::factory() ->withField('relatedEntries', Entries::class, ['allowLimit' => true, 'minRelations' => 1], value: []) - ->withScenario(Element::SCENARIO_LIVE) + ->withScenario(ElementRules::SCENARIO_LIVE) ->createElementWithFields(save: false); $result->element->validate(); @@ -26,7 +26,7 @@ $result = EntryModel::factory() ->withField('relatedEntries', Entries::class, ['validateRelatedElements' => true], value: new ElementCollection([$relatedEntry])) - ->withScenario(Element::SCENARIO_LIVE) + ->withScenario(ElementRules::SCENARIO_LIVE) ->createElementWithFields(save: false); $result->element->validate(); diff --git a/tests/Feature/Http/RespondsWithFlashTest.php b/tests/Feature/Http/RespondsWithFlashTest.php index 246e85dea9a..60eadaefd0e 100644 --- a/tests/Feature/Http/RespondsWithFlashTest.php +++ b/tests/Feature/Http/RespondsWithFlashTest.php @@ -55,6 +55,11 @@ public function errors(): MessageBag { return new MessageBag(['name' => ['Name is required']]); } + + public function setAttributes(array $values): void + { + $this->name = $values['name'] ?? ''; + } }; return $this->asModelFailure($model, 'Model save failed', 'testModel'); diff --git a/tests/Feature/User/Elements/UserValidationTest.php b/tests/Feature/User/Elements/UserValidationTest.php index 0843d4b4a80..6f49df31ea1 100644 --- a/tests/Feature/User/Elements/UserValidationTest.php +++ b/tests/Feature/User/Elements/UserValidationTest.php @@ -9,7 +9,7 @@ use CraftCms\Cms\User\Elements\User; use CraftCms\Cms\User\Models\User as UserModel; use CraftCms\Cms\User\Validation\UserRules; -use CraftCms\Cms\Validation\Attributes\Ruleset; +use CraftCms\RulesetValidation\Attributes\Ruleset; use Illuminate\Support\Facades\Hash; #[Ruleset(UserRules::class)] @@ -440,7 +440,7 @@ public function getFieldLayout(): ?FieldLayout describe('Scenario validation', function () { test('SCENARIO_PASSWORD only validates newPassword', function () { $user = UserModel::factory()->createElement(); - $user->setScenario(User::SCENARIO_PASSWORD); + $user->ruleset->useScenario(UserRules::SCENARIO_PASSWORD); $user->username = str_repeat('a', 256); // Invalid $user->newPassword = 'p'; // too short @@ -452,7 +452,7 @@ public function getFieldLayout(): ?FieldLayout test('SCENARIO_REGISTRATION validates username email and newPassword', function () { $user = UserModel::factory()->createElement(); - $user->setScenario(User::SCENARIO_REGISTRATION); + $user->ruleset->useScenario(UserRules::SCENARIO_REGISTRATION); $user->username = str_repeat('a', 256); // Invalid $user->email = 'invalid-email'; // Invalid $user->newPassword = 'p'; // too short @@ -468,7 +468,7 @@ public function getFieldLayout(): ?FieldLayout test('SCENARIO_ACTIVATION validates username and email', function () { $user = UserModel::factory()->createElement(); - $user->setScenario(User::SCENARIO_ACTIVATION); + $user->ruleset->useScenario(UserRules::SCENARIO_ACTIVATION); $user->username = str_repeat('a', 256); // Invalid $user->email = 'invalid-email'; // Invalid @@ -511,7 +511,7 @@ public function getFieldLayout(): ?FieldLayout $user = new TestUserWithFieldLayout; $user->setMockFieldLayout($fieldLayout); - $user->setScenario(User::SCENARIO_LIVE); + $user->ruleset->useScenario(UserRules::SCENARIO_LIVE); $user->email = 'test@example.com'; $user->firstName = ''; $user->lastName = ''; @@ -539,7 +539,7 @@ public function getFieldLayout(): ?FieldLayout $user = new TestUserWithFieldLayout; $user->setMockFieldLayout($fieldLayout); - $user->setScenario(User::SCENARIO_LIVE); + $user->ruleset->useScenario(UserRules::SCENARIO_LIVE); $user->email = 'test@example.com'; $user->firstName = ''; $user->lastName = ''; @@ -564,7 +564,7 @@ public function getFieldLayout(): ?FieldLayout $user = new TestUserWithFieldLayout; $user->setMockFieldLayout($fieldLayout); - $user->setScenario(User::SCENARIO_LIVE); + $user->ruleset->useScenario(UserRules::SCENARIO_LIVE); $user->email = 'test@example.com'; $user->fullName = ''; @@ -588,7 +588,7 @@ public function getFieldLayout(): ?FieldLayout $user = new TestUserWithFieldLayout; $user->setMockFieldLayout($fieldLayout); - $user->setScenario(User::SCENARIO_LIVE); + $user->ruleset->useScenario(UserRules::SCENARIO_LIVE); $user->email = 'test@example.com'; $user->fullName = ''; @@ -611,7 +611,7 @@ public function getFieldLayout(): ?FieldLayout $user = new TestUserWithFieldLayout; $user->setMockFieldLayout($fieldLayout); - $user->setScenario(User::SCENARIO_DEFAULT); + $user->ruleset->useScenario(UserRules::SCENARIO_DEFAULT); $user->email = 'test@example.com'; $user->firstName = ''; $user->lastName = ''; @@ -633,7 +633,7 @@ public function getFieldLayout(): ?FieldLayout $user = new TestUserWithFieldLayout; $user->setMockFieldLayout($fieldLayout); - $user->setScenario(User::SCENARIO_LIVE); + $user->ruleset->useScenario(UserRules::SCENARIO_LIVE); $user->email = 'test@example.com'; $user->firstName = ''; $user->lastName = ''; diff --git a/tests/TestClasses/TestEntryWithAfterValidate.php b/tests/TestClasses/TestEntryWithAfterValidate.php index 877a2fd5d71..e28683f76f9 100644 --- a/tests/TestClasses/TestEntryWithAfterValidate.php +++ b/tests/TestClasses/TestEntryWithAfterValidate.php @@ -7,7 +7,7 @@ use CraftCms\Cms\Element\Validation\ElementRules; use CraftCms\Cms\Entry\Elements\Entry; use CraftCms\Cms\FieldLayout\FieldLayout; -use CraftCms\Cms\Validation\Attributes\Ruleset; +use CraftCms\RulesetValidation\Attributes\Ruleset; use Illuminate\Validation\Validator as LaravelValidator; use Override; diff --git a/tests/Unit/Element/ElementWrites/PropagateElementTest.php b/tests/Unit/Element/ElementWrites/PropagateElementTest.php index c25d599e7ec..fd244d27084 100644 --- a/tests/Unit/Element/ElementWrites/PropagateElementTest.php +++ b/tests/Unit/Element/ElementWrites/PropagateElementTest.php @@ -11,6 +11,7 @@ use CraftCms\Cms\Element\Models\ElementSiteSettings; use CraftCms\Cms\Element\Operations\ElementUris; use CraftCms\Cms\Element\Operations\ElementWrites; +use CraftCms\Cms\Element\Validation\ElementRules; use CraftCms\Cms\Field\Field; use CraftCms\Cms\FieldLayout\FieldLayout; use CraftCms\Cms\FieldLayout\LayoutElements\CustomField; @@ -110,7 +111,7 @@ ->and($siteElement->getEnabledForSite())->toBeFalse() ->and($siteElement->propagating)->toBeTrue() ->and($siteElement->propagatingFrom)->toBe($element) - ->and($siteElement->getScenario())->toBe(Element::SCENARIO_ESSENTIALS) + ->and($siteElement->ruleset->getScenario())->toBe(ElementRules::SCENARIO_ESSENTIALS) ->and($siteElement->getDirtyAttributes())->toContain('enabled') ->and($element->newSiteIds)->toBe([2]); @@ -232,7 +233,7 @@ expect($siteElement->getFieldValue('plainText'))->toBe('fallback value') ->and($field->propagateCalls)->toHaveCount(1) - ->and($siteElement->getScenario())->toBe(Element::SCENARIO_LIVE); + ->and($siteElement->ruleset->getScenario())->toBe(ElementRules::SCENARIO_LIVE); }); it('uses the live scenario when cross-site validation applies to enabled site elements', function () { @@ -248,7 +249,7 @@ $this->executor->propagate($element, supportedSites(enabledByDefault: true), 2, $siteElement, crossSiteValidate: true); - expect($siteElement->getScenario())->toBe(Element::SCENARIO_LIVE); + expect($siteElement->ruleset->getScenario())->toBe(ElementRules::SCENARIO_LIVE); }); it('continues when uri generation is aborted', function () { diff --git a/tests/Unit/Element/ElementWrites/PropagateElementsTest.php b/tests/Unit/Element/ElementWrites/PropagateElementsTest.php index 0d1a19881f3..177f2a0ffca 100644 --- a/tests/Unit/Element/ElementWrites/PropagateElementsTest.php +++ b/tests/Unit/Element/ElementWrites/PropagateElementsTest.php @@ -16,6 +16,7 @@ use CraftCms\Cms\Element\Operations\ElementWrites; use CraftCms\Cms\Element\Queries\Contracts\ElementQueryInterface; use CraftCms\Cms\Element\Queries\Exceptions\QueryAbortedException; +use CraftCms\Cms\Element\Validation\ElementRules; use CraftCms\Cms\Search\Search; use CraftCms\Cms\Site\Data\Site; use CraftCms\Cms\Site\Sites as SitesService; @@ -216,7 +217,7 @@ function createElement(int $id, int $siteId = 1, ?DateTime $dateUpdated = null): $this->action->propagateElements($query); - expect($element->getScenario())->toBe(Element::SCENARIO_ESSENTIALS) + expect($element->ruleset->getScenario())->toBe(ElementRules::SCENARIO_ESSENTIALS) ->and($element->newSiteIds)->toBe([]) ->and($element->afterPropagateCalled)->toBeTrue(); diff --git a/tests/Unit/Element/ElementWrites/ResaveElementsTest.php b/tests/Unit/Element/ElementWrites/ResaveElementsTest.php index 4fa5cc8c9ec..d27cc3eff74 100644 --- a/tests/Unit/Element/ElementWrites/ResaveElementsTest.php +++ b/tests/Unit/Element/ElementWrites/ResaveElementsTest.php @@ -18,6 +18,7 @@ use CraftCms\Cms\Element\Operations\ElementWrites; use CraftCms\Cms\Element\Queries\Contracts\ElementQueryInterface; use CraftCms\Cms\Element\Queries\Exceptions\QueryAbortedException; +use CraftCms\Cms\Element\Validation\ElementRules; use CraftCms\Cms\Field\Contracts\ElementContainerFieldInterface; use CraftCms\Cms\Search\Search; use CraftCms\Cms\Site\Sites; @@ -59,7 +60,7 @@ expect($this->saveElementAction->calls[0]['updateSearchIndex'])->toBeFalse(); expect($this->saveElementAction->calls[0]['forceTouch'])->toBeTrue(); expect($this->saveElementAction->calls[0]['saveContent'])->toBeTrue(); - expect($this->saveElementAction->calls[0]['scenario'])->toBe(Element::SCENARIO_ESSENTIALS); + expect($this->saveElementAction->calls[0]['scenario'])->toBe(ElementRules::SCENARIO_ESSENTIALS); expect($this->saveElementAction->calls[0]['resaving'])->toBeTrue(); expect($this->saveElementAction->calls[1]['element'])->toBe($secondElement); @@ -234,7 +235,7 @@ public function save( 'updateSearchIndex' => $updateSearchIndex, 'forceTouch' => $forceTouch, 'saveContent' => $saveContent, - 'scenario' => $element->getScenario(), + 'scenario' => $element->ruleset->getScenario(), 'resaving' => $element->resaving, ]; diff --git a/tests/Unit/Validation/RulesetTest.php b/tests/Unit/Validation/RulesetTest.php deleted file mode 100644 index 920649879e5..00000000000 --- a/tests/Unit/Validation/RulesetTest.php +++ /dev/null @@ -1,307 +0,0 @@ -testMessages; - } - - public function prepareForValidation(?array $attributeNames = null): void - { - $this->prepareForValidationCalled = true; - $this->prepareForValidationAttributes = $attributeNames; - } - - protected function defineRules(): array - { - return $this->testRules; - } - }; -} - -function createTestComponent(array $scenarios = []): Validatable -{ - return new class($scenarios) implements Validatable - { - use Validates; - - public function __construct(private array $testScenarios) {} - - public function scenarios(): array - { - return $this->testScenarios; - } - - public function getRules(): array - { - return []; - } - - public function getMessages(): array - { - return []; - } - - public function validate(string|array|null $attributeNames = null, bool $clearErrors = true): bool - { - return true; - } - - public function getFirstErrors(): array - { - return []; - } - - public function errors(): MessageBag - { - return new MessageBag; - } - - public function setAttributes(array $values, bool $safeOnly = true): void {} - - public function getAttributes(): array - { - return []; - } - - public function attributes(): array - { - return []; - } - - public function attributeLabels(): array - { - return []; - } - }; -} - -describe('rules', function () { - test('returns defined rules', function () { - $component = createTestComponent(); - $ruleset = createTestRuleset($component, rules: [ - 'title' => ['required', 'string'], - 'slug' => ['nullable', 'string'], - ]); - - $rules = $ruleset->rules(); - - expect($rules)->toHaveKey('title'); - expect($rules)->toHaveKey('slug'); - expect($rules['title'])->toBe(['required', 'string']); - }); - - test('fires DefineValidationRules event', function () { - Event::fake([DefineValidationRules::class]); - - $component = createTestComponent(); - $ruleset = createTestRuleset($component, rules: [ - 'title' => ['required'], - ]); - - $ruleset->rules(); - - Event::assertDispatched(fn (DefineValidationRules $event) => $event->component === $component - && isset($event->rules['title'])); - }); - - test('event listeners can modify rules', function () { - $component = createTestComponent(); - $ruleset = createTestRuleset($component, rules: [ - 'title' => ['required'], - ]); - - Event::listen(DefineValidationRules::class, function (DefineValidationRules $event) { - $event->addRule('title', 'max:255'); - $event->addRules('email', ['required', 'email']); - }); - - $rules = $ruleset->rules(); - - expect($rules['title'])->toContain('max:255'); - expect($rules)->toHaveKey('email'); - expect($rules['email'])->toBe(['required', 'email']); - }); - - test('filters rules by scenario attributes', function () { - $component = createTestComponent( - scenarios: [ - 'essentials' => ['title', 'slug'], - ], - ); - $ruleset = createTestRuleset( - $component, - rules: [ - 'title' => ['required'], - 'slug' => ['required'], - 'body' => ['nullable'], - ], - ); - $component->setScenario('essentials'); - - $rules = $ruleset->rules(); - - expect($rules)->toHaveKey('title'); - expect($rules)->toHaveKey('slug'); - expect($rules)->not->toHaveKey('body'); - }); - - test('returns all rules when scenario maps to null', function () { - $component = createTestComponent( - scenarios: [ - 'default' => null, - ], - ); - $ruleset = createTestRuleset( - $component, - rules: [ - 'title' => ['required'], - 'slug' => ['required'], - 'body' => ['nullable'], - ], - ); - $component->setScenario('default'); - - $rules = $ruleset->rules(); - - expect($rules)->toHaveKey('title'); - expect($rules)->toHaveKey('slug'); - expect($rules)->toHaveKey('body'); - }); - - test('filters out empty rules', function () { - $component = createTestComponent(); - $ruleset = createTestRuleset($component, rules: [ - 'title' => ['required'], - 'slug' => [], - 'body' => null, - ]); - - $rules = $ruleset->rules(); - - expect($rules)->toHaveKey('title'); - expect($rules)->not->toHaveKey('slug'); - expect($rules)->not->toHaveKey('body'); - }); -}); - -describe('prepareForValidation', function () { - test('is called before validation runs', function () { - $component = createTestComponent(); - $ruleset = createTestRuleset($component, rules: [ - 'title' => ['required'], - ]); - - expect($ruleset->prepareForValidationCalled)->toBeFalse(); - - $ruleset->prepareForValidation(['title']); - - expect($ruleset->prepareForValidationCalled)->toBeTrue(); - expect($ruleset->prepareForValidationAttributes)->toBe(['title']); - }); - - test('receives null when validating all attributes', function () { - $component = createTestComponent(); - $ruleset = createTestRuleset($component); - - $ruleset->prepareForValidation(); - - expect($ruleset->prepareForValidationCalled)->toBeTrue(); - expect($ruleset->prepareForValidationAttributes)->toBeNull(); - }); -}); - -describe('messages', function () { - test('returns custom messages', function () { - $component = createTestComponent(); - $ruleset = createTestRuleset($component, messages: [ - 'title.required' => 'Please enter a title.', - 'email.email' => 'Invalid email format.', - ]); - - $messages = $ruleset->messages(); - - expect($messages)->toBe([ - 'title.required' => 'Please enter a title.', - 'email.email' => 'Invalid email format.', - ]); - }); -}); - -describe('scenarios', function () { - test('scenarios method controls rule filtering', function () { - $component = createTestComponent( - scenarios: [ - 'login' => ['email', 'password'], - 'profile' => ['title', 'email'], - ], - ); - $ruleset = createTestRuleset( - $component, - rules: [ - 'title' => ['required'], - 'email' => ['required', 'email'], - 'password' => ['required', 'min:8'], - ], - ); - - $component->setScenario('login'); - $loginRules = $ruleset->rules(); - - expect($loginRules)->toHaveKeys(['email', 'password']); - expect($loginRules)->not->toHaveKey('title'); - - $component->setScenario('profile'); - $profileRules = $ruleset->rules(); - - expect($profileRules)->toHaveKeys(['title', 'email']); - expect($profileRules)->not->toHaveKey('password'); - }); - - test('undefined scenario returns all rules', function () { - $component = createTestComponent( - scenarios: [ - 'specific' => ['title'], - ], - ); - $ruleset = createTestRuleset( - $component, - rules: [ - 'title' => ['required'], - 'email' => ['required'], - ], - ); - $component->setScenario('undefined-scenario'); - - $rules = $ruleset->rules(); - - expect($rules)->toHaveKeys(['title', 'email']); - }); -}); diff --git a/tests/Unit/Validation/ValidatesWithRulesetTest.php b/tests/Unit/Validation/ValidatesWithRulesetTest.php index 5256689c34c..79ad9f5a69d 100644 --- a/tests/Unit/Validation/ValidatesWithRulesetTest.php +++ b/tests/Unit/Validation/ValidatesWithRulesetTest.php @@ -2,6 +2,7 @@ declare(strict_types=1); +use CraftCms\Cms\Validation\ComponentRules; use CraftCms\Cms\Validation\Concerns\Validates; use CraftCms\Cms\Validation\Contracts\Validatable; use CraftCms\Cms\Validation\Ruleset; @@ -24,32 +25,17 @@ public function __construct( $this->rulesetClass = $rulesetClass ?? TestRuleset::class; } - public function getRules(): array - { - return []; - } - - public function getMessages(): array - { - return []; - } - public function setAttributes(array $values, bool $safeOnly = true): void { $this->testAttributes = array_merge($this->testAttributes, $values); } - public function getAttributes(): array + public function validationData(): array { return $this->testAttributes; } - public function attributes(): array - { - return array_keys($this->testAttributes); - } - - public function rulesClass(): string + public function ruleset(): string { return $this->rulesetClass; } @@ -58,15 +44,10 @@ public function afterValidate(?Validator $validator = null): void { $this->afterValidateCalled = true; } - - public function attributeLabels(): array - { - return []; - } }; } -class TestRuleset extends Ruleset +class TestRuleset extends ComponentRules { public bool $prepareForValidationCalled = false; @@ -81,14 +62,15 @@ public function messages(): array ]; } - public function prepareForValidation(?array $attributeNames = null): void + #[Override] + public function prepareForValidation(): void { $this->prepareForValidationCalled = true; - $this->prepareForValidationAttributes = $attributeNames; + $this->prepareForValidationAttributes = $this->validationAttributes; } #[Override] - protected function defineRules(): array + public function rules(): array { return [ 'title' => ['required', 'string', 'max:255'], @@ -99,68 +81,12 @@ protected function defineRules(): array class EmptyRuleset extends Ruleset { - #[Override] - protected function defineRules(): array + public function rules(): array { return []; } } -describe('getRuleset', function () { - test('resolves ruleset via rulesClass method', function () { - $component = createValidatableComponent(['title' => 'Test']); - - $ruleset = $component->getRuleset(); - - expect($ruleset)->toBeInstanceOf(TestRuleset::class); - }); - - test('caches ruleset instance', function () { - $component = createValidatableComponent(['title' => 'Test']); - - $ruleset1 = $component->getRuleset(); - $ruleset2 = $component->getRuleset(); - - expect($ruleset1)->toBe($ruleset2); - }); - - test('returns false when no ruleset configured', function () { - $component = new class implements Validatable - { - use Validates; - - public function getRules(): array - { - return []; - } - - public function getMessages(): array - { - return []; - } - - public function setAttributes(array $values, bool $safeOnly = true): void {} - - public function getAttributes(): array - { - return []; - } - - public function attributes(): array - { - return []; - } - - public function attributeLabels(): array - { - return []; - } - }; - - expect($component->getRuleset())->toBeFalse(); - }); -}); - describe('validate', function () { test('returns true when validation passes', function () { $component = createValidatableComponent([ @@ -188,7 +114,7 @@ public function attributeLabels(): array $component->validate(); - expect($component->getRuleset()->prepareForValidationCalled)->toBeTrue(); + expect($component->ruleset->prepareForValidationCalled)->toBeTrue(); }); test('passes attribute names to prepareForValidation', function () { @@ -196,7 +122,7 @@ public function attributeLabels(): array $component->validate(['title']); - expect($component->getRuleset()->prepareForValidationAttributes)->toBe(['title']); + expect($component->ruleset->prepareForValidationAttributes)->toBe(['title']); }); test('validates only specified attributes', function () { diff --git a/yii2-adapter/composer.lock b/yii2-adapter/composer.lock index d86c974ff21..0c1423c8d4f 100644 --- a/yii2-adapter/composer.lock +++ b/yii2-adapter/composer.lock @@ -479,7 +479,7 @@ "dist": { "type": "path", "url": "..", - "reference": "e9bf543d812ecf5ce5452a42d8d7248a582851dd" + "reference": "897e980ca4751c82d305e7f66e6e2fec93322d06" }, "require": { "bacon/bacon-qr-code": "^2.0", @@ -487,8 +487,9 @@ "composer/semver": "^3.3.2", "craftcms/laravel-aliases": "^2.0", "craftcms/laravel-dependency-aware-cache": "^1.1", + "craftcms/laravel-ruleset-validation": "^1.0.1", "craftcms/plugin-installer": "~1.6.0", - "craftcms/server-check": "~5.0.1", + "craftcms/server-check": "~5.1.0", "craftcms/yii2-adapter": "self.version", "elvanto/litemoji": "~4.3.0", "enshrined/svg-sanitize": "~0.22.0", @@ -798,6 +799,70 @@ }, "time": "2026-03-17T14:55:31+00:00" }, + { + "name": "craftcms/laravel-ruleset-validation", + "version": "1.0.1", + "source": { + "type": "git", + "url": "https://github.com/craftcms/laravel-ruleset-validation.git", + "reference": "66867ade9c09c6c8165bd62c0ebb7e4843399aab" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/craftcms/laravel-ruleset-validation/zipball/66867ade9c09c6c8165bd62c0ebb7e4843399aab", + "reference": "66867ade9c09c6c8165bd62c0ebb7e4843399aab", + "shasum": "" + }, + "require": { + "illuminate/contracts": "^13.0", + "illuminate/support": "^13.0", + "illuminate/validation": "^13.0", + "php": "^8.4" + }, + "require-dev": { + "larastan/larastan": "^3.0", + "laravel/pint": "^v1.29", + "nunomaduro/collision": "^8.1.1", + "orchestra/testbench": "^11.0", + "pestphp/pest": "^4.0" + }, + "type": "library", + "extra": { + "laravel": { + "providers": [ + "CraftCms\\RulesetValidation\\RulesetValidationServiceProvider" + ] + } + }, + "autoload": { + "psr-4": { + "CraftCms\\RulesetValidation\\": "src/" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Pixel & Tonic", + "homepage": "https://pixelandtonic.com/" + } + ], + "description": "Validate requests and objects with reusable Laravel rulesets.", + "homepage": "https://github.com/craftcms/laravel-ruleset-validation", + "keywords": [ + "craftcms", + "laravel", + "rulesets", + "validation" + ], + "support": { + "issues": "https://github.com/craftcms/laravel-ruleset-validation/issues", + "source": "https://github.com/craftcms/laravel-ruleset-validation/tree/1.0.1" + }, + "time": "2026-04-21T07:56:55+00:00" + }, { "name": "craftcms/plugin-installer", "version": "1.6.0", @@ -853,16 +918,16 @@ }, { "name": "craftcms/server-check", - "version": "5.0.4", + "version": "5.1.0", "source": { "type": "git", "url": "https://github.com/craftcms/server-check.git", - "reference": "3b1f239c1cc781710978b0baa3e3bc99410d1973" + "reference": "7a4f1720c4fe1f0731254a82e63060a217f51cdc" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/craftcms/server-check/zipball/3b1f239c1cc781710978b0baa3e3bc99410d1973", - "reference": "3b1f239c1cc781710978b0baa3e3bc99410d1973", + "url": "https://api.github.com/repos/craftcms/server-check/zipball/7a4f1720c4fe1f0731254a82e63060a217f51cdc", + "reference": "7a4f1720c4fe1f0731254a82e63060a217f51cdc", "shasum": "" }, "type": "library", @@ -891,7 +956,7 @@ "rss": "https://github.com/craftcms/server-check/releases.atom", "source": "https://github.com/craftcms/server-check" }, - "time": "2025-07-23T14:22:43+00:00" + "time": "2026-04-07T16:48:35+00:00" }, { "name": "creocoder/yii2-nested-sets", @@ -2190,16 +2255,16 @@ }, { "name": "inertiajs/inertia-laravel", - "version": "v3.0.1", + "version": "v3.0.6", "source": { "type": "git", "url": "https://github.com/inertiajs/inertia-laravel.git", - "reference": "4675331c428c0f77b2539684835c5e0fd27ee023" + "reference": "c255b1ea050cf563b240542a76f7f756ccdb2d67" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/inertiajs/inertia-laravel/zipball/4675331c428c0f77b2539684835c5e0fd27ee023", - "reference": "4675331c428c0f77b2539684835c5e0fd27ee023", + "url": "https://api.github.com/repos/inertiajs/inertia-laravel/zipball/c255b1ea050cf563b240542a76f7f756ccdb2d67", + "reference": "c255b1ea050cf563b240542a76f7f756ccdb2d67", "shasum": "" }, "require": { @@ -2257,22 +2322,22 @@ ], "support": { "issues": "https://github.com/inertiajs/inertia-laravel/issues", - "source": "https://github.com/inertiajs/inertia-laravel/tree/v3.0.1" + "source": "https://github.com/inertiajs/inertia-laravel/tree/v3.0.6" }, - "time": "2026-03-25T21:07:46+00:00" + "time": "2026-04-10T14:29:45+00:00" }, { "name": "laravel/framework", - "version": "v13.2.0", + "version": "v13.5.0", "source": { "type": "git", "url": "https://github.com/laravel/framework.git", - "reference": "9e48d1fe933e89de628dafa167d2c5778566d4cf" + "reference": "ffa1850049a691b93129808f27ecd10e65c9d1a5" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/laravel/framework/zipball/9e48d1fe933e89de628dafa167d2c5778566d4cf", - "reference": "9e48d1fe933e89de628dafa167d2c5778566d4cf", + "url": "https://api.github.com/repos/laravel/framework/zipball/ffa1850049a691b93129808f27ecd10e65c9d1a5", + "reference": "ffa1850049a691b93129808f27ecd10e65c9d1a5", "shasum": "" }, "require": { @@ -2390,6 +2455,7 @@ "phpstan/phpstan": "^2.0", "phpunit/phpunit": "^11.5.50 || ^12.5.8 || ^13.0.3", "predis/predis": "^2.3 || ^3.0", + "rector/rector": "^2.3", "resend/resend-php": "^1.0", "symfony/cache": "^7.4.0 || ^8.0.0", "symfony/http-client": "^7.4.0 || ^8.0.0", @@ -2425,6 +2491,7 @@ "psr/http-message": "Required to allow Storage::put to accept a StreamInterface (^1.0).", "pusher/pusher-php-server": "Required to use the Pusher broadcast driver (^6.0 || ^7.0).", "resend/resend-php": "Required to enable support for the Resend mail transport (^0.10.0 || ^1.0).", + "spatie/fork": "Required to use the 'fork' concurrency driver (^1.2).", "symfony/cache": "Required to PSR-6 cache bridge (^7.4 || ^8.0).", "symfony/filesystem": "Required to enable support for relative symbolic links (^7.4 || ^8.0).", "symfony/http-client": "Required to enable support for the Symfony API mail transports (^7.4 || ^8.0).", @@ -2480,20 +2547,20 @@ "issues": "https://github.com/laravel/framework/issues", "source": "https://github.com/laravel/framework" }, - "time": "2026-03-24T18:42:09+00:00" + "time": "2026-04-14T13:55:03+00:00" }, { "name": "laravel/prompts", - "version": "v0.3.16", + "version": "v0.3.17", "source": { "type": "git", "url": "https://github.com/laravel/prompts.git", - "reference": "11e7d5f93803a2190b00e145142cb00a33d17ad2" + "reference": "6a82ac19a28b916ae0885828795dbd4c59d9a818" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/laravel/prompts/zipball/11e7d5f93803a2190b00e145142cb00a33d17ad2", - "reference": "11e7d5f93803a2190b00e145142cb00a33d17ad2", + "url": "https://api.github.com/repos/laravel/prompts/zipball/6a82ac19a28b916ae0885828795dbd4c59d9a818", + "reference": "6a82ac19a28b916ae0885828795dbd4c59d9a818", "shasum": "" }, "require": { @@ -2537,22 +2604,22 @@ "description": "Add beautiful and user-friendly forms to your command-line applications.", "support": { "issues": "https://github.com/laravel/prompts/issues", - "source": "https://github.com/laravel/prompts/tree/v0.3.16" + "source": "https://github.com/laravel/prompts/tree/v0.3.17" }, - "time": "2026-03-23T14:35:33+00:00" + "time": "2026-04-20T16:07:33+00:00" }, { "name": "laravel/serializable-closure", - "version": "v2.0.10", + "version": "v2.0.12", "source": { "type": "git", "url": "https://github.com/laravel/serializable-closure.git", - "reference": "870fc81d2f879903dfc5b60bf8a0f94a1609e669" + "reference": "a6abb4e54f6fcd3138120b9ad497f0bd146f9919" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/laravel/serializable-closure/zipball/870fc81d2f879903dfc5b60bf8a0f94a1609e669", - "reference": "870fc81d2f879903dfc5b60bf8a0f94a1609e669", + "url": "https://api.github.com/repos/laravel/serializable-closure/zipball/a6abb4e54f6fcd3138120b9ad497f0bd146f9919", + "reference": "a6abb4e54f6fcd3138120b9ad497f0bd146f9919", "shasum": "" }, "require": { @@ -2600,20 +2667,20 @@ "issues": "https://github.com/laravel/serializable-closure/issues", "source": "https://github.com/laravel/serializable-closure" }, - "time": "2026-02-20T19:59:49+00:00" + "time": "2026-04-14T13:33:34+00:00" }, { "name": "laravel/wayfinder", - "version": "v0.1.15", + "version": "v0.1.16", "source": { "type": "git", "url": "https://github.com/laravel/wayfinder.git", - "reference": "25b8a947af54d35106dc04e933d05a6b7fad1133" + "reference": "6a5c695dedb77a793bba6dc062bdddea94253517" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/laravel/wayfinder/zipball/25b8a947af54d35106dc04e933d05a6b7fad1133", - "reference": "25b8a947af54d35106dc04e933d05a6b7fad1133", + "url": "https://api.github.com/repos/laravel/wayfinder/zipball/6a5c695dedb77a793bba6dc062bdddea94253517", + "reference": "6a5c695dedb77a793bba6dc062bdddea94253517", "shasum": "" }, "require": { @@ -2663,7 +2730,7 @@ "issues": "https://github.com/laravel/wayfinder/issues", "source": "https://github.com/laravel/wayfinder" }, - "time": "2026-03-25T20:46:44+00:00" + "time": "2026-04-07T17:07:48+00:00" }, { "name": "league/commonmark", @@ -3272,16 +3339,16 @@ }, { "name": "maennchen/zipstream-php", - "version": "3.2.1", + "version": "3.2.2", "source": { "type": "git", "url": "https://github.com/maennchen/ZipStream-PHP.git", - "reference": "682f1098a8fddbaf43edac2306a691c7ad508ec5" + "reference": "77bebeb4c6c340bb3c11c843b2cffd8bbfde4d5e" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/maennchen/ZipStream-PHP/zipball/682f1098a8fddbaf43edac2306a691c7ad508ec5", - "reference": "682f1098a8fddbaf43edac2306a691c7ad508ec5", + "url": "https://api.github.com/repos/maennchen/ZipStream-PHP/zipball/77bebeb4c6c340bb3c11c843b2cffd8bbfde4d5e", + "reference": "77bebeb4c6c340bb3c11c843b2cffd8bbfde4d5e", "shasum": "" }, "require": { @@ -3338,7 +3405,7 @@ ], "support": { "issues": "https://github.com/maennchen/ZipStream-PHP/issues", - "source": "https://github.com/maennchen/ZipStream-PHP/tree/3.2.1" + "source": "https://github.com/maennchen/ZipStream-PHP/tree/3.2.2" }, "funding": [ { @@ -3346,7 +3413,7 @@ "type": "github" } ], - "time": "2025-12-10T09:58:31+00:00" + "time": "2026-04-11T18:38:28+00:00" }, { "name": "markbaker/complex", @@ -3763,16 +3830,16 @@ }, { "name": "nesbot/carbon", - "version": "3.11.3", + "version": "3.11.4", "source": { "type": "git", "url": "https://github.com/CarbonPHP/carbon.git", - "reference": "6a7e652845bb018c668220c2a545aded8594fbbf" + "reference": "e890471a3494740f7d9326d72ce6a8c559ffee60" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/CarbonPHP/carbon/zipball/6a7e652845bb018c668220c2a545aded8594fbbf", - "reference": "6a7e652845bb018c668220c2a545aded8594fbbf", + "url": "https://api.github.com/repos/CarbonPHP/carbon/zipball/e890471a3494740f7d9326d72ce6a8c559ffee60", + "reference": "e890471a3494740f7d9326d72ce6a8c559ffee60", "shasum": "" }, "require": { @@ -3864,7 +3931,7 @@ "type": "tidelift" } ], - "time": "2026-03-11T17:23:39+00:00" + "time": "2026-04-07T09:57:54+00:00" }, { "name": "nette/schema", @@ -4357,16 +4424,16 @@ }, { "name": "phpoffice/phpspreadsheet", - "version": "5.5.0", + "version": "5.7.0", "source": { "type": "git", "url": "https://github.com/PHPOffice/PhpSpreadsheet.git", - "reference": "eecd31b885a1c8192f12738130f85bbc6e8906ba" + "reference": "9f55d3b9b7bcb1084fda8340e4b7ce4ed10cd0c8" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/PHPOffice/PhpSpreadsheet/zipball/eecd31b885a1c8192f12738130f85bbc6e8906ba", - "reference": "eecd31b885a1c8192f12738130f85bbc6e8906ba", + "url": "https://api.github.com/repos/PHPOffice/PhpSpreadsheet/zipball/9f55d3b9b7bcb1084fda8340e4b7ce4ed10cd0c8", + "reference": "9f55d3b9b7bcb1084fda8340e4b7ce4ed10cd0c8", "shasum": "" }, "require": { @@ -4460,9 +4527,9 @@ ], "support": { "issues": "https://github.com/PHPOffice/PhpSpreadsheet/issues", - "source": "https://github.com/PHPOffice/PhpSpreadsheet/tree/5.5.0" + "source": "https://github.com/PHPOffice/PhpSpreadsheet/tree/5.7.0" }, - "time": "2026-03-01T00:58:56+00:00" + "time": "2026-04-20T02:42:17+00:00" }, { "name": "phpoption/phpoption", @@ -5563,20 +5630,20 @@ }, { "name": "spomky-labs/cbor-php", - "version": "3.2.2", + "version": "3.2.3", "source": { "type": "git", "url": "https://github.com/Spomky-Labs/cbor-php.git", - "reference": "2a5fb86aacfe1004611370ead6caa2bfc88435d0" + "reference": "dd6eb84e6d92f7b8bd0da56b4b4dd7235aed0c32" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/Spomky-Labs/cbor-php/zipball/2a5fb86aacfe1004611370ead6caa2bfc88435d0", - "reference": "2a5fb86aacfe1004611370ead6caa2bfc88435d0", + "url": "https://api.github.com/repos/Spomky-Labs/cbor-php/zipball/dd6eb84e6d92f7b8bd0da56b4b4dd7235aed0c32", + "reference": "dd6eb84e6d92f7b8bd0da56b4b4dd7235aed0c32", "shasum": "" }, "require": { - "brick/math": "^0.9|^0.10|^0.11|^0.12|^0.13|^0.14", + "brick/math": "^0.9|^0.10|^0.11|^0.12|^0.13|^0.14|^0.15|^0.16|^0.17", "ext-mbstring": "*", "php": ">=8.0" }, @@ -5618,7 +5685,7 @@ ], "support": { "issues": "https://github.com/Spomky-Labs/cbor-php/issues", - "source": "https://github.com/Spomky-Labs/cbor-php/tree/3.2.2" + "source": "https://github.com/Spomky-Labs/cbor-php/tree/3.2.3" }, "funding": [ { @@ -5630,7 +5697,7 @@ "type": "patreon" } ], - "time": "2025-11-13T13:00:34+00:00" + "time": "2026-04-01T12:15:20+00:00" }, { "name": "spomky-labs/pki-framework", @@ -5744,16 +5811,16 @@ }, { "name": "symfony/clock", - "version": "v7.4.0", + "version": "v7.4.8", "source": { "type": "git", "url": "https://github.com/symfony/clock.git", - "reference": "9169f24776edde469914c1e7a1442a50f7a4e110" + "reference": "674fa3b98e21531dd040e613479f5f6fa8f32111" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/symfony/clock/zipball/9169f24776edde469914c1e7a1442a50f7a4e110", - "reference": "9169f24776edde469914c1e7a1442a50f7a4e110", + "url": "https://api.github.com/repos/symfony/clock/zipball/674fa3b98e21531dd040e613479f5f6fa8f32111", + "reference": "674fa3b98e21531dd040e613479f5f6fa8f32111", "shasum": "" }, "require": { @@ -5798,7 +5865,7 @@ "time" ], "support": { - "source": "https://github.com/symfony/clock/tree/v7.4.0" + "source": "https://github.com/symfony/clock/tree/v7.4.8" }, "funding": [ { @@ -5818,20 +5885,20 @@ "type": "tidelift" } ], - "time": "2025-11-12T15:39:26+00:00" + "time": "2026-03-24T13:12:05+00:00" }, { "name": "symfony/console", - "version": "v8.0.7", + "version": "v8.0.8", "source": { "type": "git", "url": "https://github.com/symfony/console.git", - "reference": "15ed9008a4ebe2d6a78e4937f74e0c13ef2e618a" + "reference": "5b66d385dc58f69652e56f78a4184615e3f2b7f7" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/symfony/console/zipball/15ed9008a4ebe2d6a78e4937f74e0c13ef2e618a", - "reference": "15ed9008a4ebe2d6a78e4937f74e0c13ef2e618a", + "url": "https://api.github.com/repos/symfony/console/zipball/5b66d385dc58f69652e56f78a4184615e3f2b7f7", + "reference": "5b66d385dc58f69652e56f78a4184615e3f2b7f7", "shasum": "" }, "require": { @@ -5888,7 +5955,7 @@ "terminal" ], "support": { - "source": "https://github.com/symfony/console/tree/v8.0.7" + "source": "https://github.com/symfony/console/tree/v8.0.8" }, "funding": [ { @@ -5908,20 +5975,20 @@ "type": "tidelift" } ], - "time": "2026-03-06T14:06:22+00:00" + "time": "2026-03-30T15:14:47+00:00" }, { "name": "symfony/css-selector", - "version": "v7.4.6", + "version": "v7.4.8", "source": { "type": "git", "url": "https://github.com/symfony/css-selector.git", - "reference": "2e7c52c647b406e2107dd867db424a4dbac91864" + "reference": "b055f228a4178a1d6774909903905e3475f3eac8" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/symfony/css-selector/zipball/2e7c52c647b406e2107dd867db424a4dbac91864", - "reference": "2e7c52c647b406e2107dd867db424a4dbac91864", + "url": "https://api.github.com/repos/symfony/css-selector/zipball/b055f228a4178a1d6774909903905e3475f3eac8", + "reference": "b055f228a4178a1d6774909903905e3475f3eac8", "shasum": "" }, "require": { @@ -5957,7 +6024,7 @@ "description": "Converts CSS selectors to XPath expressions", "homepage": "https://symfony.com", "support": { - "source": "https://github.com/symfony/css-selector/tree/v7.4.6" + "source": "https://github.com/symfony/css-selector/tree/v7.4.8" }, "funding": [ { @@ -5977,7 +6044,7 @@ "type": "tidelift" } ], - "time": "2026-02-17T07:53:42+00:00" + "time": "2026-03-24T13:12:05+00:00" }, { "name": "symfony/deprecation-contracts", @@ -6048,16 +6115,16 @@ }, { "name": "symfony/dom-crawler", - "version": "v7.4.6", + "version": "v7.4.8", "source": { "type": "git", "url": "https://github.com/symfony/dom-crawler.git", - "reference": "487ba8fa43da9a8e6503fe939b45ecd96875410e" + "reference": "2918e7c2ba964defca1f5b69c6f74886529e2dc8" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/symfony/dom-crawler/zipball/487ba8fa43da9a8e6503fe939b45ecd96875410e", - "reference": "487ba8fa43da9a8e6503fe939b45ecd96875410e", + "url": "https://api.github.com/repos/symfony/dom-crawler/zipball/2918e7c2ba964defca1f5b69c6f74886529e2dc8", + "reference": "2918e7c2ba964defca1f5b69c6f74886529e2dc8", "shasum": "" }, "require": { @@ -6096,7 +6163,7 @@ "description": "Eases DOM navigation for HTML and XML documents", "homepage": "https://symfony.com", "support": { - "source": "https://github.com/symfony/dom-crawler/tree/v7.4.6" + "source": "https://github.com/symfony/dom-crawler/tree/v7.4.8" }, "funding": [ { @@ -6116,20 +6183,20 @@ "type": "tidelift" } ], - "time": "2026-02-17T07:53:42+00:00" + "time": "2026-03-24T13:12:05+00:00" }, { "name": "symfony/error-handler", - "version": "v8.0.4", + "version": "v8.0.8", "source": { "type": "git", "url": "https://github.com/symfony/error-handler.git", - "reference": "7620b97ec0ab1d2d6c7fb737aa55da411bea776a" + "reference": "c1119fe8dcfc3825ec74ec061b96ef0c8f281517" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/symfony/error-handler/zipball/7620b97ec0ab1d2d6c7fb737aa55da411bea776a", - "reference": "7620b97ec0ab1d2d6c7fb737aa55da411bea776a", + "url": "https://api.github.com/repos/symfony/error-handler/zipball/c1119fe8dcfc3825ec74ec061b96ef0c8f281517", + "reference": "c1119fe8dcfc3825ec74ec061b96ef0c8f281517", "shasum": "" }, "require": { @@ -6177,7 +6244,7 @@ "description": "Provides tools to manage errors and ease debugging PHP code", "homepage": "https://symfony.com", "support": { - "source": "https://github.com/symfony/error-handler/tree/v8.0.4" + "source": "https://github.com/symfony/error-handler/tree/v8.0.8" }, "funding": [ { @@ -6197,20 +6264,20 @@ "type": "tidelift" } ], - "time": "2026-01-23T11:07:10+00:00" + "time": "2026-03-30T15:14:47+00:00" }, { "name": "symfony/event-dispatcher", - "version": "v8.0.4", + "version": "v8.0.8", "source": { "type": "git", "url": "https://github.com/symfony/event-dispatcher.git", - "reference": "99301401da182b6cfaa4700dbe9987bb75474b47" + "reference": "f662acc6ab22a3d6d716dcb44c381c6002940df6" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/symfony/event-dispatcher/zipball/99301401da182b6cfaa4700dbe9987bb75474b47", - "reference": "99301401da182b6cfaa4700dbe9987bb75474b47", + "url": "https://api.github.com/repos/symfony/event-dispatcher/zipball/f662acc6ab22a3d6d716dcb44c381c6002940df6", + "reference": "f662acc6ab22a3d6d716dcb44c381c6002940df6", "shasum": "" }, "require": { @@ -6262,7 +6329,7 @@ "description": "Provides tools that allow your application components to communicate with each other by dispatching events and listening to them", "homepage": "https://symfony.com", "support": { - "source": "https://github.com/symfony/event-dispatcher/tree/v8.0.4" + "source": "https://github.com/symfony/event-dispatcher/tree/v8.0.8" }, "funding": [ { @@ -6282,7 +6349,7 @@ "type": "tidelift" } ], - "time": "2026-01-05T11:45:55+00:00" + "time": "2026-03-30T15:14:47+00:00" }, { "name": "symfony/event-dispatcher-contracts", @@ -6362,16 +6429,16 @@ }, { "name": "symfony/filesystem", - "version": "v8.0.6", + "version": "v8.0.8", "source": { "type": "git", "url": "https://github.com/symfony/filesystem.git", - "reference": "7bf9162d7a0dff98d079b72948508fa48018a770" + "reference": "66b769ae743ce2d13e435528fbef4af03d623e5a" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/symfony/filesystem/zipball/7bf9162d7a0dff98d079b72948508fa48018a770", - "reference": "7bf9162d7a0dff98d079b72948508fa48018a770", + "url": "https://api.github.com/repos/symfony/filesystem/zipball/66b769ae743ce2d13e435528fbef4af03d623e5a", + "reference": "66b769ae743ce2d13e435528fbef4af03d623e5a", "shasum": "" }, "require": { @@ -6408,7 +6475,7 @@ "description": "Provides basic utilities for the filesystem", "homepage": "https://symfony.com", "support": { - "source": "https://github.com/symfony/filesystem/tree/v8.0.6" + "source": "https://github.com/symfony/filesystem/tree/v8.0.8" }, "funding": [ { @@ -6428,20 +6495,20 @@ "type": "tidelift" } ], - "time": "2026-02-25T16:59:43+00:00" + "time": "2026-03-30T15:14:47+00:00" }, { "name": "symfony/finder", - "version": "v8.0.6", + "version": "v8.0.8", "source": { "type": "git", "url": "https://github.com/symfony/finder.git", - "reference": "441404f09a54de6d1bd6ad219e088cdf4c91f97c" + "reference": "8da41214757b87d97f181e3d14a4179286151007" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/symfony/finder/zipball/441404f09a54de6d1bd6ad219e088cdf4c91f97c", - "reference": "441404f09a54de6d1bd6ad219e088cdf4c91f97c", + "url": "https://api.github.com/repos/symfony/finder/zipball/8da41214757b87d97f181e3d14a4179286151007", + "reference": "8da41214757b87d97f181e3d14a4179286151007", "shasum": "" }, "require": { @@ -6476,7 +6543,7 @@ "description": "Finds files and directories via an intuitive fluent interface", "homepage": "https://symfony.com", "support": { - "source": "https://github.com/symfony/finder/tree/v8.0.6" + "source": "https://github.com/symfony/finder/tree/v8.0.8" }, "funding": [ { @@ -6496,20 +6563,20 @@ "type": "tidelift" } ], - "time": "2026-01-29T09:41:02+00:00" + "time": "2026-03-30T15:14:47+00:00" }, { "name": "symfony/html-sanitizer", - "version": "v8.0.7", + "version": "v8.0.8", "source": { "type": "git", "url": "https://github.com/symfony/html-sanitizer.git", - "reference": "555b37caeee3d07af33471e02377d5ff561f8ac2" + "reference": "b0e4a2d9a82ab6bdcc742a63398781f6dae64fe5" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/symfony/html-sanitizer/zipball/555b37caeee3d07af33471e02377d5ff561f8ac2", - "reference": "555b37caeee3d07af33471e02377d5ff561f8ac2", + "url": "https://api.github.com/repos/symfony/html-sanitizer/zipball/b0e4a2d9a82ab6bdcc742a63398781f6dae64fe5", + "reference": "b0e4a2d9a82ab6bdcc742a63398781f6dae64fe5", "shasum": "" }, "require": { @@ -6548,7 +6615,7 @@ "sanitizer" ], "support": { - "source": "https://github.com/symfony/html-sanitizer/tree/v8.0.7" + "source": "https://github.com/symfony/html-sanitizer/tree/v8.0.8" }, "funding": [ { @@ -6568,20 +6635,20 @@ "type": "tidelift" } ], - "time": "2026-03-06T13:17:40+00:00" + "time": "2026-03-30T15:14:47+00:00" }, { "name": "symfony/http-foundation", - "version": "v8.0.7", + "version": "v8.0.8", "source": { "type": "git", "url": "https://github.com/symfony/http-foundation.git", - "reference": "c5ecf7b07408dbc4a87482634307654190954ae8" + "reference": "02656f7ebeae5c155d659e946f6b3a33df24051b" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/symfony/http-foundation/zipball/c5ecf7b07408dbc4a87482634307654190954ae8", - "reference": "c5ecf7b07408dbc4a87482634307654190954ae8", + "url": "https://api.github.com/repos/symfony/http-foundation/zipball/02656f7ebeae5c155d659e946f6b3a33df24051b", + "reference": "02656f7ebeae5c155d659e946f6b3a33df24051b", "shasum": "" }, "require": { @@ -6628,7 +6695,7 @@ "description": "Defines an object-oriented layer for the HTTP specification", "homepage": "https://symfony.com", "support": { - "source": "https://github.com/symfony/http-foundation/tree/v8.0.7" + "source": "https://github.com/symfony/http-foundation/tree/v8.0.8" }, "funding": [ { @@ -6648,20 +6715,20 @@ "type": "tidelift" } ], - "time": "2026-03-06T13:17:40+00:00" + "time": "2026-03-30T15:14:47+00:00" }, { "name": "symfony/http-kernel", - "version": "v8.0.7", + "version": "v8.0.8", "source": { "type": "git", "url": "https://github.com/symfony/http-kernel.git", - "reference": "c04721f45723d8ce049fa3eee378b5a505272ac7" + "reference": "1770f6818d83b2fddc12185025b93f39a90cb628" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/symfony/http-kernel/zipball/c04721f45723d8ce049fa3eee378b5a505272ac7", - "reference": "c04721f45723d8ce049fa3eee378b5a505272ac7", + "url": "https://api.github.com/repos/symfony/http-kernel/zipball/1770f6818d83b2fddc12185025b93f39a90cb628", + "reference": "1770f6818d83b2fddc12185025b93f39a90cb628", "shasum": "" }, "require": { @@ -6732,7 +6799,7 @@ "description": "Provides a structured process for converting a Request into a Response", "homepage": "https://symfony.com", "support": { - "source": "https://github.com/symfony/http-kernel/tree/v8.0.7" + "source": "https://github.com/symfony/http-kernel/tree/v8.0.8" }, "funding": [ { @@ -6752,20 +6819,20 @@ "type": "tidelift" } ], - "time": "2026-03-06T16:58:46+00:00" + "time": "2026-03-31T21:14:05+00:00" }, { "name": "symfony/mailer", - "version": "v7.4.6", + "version": "v7.4.8", "source": { "type": "git", "url": "https://github.com/symfony/mailer.git", - "reference": "b02726f39a20bc65e30364f5c750c4ddbf1f58e9" + "reference": "f6ea532250b476bfc1b56699b388a1bdbf168f62" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/symfony/mailer/zipball/b02726f39a20bc65e30364f5c750c4ddbf1f58e9", - "reference": "b02726f39a20bc65e30364f5c750c4ddbf1f58e9", + "url": "https://api.github.com/repos/symfony/mailer/zipball/f6ea532250b476bfc1b56699b388a1bdbf168f62", + "reference": "f6ea532250b476bfc1b56699b388a1bdbf168f62", "shasum": "" }, "require": { @@ -6816,7 +6883,7 @@ "description": "Helps sending emails", "homepage": "https://symfony.com", "support": { - "source": "https://github.com/symfony/mailer/tree/v7.4.6" + "source": "https://github.com/symfony/mailer/tree/v7.4.8" }, "funding": [ { @@ -6836,20 +6903,20 @@ "type": "tidelift" } ], - "time": "2026-02-25T16:50:00+00:00" + "time": "2026-03-24T13:12:05+00:00" }, { "name": "symfony/mime", - "version": "v7.4.7", + "version": "v7.4.8", "source": { "type": "git", "url": "https://github.com/symfony/mime.git", - "reference": "da5ab4fde3f6c88ab06e96185b9922f48b677cd1" + "reference": "6df02f99998081032da3407a8d6c4e1dcb5d4379" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/symfony/mime/zipball/da5ab4fde3f6c88ab06e96185b9922f48b677cd1", - "reference": "da5ab4fde3f6c88ab06e96185b9922f48b677cd1", + "url": "https://api.github.com/repos/symfony/mime/zipball/6df02f99998081032da3407a8d6c4e1dcb5d4379", + "reference": "6df02f99998081032da3407a8d6c4e1dcb5d4379", "shasum": "" }, "require": { @@ -6905,7 +6972,7 @@ "mime-type" ], "support": { - "source": "https://github.com/symfony/mime/tree/v7.4.7" + "source": "https://github.com/symfony/mime/tree/v7.4.8" }, "funding": [ { @@ -6925,20 +6992,20 @@ "type": "tidelift" } ], - "time": "2026-03-05T15:24:09+00:00" + "time": "2026-03-30T14:11:46+00:00" }, { "name": "symfony/polyfill-ctype", - "version": "v1.33.0", + "version": "v1.36.0", "source": { "type": "git", "url": "https://github.com/symfony/polyfill-ctype.git", - "reference": "a3cc8b044a6ea513310cbd48ef7333b384945638" + "reference": "141046a8f9477948ff284fa65be2095baafb94f2" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/symfony/polyfill-ctype/zipball/a3cc8b044a6ea513310cbd48ef7333b384945638", - "reference": "a3cc8b044a6ea513310cbd48ef7333b384945638", + "url": "https://api.github.com/repos/symfony/polyfill-ctype/zipball/141046a8f9477948ff284fa65be2095baafb94f2", + "reference": "141046a8f9477948ff284fa65be2095baafb94f2", "shasum": "" }, "require": { @@ -6988,7 +7055,7 @@ "portable" ], "support": { - "source": "https://github.com/symfony/polyfill-ctype/tree/v1.33.0" + "source": "https://github.com/symfony/polyfill-ctype/tree/v1.36.0" }, "funding": [ { @@ -7008,20 +7075,20 @@ "type": "tidelift" } ], - "time": "2024-09-09T11:45:10+00:00" + "time": "2026-04-10T16:19:22+00:00" }, { "name": "symfony/polyfill-intl-grapheme", - "version": "v1.33.0", + "version": "v1.36.0", "source": { "type": "git", "url": "https://github.com/symfony/polyfill-intl-grapheme.git", - "reference": "380872130d3a5dd3ace2f4010d95125fde5d5c70" + "reference": "ad1b7b9092976d6c948b8a187cec9faaea9ec1df" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/symfony/polyfill-intl-grapheme/zipball/380872130d3a5dd3ace2f4010d95125fde5d5c70", - "reference": "380872130d3a5dd3ace2f4010d95125fde5d5c70", + "url": "https://api.github.com/repos/symfony/polyfill-intl-grapheme/zipball/ad1b7b9092976d6c948b8a187cec9faaea9ec1df", + "reference": "ad1b7b9092976d6c948b8a187cec9faaea9ec1df", "shasum": "" }, "require": { @@ -7070,7 +7137,7 @@ "shim" ], "support": { - "source": "https://github.com/symfony/polyfill-intl-grapheme/tree/v1.33.0" + "source": "https://github.com/symfony/polyfill-intl-grapheme/tree/v1.36.0" }, "funding": [ { @@ -7090,11 +7157,11 @@ "type": "tidelift" } ], - "time": "2025-06-27T09:58:17+00:00" + "time": "2026-04-10T16:19:22+00:00" }, { "name": "symfony/polyfill-intl-idn", - "version": "v1.33.0", + "version": "v1.36.0", "source": { "type": "git", "url": "https://github.com/symfony/polyfill-intl-idn.git", @@ -7157,7 +7224,7 @@ "shim" ], "support": { - "source": "https://github.com/symfony/polyfill-intl-idn/tree/v1.33.0" + "source": "https://github.com/symfony/polyfill-intl-idn/tree/v1.36.0" }, "funding": [ { @@ -7181,7 +7248,7 @@ }, { "name": "symfony/polyfill-intl-normalizer", - "version": "v1.33.0", + "version": "v1.36.0", "source": { "type": "git", "url": "https://github.com/symfony/polyfill-intl-normalizer.git", @@ -7242,7 +7309,7 @@ "shim" ], "support": { - "source": "https://github.com/symfony/polyfill-intl-normalizer/tree/v1.33.0" + "source": "https://github.com/symfony/polyfill-intl-normalizer/tree/v1.36.0" }, "funding": [ { @@ -7266,16 +7333,16 @@ }, { "name": "symfony/polyfill-mbstring", - "version": "v1.33.0", + "version": "v1.36.0", "source": { "type": "git", "url": "https://github.com/symfony/polyfill-mbstring.git", - "reference": "6d857f4d76bd4b343eac26d6b539585d2bc56493" + "reference": "6a21eb99c6973357967f6ce3708cd55a6bec6315" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/symfony/polyfill-mbstring/zipball/6d857f4d76bd4b343eac26d6b539585d2bc56493", - "reference": "6d857f4d76bd4b343eac26d6b539585d2bc56493", + "url": "https://api.github.com/repos/symfony/polyfill-mbstring/zipball/6a21eb99c6973357967f6ce3708cd55a6bec6315", + "reference": "6a21eb99c6973357967f6ce3708cd55a6bec6315", "shasum": "" }, "require": { @@ -7327,7 +7394,7 @@ "shim" ], "support": { - "source": "https://github.com/symfony/polyfill-mbstring/tree/v1.33.0" + "source": "https://github.com/symfony/polyfill-mbstring/tree/v1.36.0" }, "funding": [ { @@ -7347,20 +7414,20 @@ "type": "tidelift" } ], - "time": "2024-12-23T08:48:59+00:00" + "time": "2026-04-10T17:25:58+00:00" }, { "name": "symfony/polyfill-php80", - "version": "v1.33.0", + "version": "v1.36.0", "source": { "type": "git", "url": "https://github.com/symfony/polyfill-php80.git", - "reference": "0cc9dd0f17f61d8131e7df6b84bd344899fe2608" + "reference": "dfb55726c3a76ea3b6459fcfda1ec2d80a682411" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/symfony/polyfill-php80/zipball/0cc9dd0f17f61d8131e7df6b84bd344899fe2608", - "reference": "0cc9dd0f17f61d8131e7df6b84bd344899fe2608", + "url": "https://api.github.com/repos/symfony/polyfill-php80/zipball/dfb55726c3a76ea3b6459fcfda1ec2d80a682411", + "reference": "dfb55726c3a76ea3b6459fcfda1ec2d80a682411", "shasum": "" }, "require": { @@ -7411,7 +7478,7 @@ "shim" ], "support": { - "source": "https://github.com/symfony/polyfill-php80/tree/v1.33.0" + "source": "https://github.com/symfony/polyfill-php80/tree/v1.36.0" }, "funding": [ { @@ -7431,20 +7498,20 @@ "type": "tidelift" } ], - "time": "2025-01-02T08:10:11+00:00" + "time": "2026-04-10T16:19:22+00:00" }, { "name": "symfony/polyfill-php83", - "version": "v1.33.0", + "version": "v1.36.0", "source": { "type": "git", "url": "https://github.com/symfony/polyfill-php83.git", - "reference": "17f6f9a6b1735c0f163024d959f700cfbc5155e5" + "reference": "3600c2cb22399e25bb226e4a135ce91eeb2a6149" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/symfony/polyfill-php83/zipball/17f6f9a6b1735c0f163024d959f700cfbc5155e5", - "reference": "17f6f9a6b1735c0f163024d959f700cfbc5155e5", + "url": "https://api.github.com/repos/symfony/polyfill-php83/zipball/3600c2cb22399e25bb226e4a135ce91eeb2a6149", + "reference": "3600c2cb22399e25bb226e4a135ce91eeb2a6149", "shasum": "" }, "require": { @@ -7491,7 +7558,7 @@ "shim" ], "support": { - "source": "https://github.com/symfony/polyfill-php83/tree/v1.33.0" + "source": "https://github.com/symfony/polyfill-php83/tree/v1.36.0" }, "funding": [ { @@ -7511,20 +7578,20 @@ "type": "tidelift" } ], - "time": "2025-07-08T02:45:35+00:00" + "time": "2026-04-10T17:25:58+00:00" }, { "name": "symfony/polyfill-php84", - "version": "v1.33.0", + "version": "v1.36.0", "source": { "type": "git", "url": "https://github.com/symfony/polyfill-php84.git", - "reference": "d8ced4d875142b6a7426000426b8abc631d6b191" + "reference": "88486db2c389b290bf87ff1de7ebc1e13e42bb06" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/symfony/polyfill-php84/zipball/d8ced4d875142b6a7426000426b8abc631d6b191", - "reference": "d8ced4d875142b6a7426000426b8abc631d6b191", + "url": "https://api.github.com/repos/symfony/polyfill-php84/zipball/88486db2c389b290bf87ff1de7ebc1e13e42bb06", + "reference": "88486db2c389b290bf87ff1de7ebc1e13e42bb06", "shasum": "" }, "require": { @@ -7571,7 +7638,7 @@ "shim" ], "support": { - "source": "https://github.com/symfony/polyfill-php84/tree/v1.33.0" + "source": "https://github.com/symfony/polyfill-php84/tree/v1.36.0" }, "funding": [ { @@ -7591,20 +7658,20 @@ "type": "tidelift" } ], - "time": "2025-06-24T13:30:11+00:00" + "time": "2026-04-10T18:47:49+00:00" }, { "name": "symfony/polyfill-php85", - "version": "v1.33.0", + "version": "v1.36.0", "source": { "type": "git", "url": "https://github.com/symfony/polyfill-php85.git", - "reference": "d4e5fcd4ab3d998ab16c0db48e6cbb9a01993f91" + "reference": "2c408a6bb0313e6001a83628dc5506100474254e" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/symfony/polyfill-php85/zipball/d4e5fcd4ab3d998ab16c0db48e6cbb9a01993f91", - "reference": "d4e5fcd4ab3d998ab16c0db48e6cbb9a01993f91", + "url": "https://api.github.com/repos/symfony/polyfill-php85/zipball/2c408a6bb0313e6001a83628dc5506100474254e", + "reference": "2c408a6bb0313e6001a83628dc5506100474254e", "shasum": "" }, "require": { @@ -7651,7 +7718,7 @@ "shim" ], "support": { - "source": "https://github.com/symfony/polyfill-php85/tree/v1.33.0" + "source": "https://github.com/symfony/polyfill-php85/tree/v1.36.0" }, "funding": [ { @@ -7671,20 +7738,20 @@ "type": "tidelift" } ], - "time": "2025-06-23T16:12:55+00:00" + "time": "2026-04-10T16:50:15+00:00" }, { "name": "symfony/polyfill-uuid", - "version": "v1.33.0", + "version": "v1.36.0", "source": { "type": "git", "url": "https://github.com/symfony/polyfill-uuid.git", - "reference": "21533be36c24be3f4b1669c4725c7d1d2bab4ae2" + "reference": "26dfec253c4cf3e51b541b52ddf7e42cb0908e94" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/symfony/polyfill-uuid/zipball/21533be36c24be3f4b1669c4725c7d1d2bab4ae2", - "reference": "21533be36c24be3f4b1669c4725c7d1d2bab4ae2", + "url": "https://api.github.com/repos/symfony/polyfill-uuid/zipball/26dfec253c4cf3e51b541b52ddf7e42cb0908e94", + "reference": "26dfec253c4cf3e51b541b52ddf7e42cb0908e94", "shasum": "" }, "require": { @@ -7734,7 +7801,7 @@ "uuid" ], "support": { - "source": "https://github.com/symfony/polyfill-uuid/tree/v1.33.0" + "source": "https://github.com/symfony/polyfill-uuid/tree/v1.36.0" }, "funding": [ { @@ -7754,20 +7821,20 @@ "type": "tidelift" } ], - "time": "2024-09-09T11:45:10+00:00" + "time": "2026-04-10T16:19:22+00:00" }, { "name": "symfony/process", - "version": "v7.4.5", + "version": "v7.4.8", "source": { "type": "git", "url": "https://github.com/symfony/process.git", - "reference": "608476f4604102976d687c483ac63a79ba18cc97" + "reference": "60f19cd3badc8de688421e21e4305eba50f8089a" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/symfony/process/zipball/608476f4604102976d687c483ac63a79ba18cc97", - "reference": "608476f4604102976d687c483ac63a79ba18cc97", + "url": "https://api.github.com/repos/symfony/process/zipball/60f19cd3badc8de688421e21e4305eba50f8089a", + "reference": "60f19cd3badc8de688421e21e4305eba50f8089a", "shasum": "" }, "require": { @@ -7799,7 +7866,7 @@ "description": "Executes commands in sub-processes", "homepage": "https://symfony.com", "support": { - "source": "https://github.com/symfony/process/tree/v7.4.5" + "source": "https://github.com/symfony/process/tree/v7.4.8" }, "funding": [ { @@ -7819,20 +7886,20 @@ "type": "tidelift" } ], - "time": "2026-01-26T15:07:59+00:00" + "time": "2026-03-24T13:12:05+00:00" }, { "name": "symfony/property-access", - "version": "v7.4.4", + "version": "v7.4.8", "source": { "type": "git", "url": "https://github.com/symfony/property-access.git", - "reference": "fa49bf1ca8fce1ba0e2dba4e4658554cfb9364b1" + "reference": "b7dad9dae8b8a47ef7ecc76c8569e7d8c7d90cfc" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/symfony/property-access/zipball/fa49bf1ca8fce1ba0e2dba4e4658554cfb9364b1", - "reference": "fa49bf1ca8fce1ba0e2dba4e4658554cfb9364b1", + "url": "https://api.github.com/repos/symfony/property-access/zipball/b7dad9dae8b8a47ef7ecc76c8569e7d8c7d90cfc", + "reference": "b7dad9dae8b8a47ef7ecc76c8569e7d8c7d90cfc", "shasum": "" }, "require": { @@ -7880,7 +7947,7 @@ "reflection" ], "support": { - "source": "https://github.com/symfony/property-access/tree/v7.4.4" + "source": "https://github.com/symfony/property-access/tree/v7.4.8" }, "funding": [ { @@ -7900,20 +7967,20 @@ "type": "tidelift" } ], - "time": "2026-01-05T08:47:25+00:00" + "time": "2026-03-24T13:12:05+00:00" }, { "name": "symfony/property-info", - "version": "v7.4.7", + "version": "v7.4.8", "source": { "type": "git", "url": "https://github.com/symfony/property-info.git", - "reference": "02501d75fd834345da3ecdd8e3200ced39e370f8" + "reference": "ac5e82528b986c4f7cfccbf7764b5d2e824d6175" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/symfony/property-info/zipball/02501d75fd834345da3ecdd8e3200ced39e370f8", - "reference": "02501d75fd834345da3ecdd8e3200ced39e370f8", + "url": "https://api.github.com/repos/symfony/property-info/zipball/ac5e82528b986c4f7cfccbf7764b5d2e824d6175", + "reference": "ac5e82528b986c4f7cfccbf7764b5d2e824d6175", "shasum": "" }, "require": { @@ -7970,7 +8037,7 @@ "validator" ], "support": { - "source": "https://github.com/symfony/property-info/tree/v7.4.7" + "source": "https://github.com/symfony/property-info/tree/v7.4.8" }, "funding": [ { @@ -7990,20 +8057,20 @@ "type": "tidelift" } ], - "time": "2026-03-04T15:53:26+00:00" + "time": "2026-03-24T13:12:05+00:00" }, { "name": "symfony/routing", - "version": "v8.0.6", + "version": "v8.0.8", "source": { "type": "git", "url": "https://github.com/symfony/routing.git", - "reference": "053c40fd46e1d19c5c5a94cada93ce6c3facdd55" + "reference": "0de330ec2ea922a7b08ec45615bd51179de7fda4" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/symfony/routing/zipball/053c40fd46e1d19c5c5a94cada93ce6c3facdd55", - "reference": "053c40fd46e1d19c5c5a94cada93ce6c3facdd55", + "url": "https://api.github.com/repos/symfony/routing/zipball/0de330ec2ea922a7b08ec45615bd51179de7fda4", + "reference": "0de330ec2ea922a7b08ec45615bd51179de7fda4", "shasum": "" }, "require": { @@ -8050,7 +8117,7 @@ "url" ], "support": { - "source": "https://github.com/symfony/routing/tree/v8.0.6" + "source": "https://github.com/symfony/routing/tree/v8.0.8" }, "funding": [ { @@ -8070,20 +8137,20 @@ "type": "tidelift" } ], - "time": "2026-02-25T16:59:43+00:00" + "time": "2026-03-30T15:14:47+00:00" }, { "name": "symfony/serializer", - "version": "v7.4.7", + "version": "v7.4.8", "source": { "type": "git", "url": "https://github.com/symfony/serializer.git", - "reference": "bd395bbc6fabd136a48e1a6f91b09f88b5050b0b" + "reference": "006fd51717addf2df2bd1a64dafef6b7fab6b455" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/symfony/serializer/zipball/bd395bbc6fabd136a48e1a6f91b09f88b5050b0b", - "reference": "bd395bbc6fabd136a48e1a6f91b09f88b5050b0b", + "url": "https://api.github.com/repos/symfony/serializer/zipball/006fd51717addf2df2bd1a64dafef6b7fab6b455", + "reference": "006fd51717addf2df2bd1a64dafef6b7fab6b455", "shasum": "" }, "require": { @@ -8154,7 +8221,7 @@ "description": "Handles serializing and deserializing data structures, including object graphs, into array structures or other formats like XML and JSON.", "homepage": "https://symfony.com", "support": { - "source": "https://github.com/symfony/serializer/tree/v7.4.7" + "source": "https://github.com/symfony/serializer/tree/v7.4.8" }, "funding": [ { @@ -8174,7 +8241,7 @@ "type": "tidelift" } ], - "time": "2026-03-06T13:15:18+00:00" + "time": "2026-03-30T21:34:42+00:00" }, { "name": "symfony/service-contracts", @@ -8265,16 +8332,16 @@ }, { "name": "symfony/string", - "version": "v8.0.6", + "version": "v8.0.8", "source": { "type": "git", "url": "https://github.com/symfony/string.git", - "reference": "6c9e1108041b5dce21a9a4984b531c4923aa9ec4" + "reference": "ae9488f874d7603f9d2dfbf120203882b645d963" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/symfony/string/zipball/6c9e1108041b5dce21a9a4984b531c4923aa9ec4", - "reference": "6c9e1108041b5dce21a9a4984b531c4923aa9ec4", + "url": "https://api.github.com/repos/symfony/string/zipball/ae9488f874d7603f9d2dfbf120203882b645d963", + "reference": "ae9488f874d7603f9d2dfbf120203882b645d963", "shasum": "" }, "require": { @@ -8331,7 +8398,7 @@ "utf8" ], "support": { - "source": "https://github.com/symfony/string/tree/v8.0.6" + "source": "https://github.com/symfony/string/tree/v8.0.8" }, "funding": [ { @@ -8351,20 +8418,20 @@ "type": "tidelift" } ], - "time": "2026-02-09T10:14:57+00:00" + "time": "2026-03-30T15:14:47+00:00" }, { "name": "symfony/translation", - "version": "v8.0.6", + "version": "v8.0.8", "source": { "type": "git", "url": "https://github.com/symfony/translation.git", - "reference": "13ff19bcf2bea492d3c2fbeaa194dd6f4599ce1b" + "reference": "27c03ae3940de24ba2f71cfdbac824f2aa1fdf2f" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/symfony/translation/zipball/13ff19bcf2bea492d3c2fbeaa194dd6f4599ce1b", - "reference": "13ff19bcf2bea492d3c2fbeaa194dd6f4599ce1b", + "url": "https://api.github.com/repos/symfony/translation/zipball/27c03ae3940de24ba2f71cfdbac824f2aa1fdf2f", + "reference": "27c03ae3940de24ba2f71cfdbac824f2aa1fdf2f", "shasum": "" }, "require": { @@ -8424,7 +8491,7 @@ "description": "Provides tools to internationalize your application", "homepage": "https://symfony.com", "support": { - "source": "https://github.com/symfony/translation/tree/v8.0.6" + "source": "https://github.com/symfony/translation/tree/v8.0.8" }, "funding": [ { @@ -8444,7 +8511,7 @@ "type": "tidelift" } ], - "time": "2026-02-17T13:07:04+00:00" + "time": "2026-03-30T15:14:47+00:00" }, { "name": "symfony/translation-contracts", @@ -8530,16 +8597,16 @@ }, { "name": "symfony/type-info", - "version": "v8.0.7", + "version": "v8.0.8", "source": { "type": "git", "url": "https://github.com/symfony/type-info.git", - "reference": "3c7de103dd6cb68be24e155838a64ef4a70ae195" + "reference": "622d81551770029d44d16be68969712eb47892f1" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/symfony/type-info/zipball/3c7de103dd6cb68be24e155838a64ef4a70ae195", - "reference": "3c7de103dd6cb68be24e155838a64ef4a70ae195", + "url": "https://api.github.com/repos/symfony/type-info/zipball/622d81551770029d44d16be68969712eb47892f1", + "reference": "622d81551770029d44d16be68969712eb47892f1", "shasum": "" }, "require": { @@ -8588,7 +8655,7 @@ "type" ], "support": { - "source": "https://github.com/symfony/type-info/tree/v8.0.7" + "source": "https://github.com/symfony/type-info/tree/v8.0.8" }, "funding": [ { @@ -8608,20 +8675,20 @@ "type": "tidelift" } ], - "time": "2026-03-04T13:55:34+00:00" + "time": "2026-03-30T15:14:47+00:00" }, { "name": "symfony/uid", - "version": "v7.4.4", + "version": "v7.4.8", "source": { "type": "git", "url": "https://github.com/symfony/uid.git", - "reference": "7719ce8aba76be93dfe249192f1fbfa52c588e36" + "reference": "6883ebdf7bf6a12b37519dbc0df62b0222401b56" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/symfony/uid/zipball/7719ce8aba76be93dfe249192f1fbfa52c588e36", - "reference": "7719ce8aba76be93dfe249192f1fbfa52c588e36", + "url": "https://api.github.com/repos/symfony/uid/zipball/6883ebdf7bf6a12b37519dbc0df62b0222401b56", + "reference": "6883ebdf7bf6a12b37519dbc0df62b0222401b56", "shasum": "" }, "require": { @@ -8666,7 +8733,7 @@ "uuid" ], "support": { - "source": "https://github.com/symfony/uid/tree/v7.4.4" + "source": "https://github.com/symfony/uid/tree/v7.4.8" }, "funding": [ { @@ -8686,20 +8753,20 @@ "type": "tidelift" } ], - "time": "2026-01-03T23:30:35+00:00" + "time": "2026-03-24T13:12:05+00:00" }, { "name": "symfony/var-dumper", - "version": "v8.0.6", + "version": "v8.0.8", "source": { "type": "git", "url": "https://github.com/symfony/var-dumper.git", - "reference": "2e14f7e0bf5ff02c6e63bd31cb8e4855a13d6209" + "reference": "cfb7badd53bf4177f6e9416cfbbccc13c0e773a1" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/symfony/var-dumper/zipball/2e14f7e0bf5ff02c6e63bd31cb8e4855a13d6209", - "reference": "2e14f7e0bf5ff02c6e63bd31cb8e4855a13d6209", + "url": "https://api.github.com/repos/symfony/var-dumper/zipball/cfb7badd53bf4177f6e9416cfbbccc13c0e773a1", + "reference": "cfb7badd53bf4177f6e9416cfbbccc13c0e773a1", "shasum": "" }, "require": { @@ -8753,7 +8820,7 @@ "dump" ], "support": { - "source": "https://github.com/symfony/var-dumper/tree/v8.0.6" + "source": "https://github.com/symfony/var-dumper/tree/v8.0.8" }, "funding": [ { @@ -8773,20 +8840,20 @@ "type": "tidelift" } ], - "time": "2026-02-15T10:53:29+00:00" + "time": "2026-03-31T07:15:36+00:00" }, { "name": "symfony/yaml", - "version": "v8.0.6", + "version": "v8.0.8", "source": { "type": "git", "url": "https://github.com/symfony/yaml.git", - "reference": "5f006c50a981e1630bbb70ad409c5d85f9a716e0" + "reference": "54174ab48c0c0f9e21512b304be17f8150ccf8f1" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/symfony/yaml/zipball/5f006c50a981e1630bbb70ad409c5d85f9a716e0", - "reference": "5f006c50a981e1630bbb70ad409c5d85f9a716e0", + "url": "https://api.github.com/repos/symfony/yaml/zipball/54174ab48c0c0f9e21512b304be17f8150ccf8f1", + "reference": "54174ab48c0c0f9e21512b304be17f8150ccf8f1", "shasum": "" }, "require": { @@ -8828,7 +8895,7 @@ "description": "Loads and dumps YAML files", "homepage": "https://symfony.com", "support": { - "source": "https://github.com/symfony/yaml/tree/v8.0.6" + "source": "https://github.com/symfony/yaml/tree/v8.0.8" }, "funding": [ { @@ -8848,7 +8915,7 @@ "type": "tidelift" } ], - "time": "2026-02-09T10:14:57+00:00" + "time": "2026-03-30T15:14:47+00:00" }, { "name": "thamtech/yii2-ratelimiter-advanced", @@ -9298,20 +9365,20 @@ }, { "name": "web-auth/cose-lib", - "version": "4.5.0", + "version": "4.5.1", "source": { "type": "git", "url": "https://github.com/web-auth/cose-lib.git", - "reference": "5adac6fe126994a3ee17ed9950efb4947ab132a9" + "reference": "3185af4df10dc537b65c140c315b88d15ae15b80" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/web-auth/cose-lib/zipball/5adac6fe126994a3ee17ed9950efb4947ab132a9", - "reference": "5adac6fe126994a3ee17ed9950efb4947ab132a9", + "url": "https://api.github.com/repos/web-auth/cose-lib/zipball/3185af4df10dc537b65c140c315b88d15ae15b80", + "reference": "3185af4df10dc537b65c140c315b88d15ae15b80", "shasum": "" }, "require": { - "brick/math": "^0.9|^0.10|^0.11|^0.12|^0.13|^0.14", + "brick/math": "^0.9|^0.10|^0.11|^0.12|^0.13|^0.14|^0.15|^0.16|^0.17", "ext-json": "*", "ext-openssl": "*", "php": ">=8.1", @@ -9353,7 +9420,7 @@ ], "support": { "issues": "https://github.com/web-auth/cose-lib/issues", - "source": "https://github.com/web-auth/cose-lib/tree/4.5.0" + "source": "https://github.com/web-auth/cose-lib/tree/4.5.1" }, "funding": [ { @@ -9365,7 +9432,7 @@ "type": "patreon" } ], - "time": "2026-01-03T14:43:18+00:00" + "time": "2026-04-01T12:47:39+00:00" }, { "name": "web-auth/webauthn-lib", @@ -9455,16 +9522,16 @@ }, { "name": "webmozart/assert", - "version": "2.1.6", + "version": "2.3.0", "source": { "type": "git", "url": "https://github.com/webmozarts/assert.git", - "reference": "ff31ad6efc62e66e518fbab1cde3453d389bcdc8" + "reference": "eb0d790f735ba6cff25c683a85a1da0eadeff9e4" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/webmozarts/assert/zipball/ff31ad6efc62e66e518fbab1cde3453d389bcdc8", - "reference": "ff31ad6efc62e66e518fbab1cde3453d389bcdc8", + "url": "https://api.github.com/repos/webmozarts/assert/zipball/eb0d790f735ba6cff25c683a85a1da0eadeff9e4", + "reference": "eb0d790f735ba6cff25c683a85a1da0eadeff9e4", "shasum": "" }, "require": { @@ -9511,9 +9578,9 @@ ], "support": { "issues": "https://github.com/webmozarts/assert/issues", - "source": "https://github.com/webmozarts/assert/tree/2.1.6" + "source": "https://github.com/webmozarts/assert/tree/2.3.0" }, - "time": "2026-02-27T10:28:38+00:00" + "time": "2026-04-11T10:33:05+00:00" }, { "name": "webonyx/graphql-php", diff --git a/yii2-adapter/legacy/base/Element.php b/yii2-adapter/legacy/base/Element.php index 1d73e187474..4bbb2c6ac0f 100644 --- a/yii2-adapter/legacy/base/Element.php +++ b/yii2-adapter/legacy/base/Element.php @@ -84,6 +84,7 @@ use CraftCms\Cms\Element\Events\Render; use CraftCms\Cms\Element\Events\SetEagerLoadedElements; use CraftCms\Cms\Element\Events\SetRoute; +use CraftCms\Cms\Element\Validation\ElementRules; use Illuminate\Support\Facades\Event; use Override; @@ -97,6 +98,12 @@ abstract class Element extends \CraftCms\Cms\Element\Element { use ElementEventConstants; + public const string SCENARIO_DEFAULT = ElementRules::SCENARIO_DEFAULT; + + public const string SCENARIO_ESSENTIALS = ElementRules::SCENARIO_ESSENTIALS; + + public const string SCENARIO_LIVE = ElementRules::SCENARIO_LIVE; + public function init(): void { parent::init(); diff --git a/yii2-adapter/legacy/base/Field.php b/yii2-adapter/legacy/base/Field.php index e6835674b21..983f77a68be 100644 --- a/yii2-adapter/legacy/base/Field.php +++ b/yii2-adapter/legacy/base/Field.php @@ -10,6 +10,7 @@ use Closure; use Craft; use CraftCms\Cms\Element\Element; +use CraftCms\Cms\Element\Validation\ElementRules; use CraftCms\Cms\Field\Enums\TranslationMethod; use CraftCms\Cms\Support\Arr; use Illuminate\Contracts\Database\Query\Builder; @@ -78,14 +79,14 @@ public function getElementRules(ElementInterface $element): array { return [ function(string $attribute, mixed $value, Closure $fail) use ($element) { - $scenario = $element->getScenario(); + $scenario = $element->ruleset->getScenario(); $isEmpty = fn() => $this->isValueEmpty($element->getFieldValue($this->handle), $element); foreach ($this->getElementValidationRules() as $rule) { $validator = $this->_normalizeFieldValidator($attribute, $rule, $element, $isEmpty); if ( - in_array($element->getScenario(), $validator->on) || + in_array($element->ruleset->getScenario(), $validator->on) || (empty($validator->on) && !in_array($scenario, $validator->except)) ) { $validator->validateAttributes($element); @@ -113,7 +114,7 @@ private function _normalizeFieldValidator( if (is_string($rule)) { // "Validator" syntax - $rule = [$attribute, $rule, 'on' => [Element::SCENARIO_DEFAULT, Element::SCENARIO_LIVE]]; + $rule = [$attribute, $rule, 'on' => [ElementRules::SCENARIO_DEFAULT, ElementRules::SCENARIO_LIVE]]; } if (!is_array($rule) || !isset($rule[0])) { @@ -151,7 +152,7 @@ private function _normalizeFieldValidator( // Set 'on' to the main scenarios by default if (!array_key_exists('on', $rule)) { - $rule['on'] = [Element::SCENARIO_DEFAULT, Element::SCENARIO_LIVE]; + $rule['on'] = [ElementRules::SCENARIO_DEFAULT, ElementRules::SCENARIO_LIVE]; } return Validator::createValidator($rule[1], $element, (array) $rule[0], array_slice($rule, 2)); diff --git a/yii2-adapter/legacy/base/Model.php b/yii2-adapter/legacy/base/Model.php index e3939b90fe1..a06938ff565 100644 --- a/yii2-adapter/legacy/base/Model.php +++ b/yii2-adapter/legacy/base/Model.php @@ -14,9 +14,14 @@ use craft\helpers\App; use craft\helpers\Component; use craft\helpers\DateTimeHelper; +use CraftCms\Cms\Support\Arr; use CraftCms\Cms\Support\Str; use CraftCms\Cms\Support\Typecast; +use CraftCms\Cms\Support\Utils; +use CraftCms\Cms\Validation\ComponentRules; use CraftCms\Cms\Validation\Contracts\Validatable; +use CraftCms\RulesetValidation\Attributes\Ruleset; +use CraftCms\RulesetValidation\Concerns\HasRuleset; use Illuminate\Contracts\Support\MessageBag; use yii\validators\Validator; @@ -27,9 +32,11 @@ * @author Pixel & Tonic, Inc. * @since 3.0.0 */ +#[Ruleset(ComponentRules::class)] abstract class Model extends \yii\base\Model implements ModelInterface, Validatable { use ClonefixTrait; + use HasRuleset; /** * @event \yii\base\Event The event that is triggered after the model's init cycle @@ -255,7 +262,17 @@ public function setAttributes($values, $safeOnly = true): void public function getAttributes($names = null, $except = []): array { - return parent::getAttributes($names, $except); + $attributes = $this->validationData($names, $except); + + if ($names !== null) { + $attributes = Arr::only($attributes, $names); + } + + if ($except !== []) { + $attributes = Arr::except($attributes, $except); + } + + return $attributes; } public function attributes(): array @@ -364,9 +381,8 @@ public function getErrorSummary($showAllErrors): array return parent::getErrorSummary($showAllErrors); } - public function beforeValidate(): bool + public function prepareForValidation(): void { - return true; } public function afterValidate(?\Illuminate\Validation\Validator $validator = null): void @@ -432,6 +448,11 @@ public function getValidationData(): array return []; } + public function validationData($names = null, $except = []): array + { + return Arr::except(Utils::getPublicProperties($this), ['ruleset']); + } + public function attributeLabels(): array { return parent::attributeLabels(); @@ -439,6 +460,6 @@ public function attributeLabels(): array public function inScenarios(string ...$scenarios): bool { - return in_array($this->getScenario(), $scenarios, true); + return in_array($this->ruleset->getScenario(), $scenarios, true); } } diff --git a/yii2-adapter/legacy/console/controllers/ResaveController.php b/yii2-adapter/legacy/console/controllers/ResaveController.php index 7a6448a57a5..27e24a4aa4d 100644 --- a/yii2-adapter/legacy/console/controllers/ResaveController.php +++ b/yii2-adapter/legacy/console/controllers/ResaveController.php @@ -29,6 +29,7 @@ use CraftCms\Cms\Element\Exceptions\InvalidElementException; use CraftCms\Cms\Element\Jobs\ResaveElements; use CraftCms\Cms\Element\Queries\Contracts\ElementQueryInterface; +use CraftCms\Cms\Element\Validation\ElementRules; use CraftCms\Cms\Entry\Elements\Entry; use CraftCms\Cms\Field\Fields; use CraftCms\Cms\FieldLayout\FieldLayout; @@ -613,7 +614,7 @@ private function _resaveElements(ElementQueryInterface $query): int $set = false; } } elseif ($this->ifInvalid) { - $element->setScenario(Element::SCENARIO_LIVE); + $element->ruleset->useScenario(ElementRules::SCENARIO_LIVE); if ($element->validate($this->set) && $element->validate("field:$this->set")) { $set = false; } diff --git a/yii2-adapter/legacy/controllers/AssetsController.php b/yii2-adapter/legacy/controllers/AssetsController.php index 7eb3d6fba6d..719f10f4921 100644 --- a/yii2-adapter/legacy/controllers/AssetsController.php +++ b/yii2-adapter/legacy/controllers/AssetsController.php @@ -12,7 +12,7 @@ use craft\web\Controller; use craft\web\UploadedFile; use CraftCms\Cms\Asset\Elements\Asset; -use CraftCms\Cms\Element\Element; +use CraftCms\Cms\Element\Validation\ElementRules; use CraftCms\Cms\Support\Facades\Deprecator; use CraftCms\Cms\Support\Facades\Elements; use CraftCms\Cms\Support\Facades\Sites; @@ -83,7 +83,7 @@ public function actionSaveAsset(): ?Response $asset->setFieldValuesFromRequest($fieldsLocation); // Save the asset - $asset->setScenario(Element::SCENARIO_LIVE); + $asset->ruleset->useScenario(ElementRules::SCENARIO_LIVE); if (!Elements::saveElement($asset)) { return $this->asModelFailure( diff --git a/yii2-adapter/legacy/controllers/CategoriesController.php b/yii2-adapter/legacy/controllers/CategoriesController.php index 8cbbfe48f13..beabda42ef2 100644 --- a/yii2-adapter/legacy/controllers/CategoriesController.php +++ b/yii2-adapter/legacy/controllers/CategoriesController.php @@ -15,9 +15,9 @@ use CraftCms\Cms\Cms; use CraftCms\Cms\Cp\RequestedSite; use CraftCms\Cms\Element\Drafts; -use CraftCms\Cms\Element\Element; use CraftCms\Cms\Element\ElementHelper; use CraftCms\Cms\Element\Exceptions\InvalidElementException; +use CraftCms\Cms\Element\Validation\ElementRules; use CraftCms\Cms\Field\Fields; use CraftCms\Cms\Support\Facades\Elements; use CraftCms\Cms\Support\Facades\Sites; @@ -292,7 +292,7 @@ public function actionCreate(string $groupHandle): ?Response } // Save it - $category->setScenario(Element::SCENARIO_ESSENTIALS); + $category->ruleset->useScenario(ElementRules::SCENARIO_ESSENTIALS); if (!app(Drafts::class)->saveElementAsDraft($category, Craft::$app->getUser()->getId(), null, null, false)) { return $this->asModelFailure($category, mb_ucfirst(t('Couldn’t create {type}.', [ 'type' => Category::lowerDisplayName(), @@ -378,7 +378,7 @@ public function actionSaveCategory(): ?Response // Save the category if ($category->enabled && $category->getEnabledForSite()) { - $category->setScenario(Element::SCENARIO_LIVE); + $category->ruleset->useScenario(ElementRules::SCENARIO_LIVE); } if (!Elements::saveElement($category)) { diff --git a/yii2-adapter/legacy/controllers/ElementIndexesController.php b/yii2-adapter/legacy/controllers/ElementIndexesController.php index 133c8c23763..bcc33f3f92a 100644 --- a/yii2-adapter/legacy/controllers/ElementIndexesController.php +++ b/yii2-adapter/legacy/controllers/ElementIndexesController.php @@ -21,6 +21,7 @@ use CraftCms\Cms\Element\Queries\Contracts\ElementQueryInterface; use CraftCms\Cms\Element\Queries\ElementQuery; use CraftCms\Cms\Element\Queries\ExcludeDescendantIdsExpression; +use CraftCms\Cms\Element\Validation\ElementRules; use CraftCms\Cms\FieldLayout\FieldLayout; use CraftCms\Cms\Support\Arr; use CraftCms\Cms\Support\Facades\Conditions; @@ -379,18 +380,18 @@ public function actionSaveElements(): Response foreach ($elements as $element) { $attributes = Arr::except($data["element-$element->id"], 'fields'); if (!empty($attributes)) { - $scenario = $element->getScenario(); - $element->setScenario(Element::SCENARIO_LIVE); + $scenario = $element->ruleset->getScenario(); + $element->ruleset->useScenario(ElementRules::SCENARIO_LIVE); $element->setAttributesFromRequest($attributes); - $element->setScenario($scenario); + $element->ruleset->useScenario($scenario); } $element->setFieldValuesFromRequest("$namespace.element-$element->id.fields"); if ($element->getIsUnpublishedDraft()) { - $element->setScenario(Element::SCENARIO_ESSENTIALS); + $element->ruleset->useScenario(ElementRules::SCENARIO_ESSENTIALS); } elseif ($element->enabled && $element->getEnabledForSite()) { - $element->setScenario(Element::SCENARIO_LIVE); + $element->ruleset->useScenario(ElementRules::SCENARIO_LIVE); } $names = array_merge( diff --git a/yii2-adapter/legacy/controllers/ElementsController.php b/yii2-adapter/legacy/controllers/ElementsController.php index 7c639a5ed1b..da9b411fbe6 100644 --- a/yii2-adapter/legacy/controllers/ElementsController.php +++ b/yii2-adapter/legacy/controllers/ElementsController.php @@ -34,6 +34,7 @@ use CraftCms\Cms\Element\Queries\Contracts\ElementQueryInterface; use CraftCms\Cms\Element\Queries\Contracts\NestedElementQueryInterface; use CraftCms\Cms\Element\Revisions; +use CraftCms\Cms\Element\Validation\ElementRules; use CraftCms\Cms\FieldLayout\FieldLayoutForm; use CraftCms\Cms\FieldLayout\LayoutElements\BaseField; use CraftCms\Cms\FieldLayout\LayoutElements\CustomField; @@ -247,7 +248,7 @@ public function actionCreate(): Response $user = static::currentUser(); // Save it - $element->setScenario(Element::SCENARIO_ESSENTIALS); + $element->ruleset->useScenario(ElementRules::SCENARIO_ESSENTIALS); if (!app(Drafts::class)->saveElementAsDraft($element, $user->id, null, null, false)) { return $this->_asFailure($element, mb_ucfirst(t('Couldn’t create {type}.', [ 'type' => $element::lowerDisplayName(), @@ -319,7 +320,7 @@ public function actionEdit(?ElementInterface $element, ?int $elementId = null): // Prevalidate? if ($this->_prevalidate && $element->enabled && $element->getEnabledForSite()) { - $element->setScenario(Element::SCENARIO_LIVE); + $element->ruleset->useScenario(ElementRules::SCENARIO_LIVE); $element->validate(); } } else { @@ -1439,7 +1440,7 @@ public function actionSave(): ?Response Gate::authorize('save', $element); if ($element->enabled && $element->getEnabledForSite()) { - $element->setScenario(Element::SCENARIO_LIVE); + $element->ruleset->useScenario(ElementRules::SCENARIO_LIVE); } $isNotNew = $element->id; @@ -1593,7 +1594,7 @@ public function actionSaveNestedElementForDerivative(): ?Response Gate::authorize('save', $element); if ($element->enabled && $element->getEnabledForSite()) { - $element->setScenario(Element::SCENARIO_LIVE); + $element->ruleset->useScenario(ElementRules::SCENARIO_LIVE); } try { @@ -1882,7 +1883,7 @@ public function actionValidate(): ?Response throw new BadRequestHttpException('No element was identified by the request.'); } - $element->setScenario(Element::SCENARIO_LIVE); + $element->ruleset->useScenario(ElementRules::SCENARIO_LIVE); if (!$element->validate()) { return $this->_asFailure($element, t('{type} validation failed.', [ @@ -1984,7 +1985,7 @@ public function actionSaveDraft(): ?Response $element->isProvisionalDraft = false; } - $element->setScenario(Element::SCENARIO_ESSENTIALS); + $element->ruleset->useScenario(ElementRules::SCENARIO_ESSENTIALS); // If the field layout ID changed, save all content $saveContent = $element->getFieldLayout()?->id !== $oldFieldLayoutId; @@ -2145,7 +2146,7 @@ public function actionApplyDraft(): ?Response // Validate and save the draft if ($element->enabled && $element->getEnabledForSite()) { - $element->setScenario(Element::SCENARIO_LIVE); + $element->ruleset->useScenario(ElementRules::SCENARIO_LIVE); } // if we're about to apply an unpublished draft, set propagateRequired to true @@ -2169,7 +2170,7 @@ public function actionApplyDraft(): ?Response // save the draft anyway, so we don’t lose the latest changes // (see https://github.com/craftcms/cms/issues/18657) $errors = $element->getErrors(); - $element->setScenario(Element::SCENARIO_ESSENTIALS); + $element->ruleset->useScenario(ElementRules::SCENARIO_ESSENTIALS); Elements::saveElement($element, saveContent: $saveContent); $element->clearErrors(); $element->addErrors($errors); @@ -2361,7 +2362,7 @@ public function actionUpdateFieldLayout(): ?Response // Prevalidate? if ($this->_prevalidate && $element->enabled && $element->getEnabledForSite()) { - $element->setScenario(Element::SCENARIO_LIVE); + $element->ruleset->useScenario(ElementRules::SCENARIO_LIVE); $element->validate(); } @@ -2837,15 +2838,15 @@ private function _applyParamsToElement(ElementInterface $element): void $element->updateSearchIndexImmediately = $this->_updateSearchIndexImmediately; } - $scenario = $element->getScenario(); - $element->setScenario(Element::SCENARIO_LIVE); + $scenario = $element->ruleset->getScenario(); + $element->ruleset->useScenario(ElementRules::SCENARIO_LIVE); $element->setAttributesFromRequest($this->_attributes + array_filter(['fieldId' => $this->_fieldId])); if ($this->_slug !== null) { $element->slug = $this->_slug; } - $element->setScenario($scenario); + $element->ruleset->useScenario($scenario); // Now that the element is fully configured, make sure the user can actually view it if (!Gate::check('view', $element)) { @@ -2912,7 +2913,7 @@ private function _asSuccess( $newElement->slug = ElementHelper::tempSlug(); } - $newElement->setScenario(Element::SCENARIO_ESSENTIALS); + $newElement->ruleset->useScenario(ElementRules::SCENARIO_ESSENTIALS); if (!app(\CraftCms\Cms\Element\Drafts::class)->saveElementAsDraft($newElement, $user->id, null, null, false)) { throw new ServerErrorHttpException(sprintf('Unable to create a new element: %s', implode(', ', $element->getErrorSummary(true)))); diff --git a/yii2-adapter/legacy/controllers/GlobalsController.php b/yii2-adapter/legacy/controllers/GlobalsController.php index bf9272f7436..32689b5b671 100644 --- a/yii2-adapter/legacy/controllers/GlobalsController.php +++ b/yii2-adapter/legacy/controllers/GlobalsController.php @@ -11,7 +11,7 @@ use craft\elements\GlobalSet; use craft\web\Controller; use CraftCms\Cms\Cp\RequestedSite; -use CraftCms\Cms\Element\Element; +use CraftCms\Cms\Element\Validation\ElementRules; use CraftCms\Cms\Field\Fields; use CraftCms\Cms\Support\Facades\Elements; use CraftCms\Cms\Support\Facades\Sites; @@ -224,7 +224,7 @@ public function actionSaveContent(): ?Response $fieldsLocation = $this->request->getParam('fieldsLocation', 'fields'); $globalSet->setFieldValuesFromRequest($fieldsLocation); - $globalSet->setScenario(Element::SCENARIO_LIVE); + $globalSet->ruleset->useScenario(ElementRules::SCENARIO_LIVE); if (!Elements::saveElement($globalSet)) { $this->setFailFlash(mb_ucfirst(t('Couldn’t save {type}.', [ diff --git a/yii2-adapter/legacy/elements/Asset.php b/yii2-adapter/legacy/elements/Asset.php index ec56af15f60..b87a72e71a6 100644 --- a/yii2-adapter/legacy/elements/Asset.php +++ b/yii2-adapter/legacy/elements/Asset.php @@ -19,6 +19,8 @@ use CraftCms\Cms\Asset\Events\BeforeGenerateTransform; use CraftCms\Cms\Asset\Events\BeforeHandleFile; use CraftCms\Cms\Asset\Events\DefineAssetUrl; +use CraftCms\Cms\Asset\Validation\AssetRules; +use CraftCms\Cms\Element\Validation\ElementRules; use Illuminate\Support\Facades\Event; /** @@ -29,6 +31,22 @@ class Asset extends \CraftCms\Cms\Asset\Elements\Asset { use ElementEventConstants; + public const string SCENARIO_DEFAULT = ElementRules::SCENARIO_DEFAULT; + + public const string SCENARIO_ESSENTIALS = ElementRules::SCENARIO_ESSENTIALS; + + public const string SCENARIO_LIVE = ElementRules::SCENARIO_LIVE; + + public const string SCENARIO_MOVE = AssetRules::SCENARIO_MOVE; + + public const string SCENARIO_FILEOPS = AssetRules::SCENARIO_FILEOPS; + + public const string SCENARIO_INDEX = AssetRules::SCENARIO_INDEX; + + public const string SCENARIO_CREATE = AssetRules::SCENARIO_CREATE; + + public const string SCENARIO_REPLACE = AssetRules::SCENARIO_REPLACE; + // Events // ------------------------------------------------------------------------- diff --git a/yii2-adapter/legacy/elements/GlobalSet.php b/yii2-adapter/legacy/elements/GlobalSet.php index 2f23d3b10e9..60398d66836 100644 --- a/yii2-adapter/legacy/elements/GlobalSet.php +++ b/yii2-adapter/legacy/elements/GlobalSet.php @@ -13,6 +13,7 @@ use craft\validators\HandleValidator; use craft\validators\UniqueValidator; use CraftCms\Cms\Element\Element; +use CraftCms\Cms\Element\Validation\ElementRules; use CraftCms\Cms\Field\Fields; use CraftCms\Cms\FieldLayout\Contracts\FieldLayoutProviderInterface; use CraftCms\Cms\FieldLayout\FieldLayout; @@ -221,14 +222,14 @@ protected function defineRules(): array ['name', 'handle'], UniqueValidator::class, 'targetClass' => GlobalSetRecord::class, - 'except' => [self::SCENARIO_ESSENTIALS], + 'except' => [ElementRules::SCENARIO_ESSENTIALS], ]; $rules[] = [ ['handle'], HandleValidator::class, 'reservedWords' => ['id', 'dateCreated', 'dateUpdated', 'uid', 'title'], - 'except' => [self::SCENARIO_ESSENTIALS], + 'except' => [ElementRules::SCENARIO_ESSENTIALS], ]; $rules[] = [['fieldLayout'], function() { diff --git a/yii2-adapter/legacy/elements/Tag.php b/yii2-adapter/legacy/elements/Tag.php index 233e3033d3e..d9954333ea9 100644 --- a/yii2-adapter/legacy/elements/Tag.php +++ b/yii2-adapter/legacy/elements/Tag.php @@ -16,6 +16,7 @@ use craft\records\Tag as TagRecord; use CraftCms\Cms\Element\Conditions\Contracts\ElementConditionInterface; use CraftCms\Cms\Element\Element; +use CraftCms\Cms\Element\Validation\ElementRules; use CraftCms\Cms\FieldLayout\FieldLayout; use CraftCms\Cms\User\Elements\User; use GraphQL\Type\Definition\Type; @@ -213,7 +214,7 @@ protected function defineRules(): array ['title'], 'validateTitle', 'when' => fn(): bool => !$this->errors()->has('groupId') && !$this->errors()->has('title'), - 'on' => [self::SCENARIO_DEFAULT, self::SCENARIO_LIVE], + 'on' => [ElementRules::SCENARIO_DEFAULT, ElementRules::SCENARIO_LIVE], ]; return $rules; } diff --git a/yii2-adapter/legacy/elements/User.php b/yii2-adapter/legacy/elements/User.php index bce6f1e14db..aeec061e31d 100644 --- a/yii2-adapter/legacy/elements/User.php +++ b/yii2-adapter/legacy/elements/User.php @@ -14,9 +14,11 @@ use craft\events\AuthenticateUserEvent; use craft\events\DefineValueEvent; use CraftCms\Cms\Auth\Events\Authenticating; +use CraftCms\Cms\Element\Validation\ElementRules; use CraftCms\Cms\User\Elements\User as UserElement; use CraftCms\Cms\User\Events\DefineFriendlyName; use CraftCms\Cms\User\Events\DefineName; +use CraftCms\Cms\User\Validation\UserRules; use Illuminate\Support\Facades\Event; /** @@ -26,6 +28,18 @@ class User extends UserElement { use ElementEventConstants; + public const string SCENARIO_DEFAULT = ElementRules::SCENARIO_DEFAULT; + + public const string SCENARIO_ESSENTIALS = ElementRules::SCENARIO_ESSENTIALS; + + public const string SCENARIO_LIVE = ElementRules::SCENARIO_LIVE; + + public const string SCENARIO_ACTIVATION = UserRules::SCENARIO_ACTIVATION; + + public const string SCENARIO_REGISTRATION = UserRules::SCENARIO_REGISTRATION; + + public const string SCENARIO_PASSWORD = UserRules::SCENARIO_PASSWORD; + /** * @event DefineValueEvent The event that is triggered when defining the user’s name, as returned by [[getName()]] or [[__toString()]]. * diff --git a/yii2-adapter/legacy/services/Auth.php b/yii2-adapter/legacy/services/Auth.php index 14494e38e48..982ae3fc68b 100644 --- a/yii2-adapter/legacy/services/Auth.php +++ b/yii2-adapter/legacy/services/Auth.php @@ -17,6 +17,7 @@ use CraftCms\Cms\Support\Facades\Elements; use CraftCms\Cms\Support\Json; use CraftCms\Cms\User\Elements\User; +use CraftCms\Cms\User\Validation\UserRules; use CraftCms\Cms\View\TemplateMode; use DateTime; use Illuminate\Support\Collection; @@ -332,7 +333,7 @@ public static function registerEvents(): void $user = $event->user; $user->newPassword = $event->newPassword; - $user->setScenario(User::SCENARIO_PASSWORD); + $user->ruleset->useScenario(UserRules::SCENARIO_PASSWORD); if (!Elements::saveElement($user)) { $event->status = 'password.save_failed'; diff --git a/yii2-adapter/legacy/services/Globals.php b/yii2-adapter/legacy/services/Globals.php index ecf982e5862..fc9b3fdd360 100644 --- a/yii2-adapter/legacy/services/Globals.php +++ b/yii2-adapter/legacy/services/Globals.php @@ -304,7 +304,7 @@ public function saveSet(GlobalSet $globalSet, bool $runValidation = true): bool } // Prevent most custom field validators - $globalSet->setScenario(GlobalSet::SCENARIO_SAVE_SET); + $globalSet->ruleset->useScenario(GlobalSet::SCENARIO_SAVE_SET); if ($runValidation && !$globalSet->validate()) { Log::info('Global set not saved due to validation error.', [__METHOD__]); diff --git a/yii2-adapter/legacy/test/fixtures/elements/BaseElementFixture.php b/yii2-adapter/legacy/test/fixtures/elements/BaseElementFixture.php index 4617148d197..e2c27daa520 100644 --- a/yii2-adapter/legacy/test/fixtures/elements/BaseElementFixture.php +++ b/yii2-adapter/legacy/test/fixtures/elements/BaseElementFixture.php @@ -107,7 +107,7 @@ public function load(): void $this->populateElement($element, $data); if ($element->enabled && $element->getIsCanonical() && !$element->isProvisionalDraft) { - $element->setScenario(Element::SCENARIO_LIVE); + $element->ruleset->useScenario(Element::SCENARIO_LIVE); } if (!$this->saveElement($element)) { diff --git a/yii2-adapter/legacy/validators/ElementUriValidator.php b/yii2-adapter/legacy/validators/ElementUriValidator.php index e62e40001f8..6ddfd037e83 100644 --- a/yii2-adapter/legacy/validators/ElementUriValidator.php +++ b/yii2-adapter/legacy/validators/ElementUriValidator.php @@ -9,6 +9,7 @@ use craft\base\ElementInterface; use CraftCms\Cms\Element\Element; +use CraftCms\Cms\Element\Validation\ElementRules; use CraftCms\Cms\Shared\Exceptions\OperationAbortedException; use CraftCms\Cms\Support\Facades\Elements; use yii\base\InvalidConfigException; @@ -50,7 +51,7 @@ public function validateAttribute($model, $attribute): void // Ignore published drafts if the scenario isn't "live", // or if the canonical element is enabled and the URI hasn't changed on the draft if ($model->getIsDraft() && !$model->getIsUnpublishedDraft()) { - if ($model->getScenario() !== Element::SCENARIO_LIVE) { + if ($model->ruleset->getScenario() !== ElementRules::SCENARIO_LIVE) { return; } @@ -72,7 +73,7 @@ public function validateAttribute($model, $attribute): void if ( $model->enabled && $model->getEnabledForSite() && - (!$model->getIsUnpublishedDraft() || $model->getScenario() === Element::SCENARIO_LIVE) + (!$model->getIsUnpublishedDraft() || $model->ruleset->getScenario() === Element::SCENARIO_LIVE) ) { $this->addError($model, $attribute, t('Could not generate a unique URI based on the URI format.')); return; diff --git a/yii2-adapter/legacy/validators/SlugValidator.php b/yii2-adapter/legacy/validators/SlugValidator.php index 45db237effd..36a77b9f414 100644 --- a/yii2-adapter/legacy/validators/SlugValidator.php +++ b/yii2-adapter/legacy/validators/SlugValidator.php @@ -11,6 +11,7 @@ use CraftCms\Cms\Cms; use CraftCms\Cms\Element\Element; use CraftCms\Cms\Element\ElementHelper; +use CraftCms\Cms\Element\Validation\ElementRules; use yii\validators\Validator; use function CraftCms\Cms\t; @@ -64,7 +65,7 @@ public function validateAttribute($model, $attribute): void $isDraft = $model instanceof ElementInterface && $model->getIsDraft(); // If this is a draft with a temp slug, leave it alone - if ($isDraft && !in_array($model->getScenario(), [Element::SCENARIO_LIVE, Element::SCENARIO_DEFAULT])) { + if ($isDraft && !in_array($model->ruleset->getScenario(), [ElementRules::SCENARIO_LIVE, ElementRules::SCENARIO_DEFAULT])) { if ($isTemp) { // Leave it alone return; diff --git a/yii2-adapter/lib/ar-softdelete/README.md b/yii2-adapter/lib/ar-softdelete/README.md index d555b2a5764..3589774ebb2 100644 --- a/yii2-adapter/lib/ar-softdelete/README.md +++ b/yii2-adapter/lib/ar-softdelete/README.md @@ -160,7 +160,7 @@ class Item extends ActiveRecord } $item = Item::findOne($id); -$item->setScenario('some'); +$item->ruleset->useScenario('some'); $item->delete(); // nothing happens! ``` diff --git a/yii2-adapter/src/Mixins/ValidateMixin.php b/yii2-adapter/src/Mixins/ValidateMixin.php index 819f70e951d..bc437309ad5 100644 --- a/yii2-adapter/src/Mixins/ValidateMixin.php +++ b/yii2-adapter/src/Mixins/ValidateMixin.php @@ -139,4 +139,32 @@ public function rulesClass(): Closure return LegacyElementRules::class; }; } + + public function setScenario(): Closure + { + return function(string $scenario) { + Deprecator::log($this::class . '->setScenario', 'Calling `->setScenario` is deprecated. Use `->ruleset->useScenario()` instead.'); + + /** + * @var \CraftCms\RulesetValidation\Contracts\ValidatesWithRuleset $this + * + * @phpstan-ignore-next-line + */ + return $this->ruleset->useScenario($scenario); + }; + } + + public function getScenario(): Closure + { + return function() { + Deprecator::log($this::class . '->getScenario', 'Calling `->getScenario` is deprecated. Use `->ruleset->getScenario()` instead.'); + + /** + * @var \CraftCms\RulesetValidation\Contracts\ValidatesWithRuleset $this + * + * @phpstan-ignore-next-line + */ + return $this->ruleset->getScenario(); + }; + } } diff --git a/yii2-adapter/src/Validation/LegacyElementRules.php b/yii2-adapter/src/Validation/LegacyElementRules.php index 1fe6e94c2bb..107236c2fce 100644 --- a/yii2-adapter/src/Validation/LegacyElementRules.php +++ b/yii2-adapter/src/Validation/LegacyElementRules.php @@ -9,22 +9,22 @@ class LegacyElementRules extends ElementRules { - protected function defineRules(): array + public function rules(): array { - $rules = parent::defineRules(); + $rules = parent::rules(); - $reflectionClass = new ReflectionClass($this->component); + $reflectionClass = new ReflectionClass($this->subject); if (!$reflectionClass->hasMethod('defineRules')) { return $rules; } $method = $reflectionClass->getMethod('defineRules'); - $yiiRules = $method->invoke($this->component); + $yiiRules = $method->invoke($this->subject); return LegacyYiiRules::mergeWildcardRules( rules: $rules, - target: $this->component, + target: $this->subject, yiiRules: $yiiRules, ); } diff --git a/yii2-adapter/tests/unit/gql/mutations/GeneralMutationResolverTest.php b/yii2-adapter/tests/unit/gql/mutations/GeneralMutationResolverTest.php index 34ca5cdeb50..450f554ae68 100644 --- a/yii2-adapter/tests/unit/gql/mutations/GeneralMutationResolverTest.php +++ b/yii2-adapter/tests/unit/gql/mutations/GeneralMutationResolverTest.php @@ -247,19 +247,19 @@ public function testSavingElementWithoutValidationError(): void $entry->sectionId = Section::find()->one()->id; $scenario = Element::SCENARIO_DEFAULT; - $entry->setScenario($scenario); + $entry->ruleset->useScenario($scenario); $entry->enabled = false; $this->invokeMethod($this->resolver, 'saveElement', [$entry]); // Ensure scenario unchanged for disabled elements - self::assertSame($scenario, $entry->getScenario()); + self::assertSame($scenario, $entry->ruleset->getScenario()); $entry->enabled = true; $this->invokeMethod($this->resolver, 'saveElement', [$entry]); // Ensure scenario changed for enabled elements with the default scenario - self::assertNotSame($scenario, $entry->getScenario()); + self::assertNotSame($scenario, $entry->ruleset->getScenario()); } public function testNestedNormalizers(): void