From d8b8a3da8106310d78fd3efcabb106c8042d593a Mon Sep 17 00:00:00 2001 From: Rias Date: Sat, 25 Jul 2026 09:03:33 +0200 Subject: [PATCH 01/21] Add validated component type catalogs --- src/Component/TypeRegistry.php | 144 +++++++ src/Dashboard/WidgetTypes.php | 44 ++ src/Element/ElementTypes.php | 51 +++ src/Field/FieldTypes.php | 56 +++ src/Field/LinkTypes.php | 61 +++ src/Field/NestedEntryFieldTypes.php | 29 ++ src/Filesystem/FilesystemTypes.php | 30 ++ src/Gql/GqlDirectives.php | 75 ++++ src/Gql/GqlMutations.php | 36 ++ src/Gql/GqlQueries.php | 40 ++ src/Gql/GqlTypes.php | 53 +++ src/Image/ImageTransformers.php | 29 ++ src/Utility/UtilityTypes.php | 89 ++++ src/View/TemplateCacheCollectors.php | 43 ++ tests/Unit/Component/TypeRegistryTest.php | 386 ++++++++++++++++++ .../src/Event/TypeRegistryCompatibility.php | 35 ++ 16 files changed, 1201 insertions(+) create mode 100644 src/Component/TypeRegistry.php create mode 100644 src/Dashboard/WidgetTypes.php create mode 100644 src/Element/ElementTypes.php create mode 100644 src/Field/FieldTypes.php create mode 100644 src/Field/LinkTypes.php create mode 100644 src/Field/NestedEntryFieldTypes.php create mode 100644 src/Filesystem/FilesystemTypes.php create mode 100644 src/Gql/GqlDirectives.php create mode 100644 src/Gql/GqlMutations.php create mode 100644 src/Gql/GqlQueries.php create mode 100644 src/Gql/GqlTypes.php create mode 100644 src/Image/ImageTransformers.php create mode 100644 src/Utility/UtilityTypes.php create mode 100644 src/View/TemplateCacheCollectors.php create mode 100644 tests/Unit/Component/TypeRegistryTest.php create mode 100644 yii2-adapter/src/Event/TypeRegistryCompatibility.php diff --git a/src/Component/TypeRegistry.php b/src/Component/TypeRegistry.php new file mode 100644 index 00000000000..0955fde0573 --- /dev/null +++ b/src/Component/TypeRegistry.php @@ -0,0 +1,144 @@ +register(MyField::class); + * } + * ``` + * + * @template T of object + * + * @internal + */ +abstract class TypeRegistry +{ + /** @var class-string|null */ + protected const ?string CONTRACT = null; + + /** @var list> */ + protected const array DEFAULT_TYPES = []; + + /** @var list> */ + protected const array PROTECTED_TYPES = []; + + /** @var array> */ + private array $types = []; + + public function __construct() + { + $this->register(...static::DEFAULT_TYPES); + } + + /** + * @param class-string ...$types + */ + public function register(string ...$types): void + { + $registeredTypes = $this->types; + $reservedIdentities = $this->reservedIdentities(); + + foreach ($types as $type) { + $this->validate($type); + + $identity = $this->identity($type); + $registeredType = $registeredTypes[$identity] ?? null; + + if ($registeredType !== null && $registeredType !== $type) { + throw new InvalidArgumentException("Type [$type] shares identity [$identity] with [$registeredType]."); + } + + if (in_array($identity, $reservedIdentities, true)) { + throw new InvalidArgumentException("Type [$type] uses reserved identity [$identity]."); + } + + $registeredTypes[$identity] = $type; + } + + $this->types = $registeredTypes; + } + + /** + * @param class-string ...$types + */ + public function remove(string ...$types): void + { + foreach ($types as $type) { + $this->ensureRemovable($type); + } + + foreach ($types as $type) { + $identity = array_search($type, $this->types, true); + + if ($identity !== false) { + unset($this->types[$identity]); + } + } + } + + /** + * @return Collection> + */ + public function types(): Collection + { + return new Collection(array_values($this->types)); + } + + /** @return Collection> */ + protected function typesByIdentity(): Collection + { + return new Collection($this->types); + } + + /** @param class-string $type */ + protected function identity(string $type): string + { + return $type; + } + + /** @return class-string|null */ + protected function typeByIdentity(string $identity): ?string + { + return $this->types[$identity] ?? null; + } + + /** @return list */ + protected function reservedIdentities(): array + { + return []; + } + + /** @param class-string $type */ + private function validate(string $type): void + { + $contract = static::CONTRACT; + + if ($contract === null) { + throw new InvalidArgumentException(sprintf('Registry [%s] must define a contract.', static::class)); + } + + if (! is_a($type, $contract, true)) { + throw new InvalidArgumentException(sprintf('Type [%s] must implement or extend [%s].', $type, $contract)); + } + } + + /** @param class-string $type */ + private function ensureRemovable(string $type): void + { + if (in_array($type, static::PROTECTED_TYPES, true)) { + throw new InvalidArgumentException("Type [$type] cannot be removed."); + } + } +} diff --git a/src/Dashboard/WidgetTypes.php b/src/Dashboard/WidgetTypes.php new file mode 100644 index 00000000000..4ffdd8a5392 --- /dev/null +++ b/src/Dashboard/WidgetTypes.php @@ -0,0 +1,44 @@ +register(MyWidget::class); + * } + * ``` + * + * @extends TypeRegistry + */ +#[Singleton] +class WidgetTypes extends TypeRegistry +{ + protected const string CONTRACT = WidgetInterface::class; + + protected const array DEFAULT_TYPES = [ + Feed::class, + CraftSupport::class, + NewUsers::class, + QuickPost::class, + RecentEntries::class, + MyDrafts::class, + Updates::class, + ]; +} diff --git a/src/Element/ElementTypes.php b/src/Element/ElementTypes.php new file mode 100644 index 00000000000..fb764193ac4 --- /dev/null +++ b/src/Element/ElementTypes.php @@ -0,0 +1,51 @@ +register(MyElement::class); + * } + * ``` + * + * @extends TypeRegistry + */ +#[Singleton] +class ElementTypes extends TypeRegistry +{ + protected const string CONTRACT = ElementInterface::class; + + protected const array DEFAULT_TYPES = [ + Address::class, + Asset::class, + Entry::class, + User::class, + ]; + + /** @return class-string|null */ + public function typeByRefHandle(string $refHandle): ?string + { + return $this->typeByIdentity(strtolower($refHandle)); + } + + /** @param class-string $type */ + #[\Override] + protected function identity(string $type): string + { + return strtolower($type::refHandle() ?? $type); + } +} diff --git a/src/Field/FieldTypes.php b/src/Field/FieldTypes.php new file mode 100644 index 00000000000..76d5241271a --- /dev/null +++ b/src/Field/FieldTypes.php @@ -0,0 +1,56 @@ +register(MyField::class); + * } + * ``` + * + * @extends TypeRegistry + */ +#[Singleton] +class FieldTypes extends TypeRegistry +{ + protected const string CONTRACT = FieldInterface::class; + + protected const array DEFAULT_TYPES = [ + Addresses::class, + Assets::class, + ButtonGroup::class, + Checkboxes::class, + Color::class, + ContentBlock::class, + Country::class, + Date::class, + Dropdown::class, + Email::class, + Entries::class, + Icon::class, + Json::class, + Lightswitch::class, + Link::class, + Markdown::class, + Matrix::class, + Money::class, + MultiSelect::class, + Number::class, + PlainText::class, + RadioButtons::class, + Range::class, + Table::class, + Time::class, + Users::class, + ]; +} diff --git a/src/Field/LinkTypes.php b/src/Field/LinkTypes.php new file mode 100644 index 00000000000..7cdda5a4f96 --- /dev/null +++ b/src/Field/LinkTypes.php @@ -0,0 +1,61 @@ +register(MyLinkType::class); + * } + * ``` + * + * @extends TypeRegistry + */ +#[Singleton] +class LinkTypes extends TypeRegistry +{ + protected const string CONTRACT = BaseLinkType::class; + + protected const array DEFAULT_TYPES = [ + Asset::class, + Email::class, + Entry::class, + Phone::class, + Sms::class, + Url::class, + ]; + + protected const array PROTECTED_TYPES = [Url::class]; + + /** @return Collection> */ + public function typesById(): Collection + { + $types = $this->typesByIdentity(); + $types->forget(Url::id()); + + return $types->put(Url::id(), Url::class); + } + + /** @param class-string $type */ + #[\Override] + protected function identity(string $type): string + { + return $type::id(); + } +} diff --git a/src/Field/NestedEntryFieldTypes.php b/src/Field/NestedEntryFieldTypes.php new file mode 100644 index 00000000000..a3992d72155 --- /dev/null +++ b/src/Field/NestedEntryFieldTypes.php @@ -0,0 +1,29 @@ +register(MyNestedEntryField::class); + * } + * ``` + * + * @extends TypeRegistry + */ +#[Singleton] +class NestedEntryFieldTypes extends TypeRegistry +{ + protected const string CONTRACT = ElementContainerFieldInterface::class; + + protected const array DEFAULT_TYPES = [Matrix::class]; +} diff --git a/src/Filesystem/FilesystemTypes.php b/src/Filesystem/FilesystemTypes.php new file mode 100644 index 00000000000..b164ae0e0d7 --- /dev/null +++ b/src/Filesystem/FilesystemTypes.php @@ -0,0 +1,30 @@ +register(MyFilesystem::class); + * } + * ``` + * + * @extends TypeRegistry + */ +#[Singleton] +class FilesystemTypes extends TypeRegistry +{ + protected const string CONTRACT = FsInterface::class; + + protected const array DEFAULT_TYPES = [Local::class]; +} diff --git a/src/Gql/GqlDirectives.php b/src/Gql/GqlDirectives.php new file mode 100644 index 00000000000..8b83cc402bc --- /dev/null +++ b/src/Gql/GqlDirectives.php @@ -0,0 +1,75 @@ +register(MyDirective::class); + * } + * ``` + * + * @extends TypeRegistry + */ +#[Singleton] +class GqlDirectives extends TypeRegistry +{ + protected const string CONTRACT = Directive::class; + + protected const array DEFAULT_TYPES = [ + FormatDateTime::class, + Markdown::class, + Money::class, + StripTags::class, + Trim::class, + ParseRefs::class, + Transform::class, + ]; + + /** @return Collection> */ + public function forSchema(?GqlSchema $schema): Collection + { + $scope = $schema === null ? [] : $schema->scope; + + return parent::types()->reject(fn (string $directive) => match ($directive) { + ParseRefs::class => ! in_array('directive:parseRefs', $scope, true), + Transform::class => ! in_array('directive:transform', $scope, true), + default => false, + }); + } + + /** @param class-string $type */ + #[\Override] + protected function identity(string $type): string + { + return $type::name(); + } + + #[\Override] + protected function reservedIdentities(): array + { + return array_values(array_map( + fn (GqlDirective $directive) => $directive->name, + GqlDirective::builtInDirectives(), + )); + } +} diff --git a/src/Gql/GqlMutations.php b/src/Gql/GqlMutations.php new file mode 100644 index 00000000000..8f0e24d2e9f --- /dev/null +++ b/src/Gql/GqlMutations.php @@ -0,0 +1,36 @@ +register(MyMutation::class); + * } + * ``` + * + * @extends TypeRegistry + */ +#[Singleton] +class GqlMutations extends TypeRegistry +{ + protected const string CONTRACT = Mutation::class; + + protected const array DEFAULT_TYPES = [ + Ping::class, + Entry::class, + Asset::class, + ]; +} diff --git a/src/Gql/GqlQueries.php b/src/Gql/GqlQueries.php new file mode 100644 index 00000000000..02d34e534f5 --- /dev/null +++ b/src/Gql/GqlQueries.php @@ -0,0 +1,40 @@ +register(MyQuery::class); + * } + * ``` + * + * @extends TypeRegistry + */ +#[Singleton] +class GqlQueries extends TypeRegistry +{ + protected const string CONTRACT = Query::class; + + protected const array DEFAULT_TYPES = [ + Address::class, + Ping::class, + Entry::class, + Asset::class, + User::class, + ]; +} diff --git a/src/Gql/GqlTypes.php b/src/Gql/GqlTypes.php new file mode 100644 index 00000000000..9312f252f42 --- /dev/null +++ b/src/Gql/GqlTypes.php @@ -0,0 +1,53 @@ +register(MyType::class); + * } + * ``` + * + * @extends TypeRegistry + */ +#[Singleton] +class GqlTypes extends TypeRegistry +{ + protected const string CONTRACT = SingularTypeInterface::class; + + protected const array DEFAULT_TYPES = [ + DateTime::class, + Number::class, + QueryArgument::class, + Address::class, + Element::class, + Entry::class, + Asset::class, + User::class, + ]; + + /** @param class-string $type */ + #[\Override] + protected function identity(string $type): string + { + return $type::getName(); + } +} diff --git a/src/Image/ImageTransformers.php b/src/Image/ImageTransformers.php new file mode 100644 index 00000000000..d88221335d9 --- /dev/null +++ b/src/Image/ImageTransformers.php @@ -0,0 +1,29 @@ +register(MyImageTransformer::class); + * } + * ``` + * + * @extends TypeRegistry + */ +#[Singleton] +class ImageTransformers extends TypeRegistry +{ + protected const string CONTRACT = ImageTransformerInterface::class; + + protected const array DEFAULT_TYPES = [ImageTransformer::class]; +} diff --git a/src/Utility/UtilityTypes.php b/src/Utility/UtilityTypes.php new file mode 100644 index 00000000000..0d738d2b9cf --- /dev/null +++ b/src/Utility/UtilityTypes.php @@ -0,0 +1,89 @@ +register(MyUtility::class); + * } + * ``` + * + * @extends TypeRegistry + */ +#[Singleton] +class UtilityTypes extends TypeRegistry +{ + protected const string CONTRACT = Utility::class; + + protected const array DEFAULT_TYPES = [ + Updates::class, + SystemReport::class, + ProjectConfig::class, + PhpInfo::class, + SystemMessages::class, + AssetIndexes::class, + QueueManager::class, + ClearCaches::class, + DeprecationErrors::class, + DbBackup::class, + FindAndReplace::class, + Migrations::class, + ]; + + public function __construct( + private readonly GeneralConfig $generalConfig, + ) { + parent::__construct(); + } + + #[\Override] + public function types(): Collection + { + $types = parent::types() + /** @var class-string $class */ + ->reject(fn (string $class) => match ($class) { + SystemMessages::class => ! Edition::isAtLeast(Edition::Pro), + AssetIndexes::class => Volumes::getAllVolumes()->isEmpty(), + DbBackup::class => $this->generalConfig->backupCommand === false, + default => false, + }); + + $disabledUtilities = array_flip($this->generalConfig->disabledUtilities); + + return $types + /** @var class-string $class */ + ->filter(fn (string $class) => ! isset($disabledUtilities[$class::id()]) && $class::isSelectable()); + } + + /** @param class-string $type */ + #[\Override] + protected function identity(string $type): string + { + return $type::id(); + } +} diff --git a/src/View/TemplateCacheCollectors.php b/src/View/TemplateCacheCollectors.php new file mode 100644 index 00000000000..2259f8e1021 --- /dev/null +++ b/src/View/TemplateCacheCollectors.php @@ -0,0 +1,43 @@ +register(MyCacheCollector::class); + * } + * ``` + * + * @extends TypeRegistry + */ +#[Singleton] +class TemplateCacheCollectors extends TypeRegistry +{ + protected const string CONTRACT = CacheCollectorInterface::class; + + protected const array DEFAULT_TYPES = [ + DependencyCollector::class, + ResourceCollector::class, + ]; + + protected const array PROTECTED_TYPES = self::DEFAULT_TYPES; + + /** @param class-string $type */ + #[\Override] + protected function identity(string $type): string + { + return $type::key(); + } +} diff --git a/tests/Unit/Component/TypeRegistryTest.php b/tests/Unit/Component/TypeRegistryTest.php new file mode 100644 index 00000000000..a1ab80e2623 --- /dev/null +++ b/tests/Unit/Component/TypeRegistryTest.php @@ -0,0 +1,386 @@ +andReturn(collect([new stdClass])); +}); + +it('contains its built-in types in order', function (string $registry, array $expected) { + expect(app($registry)->types()->all())->toBe($expected); +})->with([ + 'fields' => [FieldTypes::class, [ + Addresses::class, + Assets::class, + ButtonGroup::class, + Checkboxes::class, + Color::class, + ContentBlock::class, + Country::class, + Date::class, + Dropdown::class, + Email::class, + Entries::class, + Icon::class, + Json::class, + Lightswitch::class, + Link::class, + Markdown::class, + Matrix::class, + Money::class, + MultiSelect::class, + Number::class, + PlainText::class, + RadioButtons::class, + Range::class, + Table::class, + Time::class, + Users::class, + ]], + 'elements' => [ElementTypes::class, [ + Address::class, + Asset::class, + Entry::class, + User::class, + ]], + 'widgets' => [WidgetTypes::class, [ + Feed::class, + CraftSupport::class, + NewUsers::class, + QuickPost::class, + RecentEntries::class, + MyDrafts::class, + UpdatesWidget::class, + ]], + 'utilities' => [UtilityTypes::class, [ + Updates::class, + SystemReport::class, + ProjectConfig::class, + PhpInfo::class, + SystemMessages::class, + AssetIndexes::class, + QueueManager::class, + ClearCaches::class, + DeprecationErrors::class, + DbBackup::class, + FindAndReplace::class, + Migrations::class, + ]], + 'filesystems' => [FilesystemTypes::class, [ + Local::class, + ]], + 'image transformers' => [ImageTransformers::class, [ + ImageTransformer::class, + ]], + 'link types' => [LinkTypes::class, [ + LinkAsset::class, + LinkEmail::class, + LinkEntry::class, + Phone::class, + Sms::class, + Url::class, + ]], + 'gql directives' => [GqlDirectives::class, [ + FormatDateTime::class, + GqlMarkdown::class, + GqlMoney::class, + StripTags::class, + Trim::class, + ParseRefs::class, + Transform::class, + ]], +]); + +it('registers types once in first-registration order', function () { + $registry = app(UtilityTypes::class); + + $registry->register(RegistryUtilityType::class, AnotherRegistryUtilityType::class, RegistryUtilityType::class); + + expect($registry->types()->intersect([RegistryUtilityType::class, AnotherRegistryUtilityType::class])->values()->all()) + ->toBe([RegistryUtilityType::class, AnotherRegistryUtilityType::class]); +}); + +it('removes registered types idempotently', function () { + $registry = app(UtilityTypes::class); + + $registry->register(RegistryUtilityType::class, AnotherRegistryUtilityType::class); + $registry->remove(RegistryUtilityType::class); + + expect($registry->types())->not()->toContain(RegistryUtilityType::class) + ->and($registry->types())->toContain(AnotherRegistryUtilityType::class); + + $registry->remove(AnotherRegistryUtilityType::class, RegistryUtilityType::class); + $registry->remove(AnotherRegistryUtilityType::class); + + expect($registry->types()) + ->not()->toContain(RegistryUtilityType::class, AnotherRegistryUtilityType::class); +}); + +it('rejects removing required types', function (string $registry, string $type) { + $registry = app($registry); + + expect(fn () => $registry->remove($type)) + ->toThrow(InvalidArgumentException::class); +})->with([ + 'URL link type' => [LinkTypes::class, Url::class], +]); + +it('rejects types that do not satisfy the registry contract', function (string $registry) { + expect(fn () => app($registry)->register(stdClass::class)) + ->toThrow(InvalidArgumentException::class); +})->with([ + 'fields' => FieldTypes::class, + 'elements' => ElementTypes::class, + 'widgets' => WidgetTypes::class, + 'utilities' => UtilityTypes::class, + 'filesystems' => FilesystemTypes::class, + 'image transformers' => ImageTransformers::class, + 'link types' => LinkTypes::class, + 'gql directives' => GqlDirectives::class, +]); + +it('does not partially register a rejected batch', function () { + $registry = app(FieldTypes::class); + + expect(fn () => $registry->register(RegistryFieldType::class, stdClass::class)) + ->toThrow(InvalidArgumentException::class) + ->and($registry->types())->not()->toContain(RegistryFieldType::class); +}); + +it('returns snapshots that cannot mutate stored types', function () { + $registry = app(UtilityTypes::class); + $snapshot = $registry->types(); + + $snapshot->push(RegistryUtilityType::class); + + expect($registry->types())->not()->toContain(RegistryUtilityType::class); +}); + +it('does not instantiate registered types', function () { + RegistryUtilityType::$instances = 0; + + app(UtilityTypes::class)->register(RegistryUtilityType::class); + + expect(RegistryUtilityType::$instances)->toBe(0); +}); + +it('rejects domain identity collisions without partially registering the batch', function (string $registryClass, string $first, string $second) { + $registry = app($registryClass); + + expect(fn () => $registry->register($first, $second)) + ->toThrow(InvalidArgumentException::class) + ->and($registry->types())->not()->toContain($first, $second); +})->with([ + 'link type IDs' => [LinkTypes::class, RegistryLinkType::class, CollidingRegistryLinkType::class], + 'element reference handles' => [ElementTypes::class, RegistryElementType::class, CollidingRegistryElementType::class], + 'utility IDs' => [UtilityTypes::class, RegistryUtilityType::class, CollidingRegistryUtilityType::class], + 'GQL directive names' => [GqlDirectives::class, RegistryGqlDirective::class, CollidingRegistryGqlDirective::class], +]); + +it('keeps the protected URL link type effective when its identity is claimed', function () { + $registry = app(LinkTypes::class); + + expect(fn () => $registry->register(UrlRegistryLinkType::class)) + ->toThrow(InvalidArgumentException::class) + ->and($registry->types())->not()->toContain(UrlRegistryLinkType::class) + ->and(Link::types()['url'])->toBe(Url::class); +}); + +it('rejects Webonyx built-in directive names', function () { + $registry = app(GqlDirectives::class); + ReservedRegistryGqlDirective::$testName = 'skip'; + + expect(fn () => $registry->register(ReservedRegistryGqlDirective::class)) + ->toThrow(InvalidArgumentException::class) + ->and($registry->types())->not()->toContain(ReservedRegistryGqlDirective::class); +}); + +it('allows element types without reference handles to coexist by class', function () { + $registry = app(ElementTypes::class); + + $registry->register(NullRegistryElementType::class, AnotherNullRegistryElementType::class); + + expect($registry->types()) + ->toContain(NullRegistryElementType::class, AnotherNullRegistryElementType::class); +}); + +abstract class RegistryFieldType extends Field {} + +abstract class RegistryElementType extends Element +{ + #[Override] + public static function refHandle(): ?string + { + return 'registryElement'; + } +} + +abstract class CollidingRegistryElementType extends RegistryElementType +{ + #[Override] + public static function refHandle(): ?string + { + return 'REGISTRYELEMENT'; + } +} + +abstract class NullRegistryElementType extends Element {} + +abstract class AnotherNullRegistryElementType extends Element {} + +class RegistryUtilityType extends Utility +{ + public static int $instances = 0; + + public function __construct() + { + self::$instances++; + } + + public static function displayName(): string + { + return 'Registry utility'; + } + + public static function id(): string + { + return 'registry-utility'; + } + + public static function contentHtml(): string + { + return ''; + } +} + +class AnotherRegistryUtilityType extends RegistryUtilityType +{ + #[Override] + public static function id(): string + { + return 'another-registry-utility'; + } +} + +class CollidingRegistryUtilityType extends RegistryUtilityType {} + +abstract class RegistryLinkType extends BaseLinkType +{ + #[Override] + public static function id(): string + { + return 'registryLink'; + } +} + +abstract class CollidingRegistryLinkType extends RegistryLinkType {} + +abstract class UrlRegistryLinkType extends BaseLinkType +{ + #[Override] + public static function id(): string + { + return 'url'; + } +} + +abstract class RegistryGqlDirective extends Directive +{ + #[Override] + public static function name(): string + { + return 'registry'; + } +} + +abstract class CollidingRegistryGqlDirective extends RegistryGqlDirective {} + +abstract class ReservedRegistryGqlDirective extends Directive +{ + public static string $testName; + + #[Override] + public static function name(): string + { + return self::$testName; + } +} diff --git a/yii2-adapter/src/Event/TypeRegistryCompatibility.php b/yii2-adapter/src/Event/TypeRegistryCompatibility.php new file mode 100644 index 00000000000..5c5a109e513 --- /dev/null +++ b/yii2-adapter/src/Event/TypeRegistryCompatibility.php @@ -0,0 +1,35 @@ + $eventClass */ + public static function reconcile( + TypeRegistry $registry, + Component $component, + string $eventName, + string $attribute = 'types', + string $eventClass = RegisterComponentTypesEvent::class, + ): void { + if (!$component->hasEventHandlers($eventName)) { + return; + } + + $types = $registry->types(); + $event = new $eventClass([$attribute => $types->all()]); + $component->trigger($eventName, $event); + $transformedTypes = collect($event->{$attribute}); + + $registry->remove(...$types->diff($transformedTypes)); + $registry->register(...$transformedTypes->diff($types)); + } +} From 6334d3c2e33c062c04c20de8b7039941103643ca Mon Sep 17 00:00:00 2001 From: Rias Date: Sat, 25 Jul 2026 09:03:55 +0200 Subject: [PATCH 02/21] Fold auth registration into AuthMethods --- src/Auth/AuthMethodCatalog.php | 36 +++++++++++++++ src/Auth/AuthMethods.php | 45 ++++++++++++------- src/Auth/Events/AuthMethodsResolving.php | 18 -------- src/Support/Facades/AuthMethods.php | 3 ++ .../Feature/Auth/Auth/Auth2faMethodsTest.php | 16 ++++--- 5 files changed, 80 insertions(+), 38 deletions(-) create mode 100644 src/Auth/AuthMethodCatalog.php delete mode 100644 src/Auth/Events/AuthMethodsResolving.php diff --git a/src/Auth/AuthMethodCatalog.php b/src/Auth/AuthMethodCatalog.php new file mode 100644 index 00000000000..eaf2aacefd8 --- /dev/null +++ b/src/Auth/AuthMethodCatalog.php @@ -0,0 +1,36 @@ + + */ +#[Singleton] +class AuthMethodCatalog extends TypeRegistry +{ + protected const string CONTRACT = AuthMethodInterface::class; + + protected const array DEFAULT_TYPES = [ + TOTP::class, + RecoveryCodes::class, + ]; + + /** @param class-string $type */ + #[\Override] + protected function identity(string $type): string + { + return $type::handle(); + } +} diff --git a/src/Auth/AuthMethods.php b/src/Auth/AuthMethods.php index 60e566b1fb8..63b9cd33fe4 100644 --- a/src/Auth/AuthMethods.php +++ b/src/Auth/AuthMethods.php @@ -5,11 +5,9 @@ namespace CraftCms\Cms\Auth; use CraftCms\Cms\Auth\Enums\AuthError; -use CraftCms\Cms\Auth\Events\AuthMethodsResolving; use CraftCms\Cms\Auth\Events\UserAuthenticating; use CraftCms\Cms\Auth\Methods\AuthMethodInterface; use CraftCms\Cms\Auth\Methods\RecoveryCodes; -use CraftCms\Cms\Auth\Methods\TOTP; use CraftCms\Cms\Auth\Models\WebAuthn; use CraftCms\Cms\Auth\Passkeys\Passkeys; use CraftCms\Cms\Cms; @@ -31,13 +29,22 @@ use Illuminate\Support\Facades\Cookie; use Illuminate\Support\Facades\Session; use InvalidArgumentException; -use RuntimeException; use SensitiveParameter; use Webauthn\Exception\InvalidUserHandleException; use function CraftCms\Cms\currentUserElement; use function CraftCms\Cms\t; +/** + * Resolves authentication methods for users and registers additional method types. + * + * ```php + * public function boot(AuthMethods $authMethods): void + * { + * $authMethods->register(MyAuthMethod::class); + * } + * ``` + */ #[Scoped] class AuthMethods { @@ -59,6 +66,7 @@ public function __construct( private readonly Hasher $hasher, private readonly Passkeys $passkeys, private readonly ProjectConfig $projectConfig, + private readonly AuthMethodCatalog $authMethodCatalog, ) { $this->methods = new Collection; } @@ -80,18 +88,7 @@ public function getAllMethods(?CraftUser $user = null): Collection return $this->methods[$user->id]; } - $methods = new Collection([ - TOTP::class, - RecoveryCodes::class, - ]); - - event($event = new AuthMethodsResolving($methods)); - - $this->methods[$user->id] = $event->methods->map(function (string $class) use ($user) { - if (! is_subclass_of($class, AuthMethodInterface::class)) { - throw new RuntimeException("$class must implement ".AuthMethodInterface::class); - } - + $this->methods[$user->id] = $this->authMethodCatalog->types()->map(function (string $class) use ($user) { /** @var AuthMethodInterface $method */ $method = app()->make($class); $method->setUser($user); @@ -115,6 +112,24 @@ public function getAllMethods(?CraftUser $user = null): Collection return $this->methods[$user->id]; } + /** @param class-string ...$types */ + public function register(string ...$types): void + { + $this->authMethodCatalog->register(...$types); + } + + /** @param class-string ...$types */ + public function remove(string ...$types): void + { + $this->authMethodCatalog->remove(...$types); + } + + /** @return Collection> */ + public function types(): Collection + { + return $this->authMethodCatalog->types(); + } + /** * @return Collection */ diff --git a/src/Auth/Events/AuthMethodsResolving.php b/src/Auth/Events/AuthMethodsResolving.php deleted file mode 100644 index d217541a746..00000000000 --- a/src/Auth/Events/AuthMethodsResolving.php +++ /dev/null @@ -1,18 +0,0 @@ -> - */ - public Collection $methods, - ) {} -} diff --git a/src/Support/Facades/AuthMethods.php b/src/Support/Facades/AuthMethods.php index 8b2d4b7c655..0351596bb6b 100644 --- a/src/Support/Facades/AuthMethods.php +++ b/src/Support/Facades/AuthMethods.php @@ -9,6 +9,9 @@ /** * @method static \Illuminate\Support\Collection getAllMethods(\CraftCms\Cms\User\Contracts\CraftUser|null $user = null) + * @method static void register(string ...$types) + * @method static void remove(string ...$types) + * @method static \Illuminate\Support\Collection types() * @method static \Illuminate\Support\Collection getAvailableMethods(\CraftCms\Cms\User\Contracts\CraftUser|null $user = null) * @method static bool hasActiveMethod(\CraftCms\Cms\User\Contracts\CraftUser|null $user = null) * @method static \Illuminate\Support\Collection getActiveMethods(\CraftCms\Cms\User\Contracts\CraftUser|null $user = null) diff --git a/tests/Feature/Auth/Auth/Auth2faMethodsTest.php b/tests/Feature/Auth/Auth/Auth2faMethodsTest.php index 362f99729ff..4a1618e9d63 100644 --- a/tests/Feature/Auth/Auth/Auth2faMethodsTest.php +++ b/tests/Feature/Auth/Auth/Auth2faMethodsTest.php @@ -3,7 +3,6 @@ declare(strict_types=1); use CraftCms\Cms\Auth\AuthMethods; -use CraftCms\Cms\Auth\Events\AuthMethodsResolving; use CraftCms\Cms\Auth\Methods\RecoveryCodes; use CraftCms\Cms\Auth\Methods\TOTP; use CraftCms\Cms\Edition; @@ -104,15 +103,22 @@ test('custom methods can be registered', function () { $user = User::factory()->createElement(); - Event::listen(AuthMethodsResolving::class, function (AuthMethodsResolving $event) { - $event->methods->push(TOTP::class); - }); + app(AuthMethods::class)->register(CustomAuthMethod::class); $methods = $this->auth->getAllMethods($user); - expect($methods)->not()->toBeEmpty(); + expect($methods->map(fn ($method) => $method::class))->toContain(CustomAuthMethod::class); }); +class CustomAuthMethod extends TOTP +{ + #[Override] + public static function handle(): string + { + return 'custom'; + } +} + test('is2faRequired returns false for Solo', function () { ProjectConfig::set('users.require2fa', 'all'); From b6ce1ea417f777ef7cfc1c4a3a831f9a0a66cd63 Mon Sep 17 00:00:00 2001 From: Rias Date: Sat, 25 Jul 2026 09:04:07 +0200 Subject: [PATCH 03/21] Register asset file kinds explicitly --- src/Asset/AssetFileKinds.php | 91 ++++++++++++++++++++ src/Asset/AssetsHelper.php | 58 ++----------- src/Asset/Events/AssetFileKindsResolving.php | 16 ---- tests/Unit/Asset/AssetsHelperTest.php | 14 ++- 4 files changed, 108 insertions(+), 71 deletions(-) create mode 100644 src/Asset/AssetFileKinds.php delete mode 100644 src/Asset/Events/AssetFileKindsResolving.php diff --git a/src/Asset/AssetFileKinds.php b/src/Asset/AssetFileKinds.php new file mode 100644 index 00000000000..f6a2197f8b4 --- /dev/null +++ b/src/Asset/AssetFileKinds.php @@ -0,0 +1,91 @@ +register('drawing', [ + * 'label' => 'Drawing', + * 'extensions' => ['dwg'], + * ]); + * } + * ``` + */ +#[Singleton] +class AssetFileKinds +{ + /** @var array */ + private array $fileKinds = []; + + /** @var array */ + private array $removedFileKinds = []; + + public function __construct( + private readonly GeneralConfig $generalConfig, + ) {} + + /** + * @param array{label?:string, extensions?:list}|Closure():array{label?:string, extensions?:list} $definition + */ + public function register(string $kind, array|Closure $definition): void + { + if ($kind === '') { + throw new InvalidArgumentException('File kind names cannot be empty.'); + } + + unset($this->removedFileKinds[$kind]); + $this->fileKinds[$kind] = $definition; + } + + public function remove(string ...$kinds): void + { + foreach ($kinds as $kind) { + $this->removedFileKinds[$kind] = true; + unset($this->fileKinds[$kind]); + } + } + + /** @return array}> */ + public function fileKinds(): array + { + $fileKinds = collect(FileKind::cases()) + ->filter(fn (FileKind $kind) => $kind !== FileKind::Unknown) + ->mapWithKeys(fn (FileKind $kind) => [$kind->value => $kind->toArray()]) + ->all(); + + $fileKinds = Arr::merge($fileKinds, $this->generalConfig->extraFileKinds); + + foreach ($this->fileKinds as $kind => $definition) { + $fileKinds = Arr::merge($fileKinds, [ + $kind => $definition instanceof Closure ? app()->call($definition) : $definition, + ]); + } + + $fileKinds = array_diff_key($fileKinds, $this->removedFileKinds); + + foreach ($fileKinds as $kind => $definition) { + if (! isset($definition['label'], $definition['extensions']) || + ! is_string($definition['label']) || + ! is_array($definition['extensions']) || + array_any($definition['extensions'], fn (mixed $extension) => ! is_string($extension)) + ) { + throw new InvalidArgumentException("Invalid file kind definition [$kind]."); + } + } + + return Arr::sort($fileKinds, 'label'); + } +} diff --git a/src/Asset/AssetsHelper.php b/src/Asset/AssetsHelper.php index 0efbc395603..bf4999ab7a6 100644 --- a/src/Asset/AssetsHelper.php +++ b/src/Asset/AssetsHelper.php @@ -9,7 +9,6 @@ use CraftCms\Cms\Asset\Data\VolumeFolder; use CraftCms\Cms\Asset\Elements\Asset; use CraftCms\Cms\Asset\Enums\FileKind; -use CraftCms\Cms\Asset\Events\AssetFileKindsResolving; use CraftCms\Cms\Asset\Events\SetAssetFilename; use CraftCms\Cms\Cms; use CraftCms\Cms\Element\Contracts\ElementInterface; @@ -18,7 +17,6 @@ use CraftCms\Cms\Filesystem\Exceptions\FilesystemException; use CraftCms\Cms\Filesystem\Exceptions\InvalidSubpathException; use CraftCms\Cms\Filesystem\Filesystems\Temp; -use CraftCms\Cms\Support\Arr; use CraftCms\Cms\Support\Env; use CraftCms\Cms\Support\Facades\Filesystems; use CraftCms\Cms\Support\Facades\Folders; @@ -43,20 +41,6 @@ class AssetsHelper { public const string INDEX_SKIP_ITEMS_PATTERN = '/.*(Thumbs\.db|__MACOSX|__MACOSX\/|__MACOSX\/.*|\.DS_STORE)$/i'; - /** - * @var array|null Supported file kinds - * - * @see getFileKinds() - */ - private static ?array $_fileKinds; - - /** - * @var array|null Allowed file kinds - * - * @see getAllowedFileKinds() - */ - private static ?array $_allowedFileKinds; - /** * Get a temporary file path. * @@ -319,7 +303,7 @@ public static function fileTransferList(array $assets, array $folderIdChanges): */ public static function getFileKinds(): array { - return self::fileKinds(); + return app(AssetFileKinds::class)->fileKinds(); } /** @@ -329,24 +313,20 @@ public static function getFileKinds(): array */ public static function getAllowedFileKinds(): array { - if (isset(self::$_allowedFileKinds)) { - return self::$_allowedFileKinds; - } - - self::$_allowedFileKinds = []; + $allowedFileKinds = []; $allowedExtensions = array_flip(Cms::config()->allowedFileExtensions); foreach (static::getFileKinds() as $kind => $info) { foreach ($info['extensions'] as $extension) { if (isset($allowedExtensions[$extension])) { - self::$_allowedFileKinds[$kind] = $info; + $allowedFileKinds[$kind] = $info; continue 2; } } } - return self::$_allowedFileKinds; + return $allowedFileKinds; } /** @@ -354,7 +334,7 @@ public static function getAllowedFileKinds(): array */ public static function getFileKindLabel(string $kind): string { - return self::fileKinds()[$kind]['label'] ?? FileKind::Unknown->value; + return self::getFileKinds()[$kind]['label'] ?? FileKind::Unknown->value; } /** @@ -394,34 +374,6 @@ public static function parseFileLocation(string $location): array return [(int) $folderId, $filename]; } - /** - * Builds the internal file kinds array, if it hasn't been built already. - */ - private static function fileKinds(): array - { - if (isset(self::$_fileKinds)) { - return self::$_fileKinds; - } - - self::$_fileKinds = collect(FileKind::cases()) - ->filter(fn (FileKind $kind) => $kind !== FileKind::Unknown) - ->mapWithKeys(fn (FileKind $kind) => [$kind->value => $kind->toArray()]) - ->all(); - - // Merge with the extraFileKinds setting - self::$_fileKinds = Arr::merge(self::$_fileKinds, Cms::config()->extraFileKinds); - - event($event = new AssetFileKindsResolving(self::$_fileKinds)); - - return self::$_fileKinds = Arr::sort($event->fileKinds, 'label'); - } - - public static function clear(): void - { - self::$_fileKinds = null; - self::$_allowedFileKinds = null; - } - /** * Returns the maximum allowed upload size in bytes per all config settings combined. */ diff --git a/src/Asset/Events/AssetFileKindsResolving.php b/src/Asset/Events/AssetFileKindsResolving.php deleted file mode 100644 index faa70e10f91..00000000000 --- a/src/Asset/Events/AssetFileKindsResolving.php +++ /dev/null @@ -1,16 +0,0 @@ - */ - public array $fileKinds, - ) {} -} diff --git a/tests/Unit/Asset/AssetsHelperTest.php b/tests/Unit/Asset/AssetsHelperTest.php index a0dacb6ae57..bcd0d1ba0bc 100644 --- a/tests/Unit/Asset/AssetsHelperTest.php +++ b/tests/Unit/Asset/AssetsHelperTest.php @@ -2,6 +2,7 @@ declare(strict_types=1); +use CraftCms\Cms\Asset\AssetFileKinds; use CraftCms\Cms\Asset\AssetsHelper; use CraftCms\Cms\Asset\Enums\FileKind; use CraftCms\Cms\Asset\Events\SetAssetFilename; @@ -399,8 +400,6 @@ }); test('it merges in extraFileKinds', function () { - AssetsHelper::clear(); - Cms::config()->extraFileKinds = [ 'stylesheet' => [ 'label' => 'Stylesheet', @@ -410,6 +409,17 @@ expect(AssetsHelper::getFileKinds())->toHaveKey('stylesheet'); }); + + test('it includes registered file kinds', function () { + app(AssetFileKinds::class)->register('stylesheet', [ + 'label' => 'Stylesheet', + 'extensions' => ['css'], + ]); + + expect(AssetsHelper::getFileKinds()) + ->toHaveKey('stylesheet') + ->and(AssetsHelper::getFileKinds()['stylesheet']['extensions'])->toBe(['css']); + }); }); describe('getAllowedFileKinds', function () { From c59994a26bb211529cd07065a55805e68da650ee Mon Sep 17 00:00:00 2001 From: Rias Date: Sat, 25 Jul 2026 09:04:27 +0200 Subject: [PATCH 04/21] Register element types explicitly --- src/Element/Elements.php | 45 +++----------- src/Element/Events/ElementTypesResolving.php | 15 ----- src/Plugin/Concerns/HasElementTypes.php | 11 +--- tests/Feature/Element/ElementTypesTest.php | 43 +++---------- .../Elements/DeleteElementsControllerTest.php | 12 ++-- .../Elements/SaveElementControllerTest.php | 13 ++-- ...SaveElementIndexElementsControllerTest.php | 6 +- .../Elements/SearchControllerTest.php | 6 +- .../Http/Controllers/MatrixControllerTest.php | 3 +- .../Plugin/Concerns/HasElementTypesTest.php | 19 +----- yii2-adapter/legacy/services/Elements.php | 61 ++++++++++++++----- 11 files changed, 94 insertions(+), 140 deletions(-) delete mode 100644 src/Element/Events/ElementTypesResolving.php diff --git a/src/Element/Elements.php b/src/Element/Elements.php index ae4bab0d021..083767e8043 100644 --- a/src/Element/Elements.php +++ b/src/Element/Elements.php @@ -4,13 +4,10 @@ namespace CraftCms\Cms\Element; -use CraftCms\Cms\Address\Elements\Address; -use CraftCms\Cms\Asset\Elements\Asset; use CraftCms\Cms\Component\ComponentHelper; use CraftCms\Cms\Database\Table; use CraftCms\Cms\Element\Contracts\ElementInterface; use CraftCms\Cms\Element\Data\EagerLoadPlan; -use CraftCms\Cms\Element\Events\ElementTypesResolving; use CraftCms\Cms\Element\Exceptions\InvalidElementException; use CraftCms\Cms\Element\Exceptions\UnsupportedSiteException; use CraftCms\Cms\Element\Operations\ElementCanonicalChanges; @@ -39,11 +36,6 @@ class Elements { - /** - * @var string[] - */ - private array $elementTypesByRefHandle = []; - public const string REF_TAG_PATTERN = '/ \{ # Tags always begin with a `{` (?P[\w\\\\]+) # Ref handle or element type class @@ -56,6 +48,7 @@ class Elements public function __construct( private readonly ElementPlaceholders $placeholders, + private readonly ElementTypes $elementTypes, ) {} /** @@ -116,16 +109,7 @@ public function getElementTypesByIds(array $elementIds): array */ public function getAllElementTypes(): array { - $elementTypes = [ - Address::class, - Asset::class, - Entry::class, - User::class, - ]; - - event($event = new ElementTypesResolving($elementTypes)); - - return $event->types; + return $this->elementTypes->types()->all(); } /** @@ -136,18 +120,14 @@ public function getAllElementTypes(): array */ public function getElementTypeByRefHandle(string $refHandle): ?string { - if (! isset($this->elementTypesByRefHandle[$refHandle])) { - $class = $this->elementTypeByRefHandle($refHandle); - - // Special cases for categories/tags/globals, if they've been removed - if ($class === false && in_array($refHandle, ['category', 'tag', 'globalset'])) { - $class = Entry::class; - } + $class = $this->elementTypeByRefHandle($refHandle); - $this->elementTypesByRefHandle[$refHandle] = $class; + // Special cases for categories/tags/globals, if they've been removed + if ($class === false && in_array($refHandle, ['category', 'tag', 'globalset'])) { + $class = Entry::class; } - return $this->elementTypesByRefHandle[$refHandle] ?: null; + return $class ?: null; } private function elementTypeByRefHandle(string $refHandle): string|false @@ -156,16 +136,7 @@ private function elementTypeByRefHandle(string $refHandle): string|false return $refHandle; } - foreach ($this->getAllElementTypes() as $class) { - if ( - ($elementRefHandle = $class::refHandle()) !== null && - strcasecmp($elementRefHandle, $refHandle) === 0 - ) { - return $class; - } - } - - return false; + return $this->elementTypes->typeByRefHandle($refHandle) ?? false; } /** diff --git a/src/Element/Events/ElementTypesResolving.php b/src/Element/Events/ElementTypesResolving.php deleted file mode 100644 index 10cdbfa572b..00000000000 --- a/src/Element/Events/ElementTypesResolving.php +++ /dev/null @@ -1,15 +0,0 @@ -[] */ - public array $types, - ) {} -} diff --git a/src/Plugin/Concerns/HasElementTypes.php b/src/Plugin/Concerns/HasElementTypes.php index 965972c16ae..cad7da5d29c 100644 --- a/src/Plugin/Concerns/HasElementTypes.php +++ b/src/Plugin/Concerns/HasElementTypes.php @@ -5,9 +5,8 @@ namespace CraftCms\Cms\Plugin\Concerns; use CraftCms\Cms\Element\Element; -use CraftCms\Cms\Element\Events\ElementTypesResolving; +use CraftCms\Cms\Element\ElementTypes; use CraftCms\Cms\Plugin\Plugin; -use Illuminate\Support\Facades\Event; /** * @mixin Plugin @@ -25,12 +24,6 @@ trait HasElementTypes public function bootHasElementTypes(): void { - if (! $this->elementTypes) { - return; - } - - Event::listen(function (ElementTypesResolving $event) { - array_push($event->types, ...$this->elementTypes); - }); + $this->app->make(ElementTypes::class)->register(...$this->elementTypes); } } diff --git a/tests/Feature/Element/ElementTypesTest.php b/tests/Feature/Element/ElementTypesTest.php index e3214ecedca..846714b43e4 100644 --- a/tests/Feature/Element/ElementTypesTest.php +++ b/tests/Feature/Element/ElementTypesTest.php @@ -2,16 +2,14 @@ declare(strict_types=1); -use CraftCms\Cms\Address\Elements\Address; -use CraftCms\Cms\Asset\Elements\Asset; use CraftCms\Cms\Element\Element as BaseElement; use CraftCms\Cms\Element\Elements; -use CraftCms\Cms\Element\Events\ElementTypesResolving; +use CraftCms\Cms\Element\ElementTypes; +use CraftCms\Cms\Element\Models\Element as ElementModel; use CraftCms\Cms\Entry\Elements\Entry as EntryElement; use CraftCms\Cms\Entry\Models\Entry as EntryModel; use CraftCms\Cms\User\Elements\User as UserElement; use CraftCms\Cms\User\Models\User as UserModel; -use Illuminate\Support\Facades\Event; test('returns element types by id uid and key', function () { $entry = EntryModel::factory()->createElement(); @@ -26,10 +24,11 @@ test('returns null when an element type cannot be found', function () { $elementTypes = app(Elements::class); + $missingId = ((int) ElementModel::withTrashed()->max('id')) + 1; - expect($elementTypes->getElementTypeById(9999))->toBeNull() + expect($elementTypes->getElementTypeById($missingId))->toBeNull() ->and($elementTypes->getElementTypeByUid('missing-uid'))->toBeNull() - ->and($elementTypes->getElementTypeByKey('id', 9999))->toBeNull() + ->and($elementTypes->getElementTypeByKey('id', $missingId))->toBeNull() ->and($elementTypes->getElementTypeByKey('uid', 'missing-uid'))->toBeNull(); }); @@ -51,23 +50,6 @@ ]); }); -test('returns all built-in element types and registered element types', function () { - Event::listen(ElementTypesResolving::class, function (ElementTypesResolving $event) { - $event->types[] = TestRegisteredElementType::class; - }); - - $types = (app(Elements::class))->getAllElementTypes(); - - expect($types)->toHaveCount(5) - ->toContain( - Address::class, - Asset::class, - EntryElement::class, - UserElement::class, - TestRegisteredElementType::class, - ); -}); - test('matches ref handles case-insensitively', function () { expect((app(Elements::class))->getElementTypeByRefHandle('UsEr'))->toBe(UserElement::class); }); @@ -89,22 +71,17 @@ expect((app(Elements::class))->getElementTypeByRefHandle('missing-ref-handle'))->toBeNull(); }); -test('caches resolved ref handles', function () { - $listenerCalls = 0; - - Event::listen(ElementTypesResolving::class, function (ElementTypesResolving $event) use (&$listenerCalls) { - $listenerCalls++; - $event->types[] = TestRegisteredElementType::class; - }); +test('reflects element type registration changes in resolved ref handles', function () { + $registry = app(ElementTypes::class); + $registry->register(TestRegisteredElementType::class); $elementTypes = app(Elements::class); expect($elementTypes->getElementTypeByRefHandle('test-registered-element'))->toBe(TestRegisteredElementType::class); - Event::forget(ElementTypesResolving::class); + $registry->remove(TestRegisteredElementType::class); - expect($elementTypes->getElementTypeByRefHandle('test-registered-element'))->toBe(TestRegisteredElementType::class) - ->and($listenerCalls)->toBe(1); + expect($elementTypes->getElementTypeByRefHandle('test-registered-element'))->toBeNull(); }); class TestRegisteredElementType extends BaseElement diff --git a/tests/Feature/Http/Controllers/Elements/DeleteElementsControllerTest.php b/tests/Feature/Http/Controllers/Elements/DeleteElementsControllerTest.php index 8e3fb81b885..29d4a21cbfd 100644 --- a/tests/Feature/Http/Controllers/Elements/DeleteElementsControllerTest.php +++ b/tests/Feature/Http/Controllers/Elements/DeleteElementsControllerTest.php @@ -7,6 +7,7 @@ use CraftCms\Cms\Element\DeletionBlockers\BaseDeletionBlocker; use CraftCms\Cms\Element\ElementCollection; use CraftCms\Cms\Element\Elements as ElementsService; +use CraftCms\Cms\Element\ElementTypes; use CraftCms\Cms\Element\Events\DefineDeletionBlockers; use CraftCms\Cms\Element\Events\ElementDeleting; use CraftCms\Cms\Element\Jobs\ReplaceRelations; @@ -139,11 +140,14 @@ ->all(); app()->bind(ElementsService::class, function () use (&$deletedIds) { - return new class(app(ElementPlaceholders::class), $deletedIds) extends ElementsService + return new class(app(ElementPlaceholders::class), app(ElementTypes::class), $deletedIds) extends ElementsService { - public function __construct(ElementPlaceholders $placeholders, private array &$deletedIds) - { - parent::__construct($placeholders); + public function __construct( + ElementPlaceholders $placeholders, + ElementTypes $elementTypes, + private array &$deletedIds, + ) { + parent::__construct($placeholders, $elementTypes); } public function deleteElement(ElementInterface $element, bool $hard = false): bool diff --git a/tests/Feature/Http/Controllers/Elements/SaveElementControllerTest.php b/tests/Feature/Http/Controllers/Elements/SaveElementControllerTest.php index 3de8ca1927d..79bbb67eeaf 100644 --- a/tests/Feature/Http/Controllers/Elements/SaveElementControllerTest.php +++ b/tests/Feature/Http/Controllers/Elements/SaveElementControllerTest.php @@ -7,6 +7,7 @@ use CraftCms\Cms\Element\Contracts\NestedElementInterface; use CraftCms\Cms\Element\Drafts; use CraftCms\Cms\Element\Elements as ElementsService; +use CraftCms\Cms\Element\ElementTypes; use CraftCms\Cms\Element\Enums\ElementActivityType; use CraftCms\Cms\Element\Exceptions\UnsupportedSiteException; use CraftCms\Cms\Element\Operations\ElementPlaceholders; @@ -303,7 +304,7 @@ function createSaveElementMatrixFixture(): array ]); $entry->errors()->add('title', 'Title is invalid.'); - app()->instance(ElementsService::class, new class(app(ElementPlaceholders::class)) extends ElementsService + app()->instance(ElementsService::class, new class(app(ElementPlaceholders::class), app(ElementTypes::class)) extends ElementsService { public function saveElement( ElementInterface $element, @@ -342,7 +343,7 @@ public function saveElement( 'slug' => 'canonical-title', ]); - app()->instance(ElementsService::class, new class(app(ElementPlaceholders::class)) extends ElementsService + app()->instance(ElementsService::class, new class(app(ElementPlaceholders::class), app(ElementTypes::class)) extends ElementsService { public function saveElement( ElementInterface $element, @@ -382,7 +383,7 @@ public function saveElement( app(Drafts::class)->createDraft($entry, auth()->id(), provisional: true); actingAs(UserModel::findOrFail(auth()->id())); - $elements = new class(app(ElementPlaceholders::class)) extends ElementsService + $elements = new class(app(ElementPlaceholders::class), app(ElementTypes::class)) extends ElementsService { public ?bool $capturedCrossSiteValidate = null; @@ -451,7 +452,7 @@ public function saveElement( it('marks nested elements to update their owner search index before saving', function () { $fixture = createSaveElementMatrixFixture(); - $elements = new class(app(ElementPlaceholders::class)) extends ElementsService + $elements = new class(app(ElementPlaceholders::class), app(ElementTypes::class)) extends ElementsService { public bool $capturedNestedOwnerIndexFlag = false; @@ -614,7 +615,7 @@ public function saveElement( ->where('id', $fixture['draftBlock']->id) ->update(['primaryOwnerId' => $fixture['owner']->id]); - $elements = new class(app(ElementPlaceholders::class)) extends ElementsService + $elements = new class(app(ElementPlaceholders::class), app(ElementTypes::class)) extends ElementsService { public int $saveCalls = 0; @@ -666,7 +667,7 @@ public function saveElement( ->where('id', $fixture['draftBlock']->id) ->update(['primaryOwnerId' => $fixture['owner']->id]); - $elements = new class(app(ElementPlaceholders::class)) extends ElementsService + $elements = new class(app(ElementPlaceholders::class), app(ElementTypes::class)) extends ElementsService { public int $saveCalls = 0; diff --git a/tests/Feature/Http/Controllers/Elements/SaveElementIndexElementsControllerTest.php b/tests/Feature/Http/Controllers/Elements/SaveElementIndexElementsControllerTest.php index 6503b4891c5..503c9ff267b 100644 --- a/tests/Feature/Http/Controllers/Elements/SaveElementIndexElementsControllerTest.php +++ b/tests/Feature/Http/Controllers/Elements/SaveElementIndexElementsControllerTest.php @@ -6,6 +6,7 @@ use CraftCms\Cms\Database\Table; use CraftCms\Cms\Element\Contracts\ElementInterface; use CraftCms\Cms\Element\Elements; +use CraftCms\Cms\Element\ElementTypes; use CraftCms\Cms\Element\Operations\ElementPlaceholders; use CraftCms\Cms\Entry\Elements\Entry; use CraftCms\Cms\Entry\Models\Entry as EntryModel; @@ -181,13 +182,14 @@ 'title' => 'Second Before Save', ]); - app()->instance(Elements::class, new class(app(ElementPlaceholders::class), $secondEntry->id) extends Elements + app()->instance(Elements::class, new class(app(ElementPlaceholders::class), app(ElementTypes::class), $secondEntry->id) extends Elements { public function __construct( ElementPlaceholders $placeholders, + ElementTypes $elementTypes, private readonly int $failingElementId, ) { - parent::__construct($placeholders); + parent::__construct($placeholders, $elementTypes); } public function saveElement( diff --git a/tests/Feature/Http/Controllers/Elements/SearchControllerTest.php b/tests/Feature/Http/Controllers/Elements/SearchControllerTest.php index 645fef9c01e..3a11ce22e0a 100644 --- a/tests/Feature/Http/Controllers/Elements/SearchControllerTest.php +++ b/tests/Feature/Http/Controllers/Elements/SearchControllerTest.php @@ -9,6 +9,7 @@ use CraftCms\Cms\Element\Conditions\IdConditionRule; use CraftCms\Cms\Element\Contracts\ElementInterface; use CraftCms\Cms\Element\Elements; +use CraftCms\Cms\Element\ElementTypes; use CraftCms\Cms\Element\Operations\ElementPlaceholders; use CraftCms\Cms\Element\Queries\Contracts\ElementQueryInterface; use CraftCms\Cms\Entry\Elements\Entry; @@ -295,7 +296,7 @@ public function modifyQuery(ElementQueryInterface $query): void } }; - $elements = new class(app(ElementPlaceholders::class), $referenceEntry) extends Elements + $elements = new class(app(ElementPlaceholders::class), app(ElementTypes::class), $referenceEntry) extends Elements { public ?int $requestedElementId = null; @@ -305,9 +306,10 @@ public function modifyQuery(ElementQueryInterface $query): void public function __construct( ElementPlaceholders $placeholders, + ElementTypes $elementTypes, private readonly Entry $referenceEntry, ) { - parent::__construct($placeholders); + parent::__construct($placeholders, $elementTypes); } public function getElementById( diff --git a/tests/Feature/Http/Controllers/MatrixControllerTest.php b/tests/Feature/Http/Controllers/MatrixControllerTest.php index 3bd5b5c0771..b33e94d5337 100644 --- a/tests/Feature/Http/Controllers/MatrixControllerTest.php +++ b/tests/Feature/Http/Controllers/MatrixControllerTest.php @@ -5,6 +5,7 @@ use CraftCms\Cms\Element\Contracts\ElementInterface; use CraftCms\Cms\Element\Drafts; use CraftCms\Cms\Element\Elements; +use CraftCms\Cms\Element\ElementTypes; use CraftCms\Cms\Element\Exceptions\InvalidElementException; use CraftCms\Cms\Element\Operations\ElementPlaceholders; use CraftCms\Cms\Entry\Elements\Entry as EntryElement; @@ -362,7 +363,7 @@ public function saveElementAsDraft(ElementInterface $element, ?int $creatorId = $this->fixture = refreshMatrixControllerFixture($this->fixture); $source = matrixControllerNestedEntries($this->fixture)->sole(); - app()->instance(Elements::class, new class(app(ElementPlaceholders::class)) extends Elements + app()->instance(Elements::class, new class(app(ElementPlaceholders::class), app(ElementTypes::class)) extends Elements { public function duplicateElement( ElementInterface $element, diff --git a/tests/Unit/Plugin/Concerns/HasElementTypesTest.php b/tests/Unit/Plugin/Concerns/HasElementTypesTest.php index 062fda5e3ec..0b87ff74314 100644 --- a/tests/Unit/Plugin/Concerns/HasElementTypesTest.php +++ b/tests/Unit/Plugin/Concerns/HasElementTypesTest.php @@ -3,7 +3,7 @@ declare(strict_types=1); use CraftCms\Cms\Element\Element; -use CraftCms\Cms\Element\Events\ElementTypesResolving; +use CraftCms\Cms\Element\Elements; use CraftCms\Cms\Tests\TestClasses\TestPlugin\src\TestPlugin; it('registers configured element types', function () { @@ -15,22 +15,7 @@ $plugin->setElementTypes([TestPluginElementType::class]); $plugin->bootHasElementTypes(); - event($event = new ElementTypesResolving([])); - - expect($event->types)->toContain(TestPluginElementType::class); -}); - -it('does not register element type listeners when none are configured', function () { - $plugin = TestPlugin::create([ - 'handle' => 'test-plugin', - 'name' => 'Test Plugin', - ]); - - $plugin->bootHasElementTypes(); - - event($event = new ElementTypesResolving([])); - - expect($event->types)->toBe([]); + expect(app(Elements::class)->getAllElementTypes())->toContain(TestPluginElementType::class); }); abstract class TestPluginElementType extends Element {} diff --git a/yii2-adapter/legacy/services/Elements.php b/yii2-adapter/legacy/services/Elements.php index 38dbff7367e..cfd5e87185c 100644 --- a/yii2-adapter/legacy/services/Elements.php +++ b/yii2-adapter/legacy/services/Elements.php @@ -38,6 +38,7 @@ use CraftCms\Cms\Element\ElementCaches; use CraftCms\Cms\Element\ElementCaches as ElementCachesService; use CraftCms\Cms\Element\ElementHelper; +use CraftCms\Cms\Element\ElementTypes; use CraftCms\Cms\Element\Enums\ElementActivityType; use CraftCms\Cms\Element\Events\CanonicalChangesMerged; use CraftCms\Cms\Element\Events\CanonicalChangesMerging; @@ -65,7 +66,6 @@ use CraftCms\Cms\Element\Events\ElementsPropagating; use CraftCms\Cms\Element\Events\ElementsResaved; use CraftCms\Cms\Element\Events\ElementsResaving; -use CraftCms\Cms\Element\Events\ElementTypesResolving; use CraftCms\Cms\Element\Events\SetElementUri; use CraftCms\Cms\Element\Exceptions\InvalidElementException; use CraftCms\Cms\Element\Exceptions\UnsupportedSiteException; @@ -1408,7 +1408,7 @@ private static function activityToLegacyActivity(ElementActivityData $activity): */ public function getAllElementTypes(): array { - return ElementsFacade::getAllElementTypes(); + return array_map(self::legacyElementType(...), ElementsFacade::getAllElementTypes()); } // Element Actions & Exporters @@ -1738,6 +1738,51 @@ private static function _authCheck(ElementInterface $element, User $user, string return $event->authorized; } + /** @internal */ + public static function finalizeRegistrationEvents(): void + { + $service = Craft::$app->getElements(); + + if (!$service->hasEventHandlers(self::EVENT_REGISTER_ELEMENT_TYPES)) { + return; + } + + $registry = app(ElementTypes::class); + $types = $registry->types(); + $event = new RegisterComponentTypesEvent([ + 'types' => $types->map(self::legacyElementType(...))->all(), + ]); + $service->trigger(self::EVENT_REGISTER_ELEMENT_TYPES, $event); + $transformedTypes = collect($event->types)->map(self::modernElementType(...)); + + $registry->remove(...$types->diff($transformedTypes)); + $registry->register(...$transformedTypes->diff($types)); + } + + /** @param class-string $type */ + private static function legacyElementType(string $type): string + { + return match ($type) { + \CraftCms\Cms\Address\Elements\Address::class => \craft\elements\Address::class, + \CraftCms\Cms\Asset\Elements\Asset::class => \craft\elements\Asset::class, + \CraftCms\Cms\Entry\Elements\Entry::class => \craft\elements\Entry::class, + \CraftCms\Cms\User\Elements\User::class => \craft\elements\User::class, + default => $type, + }; + } + + /** @param class-string $type */ + private static function modernElementType(string $type): string + { + return match ($type) { + \craft\elements\Address::class => \CraftCms\Cms\Address\Elements\Address::class, + \craft\elements\Asset::class => \CraftCms\Cms\Asset\Elements\Asset::class, + \craft\elements\Entry::class => \CraftCms\Cms\Entry\Elements\Entry::class, + \craft\elements\User::class => \CraftCms\Cms\User\Elements\User::class, + default => $type, + }; + } + public static function registerEvents(): void { Event::listen(function(BulkOpStarting $event) { @@ -1958,18 +2003,6 @@ public static function registerEvents(): void ])); }); - Event::listen(function(ElementTypesResolving $event) { - if (!Craft::$app->getElements()->hasEventHandlers(self::EVENT_REGISTER_ELEMENT_TYPES)) { - return; - } - - Craft::$app->getElements()->trigger(self::EVENT_REGISTER_ELEMENT_TYPES, $yiiEvent = new RegisterComponentTypesEvent([ - 'types' => $event->types, - ])); - - $event->types = $yiiEvent->types; - }); - Event::listen(function(ElementsEagerLoading $event) { if (!Craft::$app->getElements()->hasEventHandlers(self::EVENT_BEFORE_EAGER_LOAD_ELEMENTS)) { return; From 2981b170cba07d06054886e72ee476364cce94a5 Mon Sep 17 00:00:00 2001 From: Rias Date: Sat, 25 Jul 2026 09:04:45 +0200 Subject: [PATCH 05/21] Register field and link types explicitly --- src/Field/Events/FieldTypesResolving.php | 33 ---- src/Field/Events/LinkTypesResolving.php | 18 -- .../Events/NestedEntryFieldTypesResolving.php | 21 --- src/Field/Fields.php | 51 +---- src/Field/Link.php | 35 +--- src/Plugin/Concerns/HasFieldtypes.php | 11 +- tests/Feature/Field/FieldsTest.php | 48 +---- tests/Unit/Field/LinkTest.php | 28 +++ .../Plugin/Concerns/HasFieldtypesTest.php | 32 +--- .../Field/Concerns/LegacyFieldConstants.php | 31 +-- yii2-adapter/legacy/services/Fields.php | 178 ++++++++---------- 11 files changed, 143 insertions(+), 343 deletions(-) delete mode 100644 src/Field/Events/FieldTypesResolving.php delete mode 100644 src/Field/Events/LinkTypesResolving.php delete mode 100644 src/Field/Events/NestedEntryFieldTypesResolving.php create mode 100644 tests/Unit/Field/LinkTest.php diff --git a/src/Field/Events/FieldTypesResolving.php b/src/Field/Events/FieldTypesResolving.php deleted file mode 100644 index 8a2095cf8fd..00000000000 --- a/src/Field/Events/FieldTypesResolving.php +++ /dev/null @@ -1,33 +0,0 @@ -types->add(MyFieldType::class); - * }); - * ``` - */ -class FieldTypesResolving -{ - public function __construct( - /** @var Collection> */ - public Collection $types, - ) {} -} diff --git a/src/Field/Events/LinkTypesResolving.php b/src/Field/Events/LinkTypesResolving.php deleted file mode 100644 index f536e1d82ca..00000000000 --- a/src/Field/Events/LinkTypesResolving.php +++ /dev/null @@ -1,18 +0,0 @@ -> */ - public Collection $types, - ) {} -} diff --git a/src/Field/Fields.php b/src/Field/Fields.php index a0a0390b74f..98c73712932 100644 --- a/src/Field/Fields.php +++ b/src/Field/Fields.php @@ -15,13 +15,10 @@ use CraftCms\Cms\Database\Table; use CraftCms\Cms\Element\Contracts\ElementInterface; use CraftCms\Cms\Element\ElementCaches; -use CraftCms\Cms\Field\Addresses as AddressesField; -use CraftCms\Cms\Field\Assets as AssetsField; use CraftCms\Cms\Field\Contracts\ElementContainerFieldInterface; use CraftCms\Cms\Field\Contracts\FieldInterface; use CraftCms\Cms\Field\Contracts\MergeableFieldInterface; use CraftCms\Cms\Field\Data\FieldMergeResult; -use CraftCms\Cms\Field\Entries as EntriesField; use CraftCms\Cms\Field\Enums\TranslationMethod; use CraftCms\Cms\Field\Events\CompatibleFieldTypesResolving; use CraftCms\Cms\Field\Events\FieldCachesInvalidated; @@ -34,11 +31,6 @@ use CraftCms\Cms\Field\Events\FieldSaveApplying; use CraftCms\Cms\Field\Events\FieldSaved; use CraftCms\Cms\Field\Events\FieldSaving; -use CraftCms\Cms\Field\Events\FieldTypesResolving; -use CraftCms\Cms\Field\Events\NestedEntryFieldTypesResolving; -use CraftCms\Cms\Field\Matrix as MatrixField; -use CraftCms\Cms\Field\Table as TableField; -use CraftCms\Cms\Field\Users as UsersField; use CraftCms\Cms\FieldLayout\FieldLayout; use CraftCms\Cms\FieldLayout\FieldLayoutElement; use CraftCms\Cms\FieldLayout\LayoutElements\BaseField; @@ -104,6 +96,8 @@ class Fields public function __construct( private readonly ElementCaches $elementCaches, + private readonly FieldTypes $fieldTypes, + private readonly NestedEntryFieldTypes $nestedEntryFieldTypes, ) {} // Handle Registry @@ -222,38 +216,7 @@ public function setFieldContext(string $fieldContext): void */ public function getAllFieldTypes(): Collection { - $fieldTypes = collect([ - AddressesField::class, - AssetsField::class, - ButtonGroup::class, - Checkboxes::class, - Color::class, - ContentBlock::class, - Country::class, - Date::class, - Dropdown::class, - Email::class, - EntriesField::class, - Icon::class, - Json::class, - Lightswitch::class, - Link::class, - Markdown::class, - MatrixField::class, - Money::class, - MultiSelect::class, - Number::class, - PlainText::class, - RadioButtons::class, - Range::class, - TableField::class, - Time::class, - UsersField::class, - ]); - - event($event = new FieldTypesResolving($fieldTypes)); - - return $event->types; + return $this->fieldTypes->types(); } /** @@ -341,13 +304,7 @@ public function areFieldTypesCompatible(string $fieldA, string $fieldB): bool */ public function getNestedEntryFieldTypes(): Collection { - $fieldTypes = collect([ - MatrixField::class, - ]); - - event($event = new NestedEntryFieldTypesResolving($fieldTypes)); - - return $event->types; + return $this->nestedEntryFieldTypes->types(); } /** diff --git a/src/Field/Link.php b/src/Field/Link.php index dc09343ddf1..b2811eb3ca7 100644 --- a/src/Field/Link.php +++ b/src/Field/Link.php @@ -20,14 +20,8 @@ use CraftCms\Cms\Field\Contracts\MergeableFieldInterface; use CraftCms\Cms\Field\Contracts\TracksReferencesFieldInterface; use CraftCms\Cms\Field\Data\LinkData; -use CraftCms\Cms\Field\Events\LinkTypesResolving; -use CraftCms\Cms\Field\LinkTypes\Asset; use CraftCms\Cms\Field\LinkTypes\BaseLinkType; use CraftCms\Cms\Field\LinkTypes\BaseTextLinkType; -use CraftCms\Cms\Field\LinkTypes\Email as EmailType; -use CraftCms\Cms\Field\LinkTypes\Entry; -use CraftCms\Cms\Field\LinkTypes\Phone; -use CraftCms\Cms\Field\LinkTypes\Sms; use CraftCms\Cms\Field\LinkTypes\Url as UrlType; use CraftCms\Cms\Gql\GqlEntityRegistry; use CraftCms\Cms\Gql\Types\Generators\LinkDataType; @@ -54,8 +48,6 @@ class Link extends Field implements CrossSiteCopyableFieldInterface, InlineEdita { use ProvidesLinkField; - private static array $_types; - #[Override] public static function displayName(): string { @@ -98,32 +90,7 @@ public static function dbType(): array */ public static function types(): array { - if (! isset(self::$_types)) { - /** @var class-string[] $types */ - $types = [ - Asset::class, - EmailType::class, - Entry::class, - Phone::class, - Sms::class, - ]; - - // Fire a registerLinkTypes event - event($event = new LinkTypesResolving($types)); - - $types = $event->types; - - // URL *has* to be there - /** @var class-string[] $types */ - $types[] = UrlType::class; - - self::$_types = array_combine( - array_map(fn (string $type) => $type::id(), $types), - $types, - ); - } - - return self::$_types; + return app(LinkTypes::class)->typesById()->all(); } /** diff --git a/src/Plugin/Concerns/HasFieldtypes.php b/src/Plugin/Concerns/HasFieldtypes.php index f486d1ecfe2..565be05f004 100644 --- a/src/Plugin/Concerns/HasFieldtypes.php +++ b/src/Plugin/Concerns/HasFieldtypes.php @@ -5,9 +5,8 @@ namespace CraftCms\Cms\Plugin\Concerns; use CraftCms\Cms\Field\Contracts\FieldInterface; -use CraftCms\Cms\Field\Events\FieldTypesResolving; +use CraftCms\Cms\Field\FieldTypes; use CraftCms\Cms\Plugin\Plugin; -use Illuminate\Support\Facades\Event; /** * @mixin Plugin @@ -25,12 +24,6 @@ trait HasFieldtypes public function bootHasFieldTypes(): void { - if (! $this->fieldTypes) { - return; - } - - Event::listen(FieldTypesResolving::class, function (FieldTypesResolving $event) { - $event->types->push(...$this->fieldTypes); - }); + $this->app->make(FieldTypes::class)->register(...$this->fieldTypes); } } diff --git a/tests/Feature/Field/FieldsTest.php b/tests/Feature/Field/FieldsTest.php index ab54f581417..dbf62afdcc1 100644 --- a/tests/Feature/Field/FieldsTest.php +++ b/tests/Feature/Field/FieldsTest.php @@ -10,13 +10,13 @@ use CraftCms\Cms\Field\Entries; use CraftCms\Cms\Field\Enums\TranslationMethod; use CraftCms\Cms\Field\Events\CompatibleFieldTypesResolving; -use CraftCms\Cms\Field\Events\FieldTypesResolving; -use CraftCms\Cms\Field\Events\NestedEntryFieldTypesResolving; use CraftCms\Cms\Field\Field; use CraftCms\Cms\Field\Fields; +use CraftCms\Cms\Field\FieldTypes; use CraftCms\Cms\Field\Matrix; use CraftCms\Cms\Field\MissingField; use CraftCms\Cms\Field\Models\Field as FieldModel; +use CraftCms\Cms\Field\NestedEntryFieldTypes; use CraftCms\Cms\Field\PlainText; use CraftCms\Cms\FieldLayout\FieldLayoutTab; use CraftCms\Cms\FieldLayout\LayoutElements\CustomField as CustomFieldElement; @@ -45,25 +45,6 @@ expect($this->fields->fieldContext)->toBe('foo'); }); -it('can get all field types', function () { - expect($this->fields->getAllFieldTypes())->not()->toBeEmpty(); - - foreach ($this->fields->getAllFieldTypes() as $type) { - expect($type)->toExtend(Field::class); - } -}); - -it('can add extra field types through an event', function () { - class CustomField extends Field {} - - Event::listen( - FieldTypesResolving::class, - fn (FieldTypesResolving $event) => $event->types->add(CustomField::class), - ); - - expect($this->fields->getAllFieldTypes())->toContain(CustomField::class); -}); - it('can get all field types that have content', function () { class CustomFieldWithoutContent extends Field { @@ -74,10 +55,7 @@ public static function dbType(): null } } - Event::listen( - FieldTypesResolving::class, - fn (FieldTypesResolving $event) => $event->types->add(CustomFieldWithoutContent::class), - ); + app(FieldTypes::class)->register(CustomFieldWithoutContent::class); expect($this->fields->getFieldTypesWithContent())->toContain(PlainText::class); expect($this->fields->getFieldTypesWithContent())->not()->toContain(CustomFieldWithoutContent::class); @@ -113,15 +91,12 @@ class CustomCompatibleField extends Field {} }); it('can get nested entry field types', function () { - class CustomNestedEntryField extends Field {} + class CustomNestedEntryField extends Matrix {} expect($this->fields->getNestedEntryFieldTypes())->toContain(Matrix::class); expect($this->fields->getNestedEntryFieldTypes())->not()->toContain(CustomNestedEntryField::class); - Event::listen( - NestedEntryFieldTypesResolving::class, - fn (NestedEntryFieldTypesResolving $event) => $event->types->add(CustomNestedEntryField::class), - ); + app(NestedEntryFieldTypes::class)->register(CustomNestedEntryField::class); expect($this->fields->getNestedEntryFieldTypes())->toContain(CustomNestedEntryField::class); }); @@ -180,16 +155,6 @@ class CustomNestedEntryField extends Field {} expect($this->fields->getAllFields())->not()->toBeEmpty(); }); -it('can get all fields with content', function () { - $this->fields->saveField($this->fields->createField([ - 'type' => PlainText::class, - 'name' => 'Plain Text', - 'handle' => 'plainText', - ])); - - expect($this->fields->getAllFields())->not()->toBeEmpty(); -}); - it('can get all fields with or without content', function () { $this->fields->saveField($this->fields->createField([ 'type' => PlainText::class, @@ -346,9 +311,6 @@ class CustomNestedEntryField extends Field {} }); it('rejects saving a content block field whose nested layout references itself', function () { - // On a first save the field doesn't exist in the database yet, so the - // self-reference can't be caught by resolving the nested field by UID — - // the raw fieldUid has to be compared against the field's own uid. $fieldUid = (string) Str::uuid(); $field = $this->fields->createField([ diff --git a/tests/Unit/Field/LinkTest.php b/tests/Unit/Field/LinkTest.php new file mode 100644 index 00000000000..3d146cc4d9e --- /dev/null +++ b/tests/Unit/Field/LinkTest.php @@ -0,0 +1,28 @@ +register(RegistryLink::class); + + $first = Link::types(); + $registry->remove(RegistryLink::class); + $second = Link::types(); + + expect($first)->toHaveKey('registry', RegistryLink::class) + ->and($second)->not()->toHaveKey('registry'); +}); + +class RegistryLink extends Url +{ + #[Override] + public static function id(): string + { + return 'registry'; + } +} diff --git a/tests/Unit/Plugin/Concerns/HasFieldtypesTest.php b/tests/Unit/Plugin/Concerns/HasFieldtypesTest.php index 87f7df085fe..c3235fdbd2d 100644 --- a/tests/Unit/Plugin/Concerns/HasFieldtypesTest.php +++ b/tests/Unit/Plugin/Concerns/HasFieldtypesTest.php @@ -2,46 +2,24 @@ declare(strict_types=1); -use CraftCms\Cms\Field\Events\FieldTypesResolving; -use CraftCms\Cms\Field\PlainText; +use CraftCms\Cms\Field\Field; +use CraftCms\Cms\Field\Fields; use CraftCms\Cms\Tests\TestClasses\TestPlugin\src\TestPlugin; -use Illuminate\Contracts\Events\Dispatcher; -use Illuminate\Support\Collection; beforeEach(function () { app()->forgetInstance(TestPlugin::class); }); -afterEach(function () { - app(Dispatcher::class)->forget(FieldTypesResolving::class); - app()->forgetInstance(TestPlugin::class); -}); - it('registers configured field types', function () { $plugin = TestPlugin::create([ 'handle' => 'test-plugin', 'name' => 'Test Plugin', ]); - $plugin->setFieldTypes([PlainText::class]); + $plugin->setFieldTypes([TestPluginFieldType::class]); $plugin->bootHasFieldTypes(); - $event = new FieldTypesResolving(new Collection); - event($event); - - expect($event->types->all())->toContain(PlainText::class); + expect(app(Fields::class)->getAllFieldTypes())->toContain(TestPluginFieldType::class); }); -it('does not register field type listeners when none are configured', function () { - $plugin = TestPlugin::create([ - 'handle' => 'test-plugin', - 'name' => 'Test Plugin', - ]); - - $plugin->bootHasFieldTypes(); - - $event = new FieldTypesResolving(new Collection); - event($event); - - expect($event->types->all())->toBe([]); -}); +abstract class TestPluginFieldType extends Field {} diff --git a/yii2-adapter/constants/Field/Concerns/LegacyFieldConstants.php b/yii2-adapter/constants/Field/Concerns/LegacyFieldConstants.php index dfa5c8968ca..2aa8340ba20 100644 --- a/yii2-adapter/constants/Field/Concerns/LegacyFieldConstants.php +++ b/yii2-adapter/constants/Field/Concerns/LegacyFieldConstants.php @@ -44,7 +44,8 @@ use CraftCms\Cms\Field\Events\FieldMergeFromCompleted; use CraftCms\Cms\Field\Events\FieldMergeIntoCompleted; use CraftCms\Cms\Field\Events\InputOptionsResolving; -use CraftCms\Cms\Field\Events\LinkTypesResolving; +use CraftCms\Cms\Field\LinkTypes; +use CraftCms\Cms\Field\LinkTypes\Url; use Illuminate\Database\Query\Builder; use Illuminate\Support\Facades\Event; use yii\base\InvalidConfigException; @@ -152,10 +153,15 @@ public static function registerEvents(): void self::assetsEvents(); self::optionsFieldEvents(); self::relationFieldEvents(); - self::linkEvents(); self::matrixEvents(); } + /** @internal */ + public static function finalizeRegistrationEvents(): void + { + self::linkEvents(); + } + /** * @event LocateUploadedFilesEvent The event that is triggered when identifying any uploaded files that * should be stored as assets and related by the field. @@ -253,19 +259,16 @@ private static function relationFieldEvents(): void private static function linkEvents(): void { - Event::listen(function(LinkTypesResolving $event) { - if (!YiiEvent::hasHandlers(Link::class, Link::EVENT_REGISTER_LINK_TYPES)) { - return; - } - - $yiiEvent = new RegisterComponentTypesEvent([ - 'types' => $event->types, - ]); - - YiiEvent::trigger(Link::class, Link::EVENT_REGISTER_LINK_TYPES, $yiiEvent); + if (!YiiEvent::hasHandlers(Link::class, Link::EVENT_REGISTER_LINK_TYPES)) { + return; + } - $event->types = $yiiEvent->types; - }); + $registry = app(LinkTypes::class); + $types = $registry->types()->reject(fn(string $type) => $type === Url::class); + $yiiEvent = new RegisterComponentTypesEvent(['types' => $types->all()]); + YiiEvent::trigger(Link::class, Link::EVENT_REGISTER_LINK_TYPES, $yiiEvent); + $registry->remove(...$types->diff($yiiEvent->types)); + $registry->register(...collect($yiiEvent->types)->diff($types)); } /** diff --git a/yii2-adapter/legacy/services/Fields.php b/yii2-adapter/legacy/services/Fields.php index 7ae32e01fd5..28a41474705 100644 --- a/yii2-adapter/legacy/services/Fields.php +++ b/yii2-adapter/legacy/services/Fields.php @@ -1,6 +1,8 @@ getFields()`]]. * * @author Pixel & Tonic, Inc. + * * @since 3.0.0 * @deprecated 6.0.0 use {@see \CraftCms\Cms\Field\Fields} instead. */ @@ -84,6 +88,7 @@ class Fields extends Component /** * @event DefineCompatibleFieldTypesEvent The event that is triggered when defining the compatible field types for a field. + * * @see getCompatibleFieldTypes() * @since 4.5.7 */ @@ -96,6 +101,7 @@ class Fields extends Component /** * @event ApplyFieldSaveEvent The event that is triggered before a field save is applied to the database. + * * @since 5.5.0 */ public const EVENT_BEFORE_APPLY_FIELD_SAVE = 'beforeApplyFieldSave'; @@ -112,6 +118,7 @@ class Fields extends Component /** * @event FieldEvent The event that is triggered before a field delete is applied to the database. + * * @since 3.1.0 */ public const EVENT_BEFORE_APPLY_FIELD_DELETE = 'beforeApplyFieldDelete'; @@ -143,6 +150,7 @@ class Fields extends Component /** * @var string The active field context + * * @since 5.0.0 */ public string $fieldContext { @@ -159,6 +167,7 @@ class Fields extends Component * Returns all available field type classes. * * @return string[] The available field type classes + * * @phpstan-return class-string[] */ public function getAllFieldTypes(): array @@ -170,6 +179,7 @@ public function getAllFieldTypes(): array * Returns all field types that have a column in the content table. * * @return string[] The field type classes + * * @phpstan-return class-string[] */ public function getFieldTypesWithContent(): array @@ -180,10 +190,10 @@ public function getFieldTypesWithContent(): array /** * Returns all field types whose column types are considered compatible with a given field. * - * @param FieldInterface $field The current field to base compatible fields on - * @param bool $includeCurrent Whether $field's class should be included - * + * @param FieldInterface $field The current field to base compatible fields on + * @param bool $includeCurrent Whether $field's class should be included * @return string[] The compatible field type classes + * * @phpstan-return class-string[] */ public function getCompatibleFieldTypes(FieldInterface $field, bool $includeCurrent = true): array @@ -194,10 +204,9 @@ public function getCompatibleFieldTypes(FieldInterface $field, bool $includeCurr /** * Returns whether the two given field types are considered compatible with each other. * - * @param class-string $fieldA - * @param class-string $fieldB + * @param class-string $fieldA + * @param class-string $fieldB * - * @return bool * @since 5.3.0 */ public function areFieldTypesCompatible(string $fieldA, string $fieldB): bool @@ -209,6 +218,7 @@ public function areFieldTypesCompatible(string $fieldA, string $fieldB): bool * Returns all field types which manage nested entries. * * @return string[] The field type classes which manage nested entries + * * @phpstan-return class-string[] */ public function getNestedEntryFieldTypes(): array @@ -220,7 +230,9 @@ public function getNestedEntryFieldTypes(): array * Returns all available relational field type classes. * * @return string[] The available relational field type classes + * * @phpstan-return class-string[] + * * @since 5.1.6 */ public function getRelationalFieldTypes(): array @@ -232,9 +244,11 @@ public function getRelationalFieldTypes(): array * Creates a field with a given config. * * @template T of FieldInterface - * @param class-string|array $config The field’s class name, or its config, with a `type` value and optionally a `settings` value + * + * @param class-string|array $config The field’s class name, or its config, with a `type` value and optionally a `settings` value * * @phpstan-param class-string|array{type:class-string,id?:int|string,uid?:string} $config + * * @return T The field */ public function createField(mixed $config): FieldInterface @@ -245,9 +259,8 @@ public function createField(mixed $config): FieldInterface /** * Returns all fields within a field context(s). * - * @param string|string[]|false|null $context The field context(s) to fetch fields from. Defaults to [[\craft\services\Fields::$fieldContext]]. - * Set to `false` to get all fields regardless of context. - * + * @param string|string[]|false|null $context The field context(s) to fetch fields from. Defaults to [[\craft\services\Fields::$fieldContext]]. + * Set to `false` to get all fields regardless of context. * @return FieldInterface[] The fields */ public function getAllFields(mixed $context = null): array @@ -258,9 +271,8 @@ public function getAllFields(mixed $context = null): array /** * Returns all fields that store content in the `elements_sites.content` table. * - * @param string|string[]|false|null $context The field context(s) to fetch fields from. Defaults to [[\craft\services\Fields::$fieldContext]]. - * Set to `false` to get all fields regardless of context. - * + * @param string|string[]|false|null $context The field context(s) to fetch fields from. Defaults to [[\craft\services\Fields::$fieldContext]]. + * Set to `false` to get all fields regardless of context. * @return FieldInterface[] The fields */ public function getFieldsWithContent(mixed $context = null): array @@ -271,10 +283,10 @@ public function getFieldsWithContent(mixed $context = null): array /** * Returns all fields that don’t store content in the `elements_sites.content` table. * - * @param string|string[]|false|null $context The field context(s) to fetch fields from. Defaults to [[\craft\services\Fields::$fieldContext]]. - * Set to `false` to get all fields regardless of context. - * + * @param string|string[]|false|null $context The field context(s) to fetch fields from. Defaults to [[\craft\services\Fields::$fieldContext]]. + * Set to `false` to get all fields regardless of context. * @return FieldInterface[] The fields + * * @since 4.3.2 */ public function getFieldsWithoutContent(mixed $context = null): array @@ -285,11 +297,11 @@ public function getFieldsWithoutContent(mixed $context = null): array /** * Returns all fields of a certain type. * - * @param class-string $type The field type - * @param string|string[]|false|null $context The field context(s) to fetch fields from. Defaults to [[\craft\services\Fields::$fieldContext]]. - * Set to `false` to get all fields regardless of context. - * + * @param class-string $type The field type + * @param string|string[]|false|null $context The field context(s) to fetch fields from. Defaults to [[\craft\services\Fields::$fieldContext]]. + * Set to `false` to get all fields regardless of context. * @return FieldInterface[] The fields + * * @since 4.4.0 */ public function getFieldsByType(string $type, mixed $context = null): array @@ -300,8 +312,7 @@ public function getFieldsByType(string $type, mixed $context = null): array /** * Returns a field by its ID. * - * @param int $fieldId The field’s ID - * + * @param int $fieldId The field’s ID * @return FieldInterface|null The field, or null if it doesn’t exist */ public function getFieldById(int $fieldId): ?FieldInterface @@ -312,8 +323,7 @@ public function getFieldById(int $fieldId): ?FieldInterface /** * Returns a field by its UID. * - * @param string $fieldUid The field’s UID - * + * @param string $fieldUid The field’s UID * @return FieldInterface|null The field, or null if it doesn’t exist */ public function getFieldByUid(string $fieldUid): ?FieldInterface @@ -334,10 +344,9 @@ public function getFieldByUid(string $fieldUid): ?FieldInterface * {{ body.instructions }} * ``` * - * @param string $handle The field’s handle - * @param string|string[]|false|null $context The field context(s) to fetch fields from. Defaults to [[\craft\services\Fields::$fieldContext]]. - * Set to `false` to get all fields regardless of context. - * + * @param string $handle The field’s handle + * @param string|string[]|false|null $context The field context(s) to fetch fields from. Defaults to [[\craft\services\Fields::$fieldContext]]. + * Set to `false` to get all fields regardless of context. * @return FieldInterface|null The field, or null if it doesn’t exist */ public function getFieldByHandle(string $handle, mixed $context = null): ?FieldInterface @@ -348,9 +357,8 @@ public function getFieldByHandle(string $handle, mixed $context = null): ?FieldI /** * Returns whether a field exists with a given handle and context. * - * @param string $handle The field handle - * @param string|null $context The field context (defauts to [[\craft\services\Fields::$fieldContext]]) - * + * @param string $handle The field handle + * @param string|null $context The field context (defauts to [[\craft\services\Fields::$fieldContext]]) * @return bool Whether a field with that handle exists */ public function doesFieldWithHandleExist(string $handle, ?string $context = null): bool @@ -361,9 +369,7 @@ public function doesFieldWithHandleExist(string $handle, ?string $context = null /** * Returns the config for the given field. * - * @param FieldInterface $field * - * @return array * @since 3.1.0 */ public function createFieldConfig(FieldInterface $field): array @@ -374,10 +380,10 @@ public function createFieldConfig(FieldInterface $field): array /** * Saves a field. * - * @param FieldInterface $field The Field to be saved - * @param bool $runValidation Whether the field should be validated - * + * @param FieldInterface $field The Field to be saved + * @param bool $runValidation Whether the field should be validated * @return bool Whether the field was saved successfully + * * @throws Throwable if reasons */ public function saveField(FieldInterface $field, bool $runValidation = true): bool @@ -388,7 +394,6 @@ public function saveField(FieldInterface $field, bool $runValidation = true): bo /** * Preps a field to be saved. * - * @param FieldInterface $field * * @since 3.1.2 */ @@ -400,7 +405,6 @@ public function prepFieldForSave(FieldInterface $field): void /** * Handle field changes. * - * @param ConfigEvent $event * * @throws Throwable */ @@ -412,8 +416,7 @@ public function handleChangedField(ConfigEvent $event): void /** * Deletes a field by its ID. * - * @param int $fieldId The field’s ID - * + * @param int $fieldId The field’s ID * @return bool Whether the field was deleted successfully */ public function deleteFieldById(int $fieldId): bool @@ -424,9 +427,9 @@ public function deleteFieldById(int $fieldId): bool /** * Deletes a field. * - * @param FieldInterface $field The field - * + * @param FieldInterface $field The field * @return bool Whether the field was deleted successfully + * * @throws Throwable if reasons */ public function deleteField(FieldInterface $field): bool @@ -436,8 +439,6 @@ public function deleteField(FieldInterface $field): bool /** * Handle a field getting deleted. - * - * @param ConfigEvent $event */ public function handleDeletedField(ConfigEvent $event): void { @@ -447,9 +448,9 @@ public function handleDeletedField(ConfigEvent $event): void /** * Applies a field delete to the database. * - * @param string $fieldUid * * @throws Throwable if database error + * * @since 3.1.0 */ public function applyFieldDelete(string $fieldUid): void @@ -473,9 +474,9 @@ public function refreshFields(): void /** * Returns all the field layouts that contain the given field. * - * @param FieldInterface $field * * @return FieldLayout[] + * * @since 5.0.0 */ public function findFieldUsages(FieldInterface $field): array @@ -489,7 +490,8 @@ public function findFieldUsages(FieldInterface $field): array /** * Returns all saved field layouts. * - * @return \CraftCms\Cms\FieldLayout\FieldLayout[] + * @return FieldLayout[] + * * @since 5.0.0 */ public function getAllLayouts(): array @@ -500,10 +502,9 @@ public function getAllLayouts(): array /** * Returns a field layout by its ID. * - * @param int $layoutId The field layout’s ID - * @param bool $withTrashed Whether to return the field layout even if it’s soft-deleted - * - * @return \CraftCms\Cms\FieldLayout\FieldLayout|null The field layout, or null if it doesn’t exist + * @param int $layoutId The field layout’s ID + * @param bool $withTrashed Whether to return the field layout even if it’s soft-deleted + * @return FieldLayout|null The field layout, or null if it doesn’t exist */ public function getLayoutById(int $layoutId, bool $withTrashed = false): ?FieldLayout { @@ -513,8 +514,7 @@ public function getLayoutById(int $layoutId, bool $withTrashed = false): ?FieldL /** * Returns a field layout by its UUID. * - * @param string $uid The field layout’s UUID - * + * @param string $uid The field layout’s UUID * @return FieldLayout|null The field layout, or null if it doesn’t exist */ public function getLayoutByUid(string $uid): ?FieldLayout @@ -525,9 +525,9 @@ public function getLayoutByUid(string $uid): ?FieldLayout /** * Returns field layouts by their IDs. * - * @param int[] $layoutIds The field layouts’ IDs + * @param int[] $layoutIds The field layouts’ IDs + * @return FieldLayout[] The field layouts * - * @return \CraftCms\Cms\FieldLayout\FieldLayout[] The field layouts * @since 3.7.27 */ public function getLayoutsByIds(array $layoutIds): array @@ -538,10 +538,9 @@ public function getLayoutsByIds(array $layoutIds): array /** * Returns a field layout by its associated element type. * - * @param class-string $type The associated element type - * @param bool $create Whether to create a field layout if one doesn’t exist - * - * @return \CraftCms\Cms\FieldLayout\FieldLayout|null The field layout + * @param class-string $type The associated element type + * @param bool $create Whether to create a field layout if one doesn’t exist + * @return FieldLayout|null The field layout */ public function getLayoutByType(string $type, bool $create = true): ?FieldLayout { @@ -551,9 +550,9 @@ public function getLayoutByType(string $type, bool $create = true): ?FieldLayout /** * Returns all of the field layouts associated with a given element type. * - * @param class-string $type - * + * @param class-string $type * @return FieldLayout[] The field layouts + * * @since 3.5.0 */ public function getLayoutsByType(string $type): array @@ -564,9 +563,7 @@ public function getLayoutsByType(string $type): array /** * Creates a field layout from the given config. * - * @param array $config * - * @return \CraftCms\Cms\FieldLayout\FieldLayout * @since 4.0.0 */ public function createLayout(array $config): FieldLayout @@ -578,11 +575,13 @@ public function createLayout(array $config): FieldLayout * Creates a field layout element instance from its config. * * @template T of \CraftCms\Cms\FieldLayout\FieldLayoutElement - * @param array $config * * @phpstan-param array{type:class-string} $config + * * @return T + * * @throws InvalidArgumentException if `$config['type']` does not implement [[FieldLayoutElement]] + * * @since 3.5.0 */ public function createLayoutElement(array $config): FieldLayoutElement @@ -593,9 +592,9 @@ public function createLayoutElement(array $config): FieldLayoutElement /** * Assembles a field layout from post data. * - * @param string|null $namespace The namespace that the form data was posted in, if any - * + * @param string|null $namespace The namespace that the form data was posted in, if any * @return FieldLayout The field layout + * * @throws BadRequestHttpException */ public function assembleLayoutFromPost(?string $namespace = null): FieldLayout @@ -606,10 +605,10 @@ public function assembleLayoutFromPost(?string $namespace = null): FieldLayout /** * Saves a field layout. * - * @param \CraftCms\Cms\FieldLayout\FieldLayout $layout The field layout - * @param bool $runValidation Whether the layout should be validated - * + * @param FieldLayout $layout The field layout + * @param bool $runValidation Whether the layout should be validated * @return bool Whether the field layout was saved successfully + * * @throws Exception if $layout->id is set to an invalid layout ID */ public function saveLayout(FieldLayout $layout, bool $runValidation = true): bool @@ -624,8 +623,7 @@ public function saveLayout(FieldLayout $layout, bool $runValidation = true): boo /** * Deletes a field layout(s) by its ID. * - * @param int|int[] $layoutId The field layout’s ID - * + * @param int|int[] $layoutId The field layout’s ID * @return bool Whether the field layout was deleted successfully */ public function deleteLayoutById(array|int $layoutId, bool $hardDelete = false): bool @@ -636,8 +634,7 @@ public function deleteLayoutById(array|int $layoutId, bool $hardDelete = false): /** * Deletes a field layout. * - * @param \CraftCms\Cms\FieldLayout\FieldLayout $layout The field layout - * + * @param FieldLayout $layout The field layout * @return bool Whether the field layout was deleted successfully */ public function deleteLayout(FieldLayout $layout, bool $hardDelete = false): bool @@ -648,8 +645,7 @@ public function deleteLayout(FieldLayout $layout, bool $hardDelete = false): boo /** * Deletes field layouts associated with a given element type. * - * @param class-string $type The element type - * + * @param class-string $type The element type * @return bool Whether the field layouts were deleted successfully */ public function deleteLayoutsByType(string $type): bool @@ -660,9 +656,9 @@ public function deleteLayoutsByType(string $type): bool /** * Restores a field layout by its ID. * - * @param int $id The field layout’s ID - * + * @param int $id The field layout’s ID * @return bool Whether the layout was restored successfully + * * @since 3.1.0 */ public function restoreLayoutById(int $id): bool @@ -673,7 +669,6 @@ public function restoreLayoutById(int $id): bool /** * Returns the current field version. * - * @return string|null * @since 3.7.21 */ public function getFieldVersion(): ?string @@ -693,9 +688,6 @@ public function updateFieldVersion(): void /** * Applies a field save to the database. * - * @param string $fieldUid - * @param array $data - * @param string $context * * @since 3.1.0 */ @@ -704,18 +696,12 @@ public function applyFieldSave(string $fieldUid, array $data, string $context): app(\CraftCms\Cms\Field\Fields::class)->applyFieldSave($fieldUid, $data, $context); } - /** * Returns data for the Fields index page in the control panel. * - * @param int $page - * @param int $limit - * @param string|null $searchTerm - * @param string $orderBy - * @param int $sortDir * - * @return array * @since 5.0.0 + * * @internal */ public function getTableData( @@ -728,16 +714,14 @@ public function getTableData( return app(\CraftCms\Cms\Field\Fields::class)->getTableData($page, $limit, $searchTerm, $orderBy, $sortDir); } - public static function registerEvents(): void + /** @internal */ + public static function finalizeRegistrationEvents(): void { - Event::listen(function(FieldTypesResolving $event) { - if (Craft::$app->getFields()->hasEventHandlers(self::EVENT_REGISTER_FIELD_TYPES)) { - $yiiEvent = new RegisterComponentTypesEvent(['types' => $event->types->all()]); - Craft::$app->getFields()->trigger(self::EVENT_REGISTER_FIELD_TYPES, $yiiEvent); - $event->types = new Collection($yiiEvent->types); - } - }); + TypeRegistryCompatibility::reconcile(app(FieldTypes::class), Craft::$app->getFields(), self::EVENT_REGISTER_FIELD_TYPES); + } + public static function registerEvents(): void + { Event::listen(function(CompatibleFieldTypesResolving $event) { if (Craft::$app->getFields()->hasEventHandlers(self::EVENT_DEFINE_COMPATIBLE_FIELD_TYPES)) { $yiiEvent = new DefineCompatibleFieldTypesEvent(['field' => $event->field, 'compatibleTypes' => $event->compatibleTypes->all()]); From 470e8e34f5c96a163e013fa4adbd223f5be994d1 Mon Sep 17 00:00:00 2001 From: Rias Date: Sat, 25 Jul 2026 09:05:04 +0200 Subject: [PATCH 06/21] Register native field layout fields explicitly --- .../Events/NativeFieldsResolving.php | 42 ---------- src/FieldLayout/FieldLayout.php | 5 +- .../FieldLayoutServiceProvider.php | 39 +++++----- .../LayoutElements/BaseNativeField.php | 4 +- src/FieldLayout/NativeFields.php | 76 +++++++++++++++++++ tests/Unit/FieldLayout/NativeFieldsTest.php | 46 +++++++++++ .../FieldLayout/Concerns/LegacyConstants.php | 24 ++++-- .../events/DefineFieldLayoutFieldsEvent.php | 2 +- yii2-adapter/src/DeprecatedConcepts.php | 45 ++++++----- 9 files changed, 183 insertions(+), 100 deletions(-) delete mode 100644 src/FieldLayout/Events/NativeFieldsResolving.php create mode 100644 src/FieldLayout/NativeFields.php create mode 100644 tests/Unit/FieldLayout/NativeFieldsTest.php diff --git a/src/FieldLayout/Events/NativeFieldsResolving.php b/src/FieldLayout/Events/NativeFieldsResolving.php deleted file mode 100644 index ec3286db02a..00000000000 --- a/src/FieldLayout/Events/NativeFieldsResolving.php +++ /dev/null @@ -1,42 +0,0 @@ -fieldLayout; - * - * if ($layout->type === MyElementType::class) { - * $event->fields[] = MyNativeField::class; - * } - * }); - * ``` - * - * @see FieldLayout::getAvailableNativeFields() - */ -class NativeFieldsResolving -{ - public function __construct( - public FieldLayout $fieldLayout, - - /** - * @var array|array{class:class-string}> The fields that should be available to the field layout designer. - */ - public array $fields, - ) {} -} diff --git a/src/FieldLayout/FieldLayout.php b/src/FieldLayout/FieldLayout.php index 8d4a7516cce..4dbea4e2819 100644 --- a/src/FieldLayout/FieldLayout.php +++ b/src/FieldLayout/FieldLayout.php @@ -17,7 +17,6 @@ use CraftCms\Cms\FieldLayout\Events\FieldLayoutCustomFieldsResolving; use CraftCms\Cms\FieldLayout\Events\FieldLayoutFormCreating; use CraftCms\Cms\FieldLayout\Events\FieldLayoutUIElementsResolving; -use CraftCms\Cms\FieldLayout\Events\NativeFieldsResolving; use CraftCms\Cms\FieldLayout\LayoutElements\BaseField; use CraftCms\Cms\FieldLayout\LayoutElements\BaseUiElement; use CraftCms\Cms\FieldLayout\LayoutElements\CustomField; @@ -444,10 +443,8 @@ public function getAvailableNativeFields(): array $this->_availableNativeFields = []; - event($event = new NativeFieldsResolving($this, $this->_availableNativeFields)); - // Instantiate them - foreach ($event->fields as $field) { + foreach (app(NativeFields::class)->apply($this, $this->_availableNativeFields) as $field) { $field = match (true) { is_string($field) => app()->make($field), is_array($field) => app()->make(Arr::pull($field, 'class'), ['config' => $field]), diff --git a/src/FieldLayout/FieldLayoutServiceProvider.php b/src/FieldLayout/FieldLayoutServiceProvider.php index 46ea7ab6101..007d1af3615 100644 --- a/src/FieldLayout/FieldLayoutServiceProvider.php +++ b/src/FieldLayout/FieldLayoutServiceProvider.php @@ -8,7 +8,6 @@ use CraftCms\Cms\Asset\Elements\Asset; use CraftCms\Cms\Cms; use CraftCms\Cms\Entry\Elements\Entry; -use CraftCms\Cms\FieldLayout\Events\NativeFieldsResolving; use CraftCms\Cms\FieldLayout\LayoutElements\Addresses\AddressField; use CraftCms\Cms\FieldLayout\LayoutElements\Addresses\CountryCodeField; use CraftCms\Cms\FieldLayout\LayoutElements\Addresses\LabelField; @@ -26,43 +25,43 @@ use CraftCms\Cms\FieldLayout\LayoutElements\Users\UsernameField; use CraftCms\Cms\Site\Sites; use CraftCms\Cms\User\Elements\User; -use Illuminate\Support\Facades\Event; use Illuminate\Support\ServiceProvider; class FieldLayoutServiceProvider extends ServiceProvider { - public function boot(Sites $sites): void + public function boot(NativeFields $nativeFields): void { - Event::listen(function (NativeFieldsResolving $event) use ($sites) { - switch ($event->fieldLayout->type) { + $nativeFields->register('craft', function (FieldLayout $fieldLayout, array $fields, Sites $sites): array { + switch ($fieldLayout->type) { case Address::class: - $event->fields[] = LabelField::class; - $event->fields[] = OrganizationField::class; - $event->fields[] = OrganizationTaxIdField::class; - $event->fields[] = FullNameField::class; - $event->fields[] = CountryCodeField::class; - $event->fields[] = AddressField::class; - $event->fields[] = LatLongField::class; + array_push($fields, + LabelField::class, + OrganizationField::class, + OrganizationTaxIdField::class, + FullNameField::class, + CountryCodeField::class, + AddressField::class, + LatLongField::class, + ); break; case Asset::class: - $event->fields[] = AssetTitleField::class; - $event->fields[] = AltField::class; + array_push($fields, AssetTitleField::class, AltField::class); break; case Entry::class: - $event->fields[] = EntryTitleField::class; + $fields[] = EntryTitleField::class; break; case User::class: if (! Cms::config()->useEmailAsUsername) { - $event->fields[] = UsernameField::class; + $fields[] = UsernameField::class; } - $event->fields[] = UserFullNameField::class; - $event->fields[] = PhotoField::class; - $event->fields[] = EmailField::class; + array_push($fields, UserFullNameField::class, PhotoField::class, EmailField::class); if ($sites->isMultiSite()) { - $event->fields[] = AffiliatedSiteField::class; + $fields[] = AffiliatedSiteField::class; } break; } + + return $fields; }); } } diff --git a/src/FieldLayout/LayoutElements/BaseNativeField.php b/src/FieldLayout/LayoutElements/BaseNativeField.php index 1d6b6577a29..686c107441d 100644 --- a/src/FieldLayout/LayoutElements/BaseNativeField.php +++ b/src/FieldLayout/LayoutElements/BaseNativeField.php @@ -5,14 +5,14 @@ namespace CraftCms\Cms\FieldLayout\LayoutElements; use CraftCms\Cms\Element\Contracts\ElementInterface; -use CraftCms\Cms\FieldLayout\Events\NativeFieldsResolving; +use CraftCms\Cms\FieldLayout\NativeFields; use CraftCms\Cms\Support\Arr; use Override; /** * BaseNativeField is the base class for native fields that can be included in field layouts. * - * Native fields can be registered using {@see NativeFieldsResolving}. + * Native fields can be registered using {@see NativeFields}. */ abstract class BaseNativeField extends BaseField { diff --git a/src/FieldLayout/NativeFields.php b/src/FieldLayout/NativeFields.php new file mode 100644 index 00000000000..4642b14b2a8 --- /dev/null +++ b/src/FieldLayout/NativeFields.php @@ -0,0 +1,76 @@ +register('my-plugin', function (FieldLayout $fieldLayout, array $fields): array { + * if ($fieldLayout->type === Entry::class) { + * $fields[] = MyEntryField::class; + * } + * + * return $fields; + * }); + * } + * ``` + */ +#[Singleton] +class NativeFields +{ + /** @var array */ + private array $providers = []; + + public function __construct( + private readonly Container $container, + ) {} + + public function register(string $handle, Closure $provider): void + { + if ($handle === '') { + throw new InvalidArgumentException('Native field provider handles cannot be empty.'); + } + + if (isset($this->providers[$handle])) { + throw new InvalidArgumentException("Native field provider [$handle] is already registered."); + } + + $this->providers[$handle] = $provider; + } + + public function remove(string ...$handles): void + { + foreach ($handles as $handle) { + unset($this->providers[$handle]); + } + } + + public function apply(FieldLayout $fieldLayout, array $fields = []): array + { + foreach ($this->providers as $handle => $provider) { + $fields = $this->container->call($provider, [ + 'fieldLayout' => $fieldLayout, + 'fields' => $fields, + ]); + + if (! is_array($fields)) { + throw new InvalidArgumentException("Native field provider [$handle] must return an array."); + } + } + + return $fields; + } +} diff --git a/tests/Unit/FieldLayout/NativeFieldsTest.php b/tests/Unit/FieldLayout/NativeFieldsTest.php new file mode 100644 index 00000000000..689519ec708 --- /dev/null +++ b/tests/Unit/FieldLayout/NativeFieldsTest.php @@ -0,0 +1,46 @@ +register('plugin', fn (FieldLayout $layout, array $fields) => [...$fields, EntryTitleField::class]); + + expect($registry->apply(new FieldLayout))->toBe([EntryTitleField::class]); +}); + +it('resolves provider dependencies from the current scope with contextual arguments', function () { + app()->scoped(Sites::class, fn () => Mockery::mock(Sites::class)); + + $registry = app(NativeFields::class); + $registry->remove('craft'); + $layout = new FieldLayout; + $calls = []; + $registry->register('plugin', function (FieldLayout $fieldLayout, array $fields, Sites $sites) use (&$calls): array { + $calls[] = [$fieldLayout, $fields]; + + return [...$fields, $sites]; + }); + + $first = $registry->apply($layout, ['existing']); + app()->forgetScopedInstances(); + $second = $registry->apply($layout, ['existing']); + + expect($calls)->toBe([ + [$layout, ['existing']], + [$layout, ['existing']], + ])->and($first[1])->not()->toBe($second[1]); +}); + +it('rejects duplicate provider handles', function () { + $registry = app(NativeFields::class); + $registry->register('plugin', fn (FieldLayout $layout, array $fields) => $fields); + + expect(fn () => $registry->register('plugin', fn (FieldLayout $layout, array $fields) => $fields)) + ->toThrow(InvalidArgumentException::class); +}); diff --git a/yii2-adapter/constants/FieldLayout/Concerns/LegacyConstants.php b/yii2-adapter/constants/FieldLayout/Concerns/LegacyConstants.php index 49d8efbfb31..91b549f75f2 100644 --- a/yii2-adapter/constants/FieldLayout/Concerns/LegacyConstants.php +++ b/yii2-adapter/constants/FieldLayout/Concerns/LegacyConstants.php @@ -1,6 +1,7 @@ $event->fields]); - $yiiEvent->sender = $event->fieldLayout; + $nativeFields = app(NativeFields::class); + $nativeFields->remove('yii2-adapter:legacy-events'); + $nativeFields->register('yii2-adapter:legacy-events', function(\CraftCms\Cms\FieldLayout\FieldLayout $fieldLayout, array $fields): array { + if (!YiiEvent::hasHandlers(FieldLayout::class, FieldLayout::EVENT_DEFINE_NATIVE_FIELDS)) { + return $fields; + } - YiiEvent::trigger(FieldLayout::class, FieldLayout::EVENT_DEFINE_NATIVE_FIELDS, $yiiEvent); + $yiiEvent = new DefineFieldLayoutFieldsEvent(['fields' => $fields]); + $yiiEvent->sender = $fieldLayout; - $event->fields = $yiiEvent->fields; - } + YiiEvent::trigger(FieldLayout::class, FieldLayout::EVENT_DEFINE_NATIVE_FIELDS, $yiiEvent); + + return $yiiEvent->fields; }); Event::listen(function(FieldLayoutUIElementsResolving $event) { diff --git a/yii2-adapter/legacy/events/DefineFieldLayoutFieldsEvent.php b/yii2-adapter/legacy/events/DefineFieldLayoutFieldsEvent.php index 1407849968e..7ddd8aa3e4f 100644 --- a/yii2-adapter/legacy/events/DefineFieldLayoutFieldsEvent.php +++ b/yii2-adapter/legacy/events/DefineFieldLayoutFieldsEvent.php @@ -15,7 +15,7 @@ * * @author Pixel & Tonic, Inc. * @since 3.5.0 - * @deprecated 6.0.0 use {@see \CraftCms\Cms\FieldLayout\Events\NativeFieldsResolving} instead. + * @deprecated 6.0.0 use {@see \CraftCms\Cms\FieldLayout\NativeFields} instead. */ class DefineFieldLayoutFieldsEvent extends Event { diff --git a/yii2-adapter/src/DeprecatedConcepts.php b/yii2-adapter/src/DeprecatedConcepts.php index 91e4d5eb6c1..054b875dea1 100644 --- a/yii2-adapter/src/DeprecatedConcepts.php +++ b/yii2-adapter/src/DeprecatedConcepts.php @@ -56,10 +56,11 @@ use CraftCms\Cms\Cp\Events\CpDataResolving; use CraftCms\Cms\Database\Table; use CraftCms\Cms\Element\Jobs\PropagateElements; -use CraftCms\Cms\Field\Events\FieldTypesResolving; -use CraftCms\Cms\Field\Events\LinkTypesResolving; -use CraftCms\Cms\FieldLayout\Events\NativeFieldsResolving; +use CraftCms\Cms\Field\FieldTypes; +use CraftCms\Cms\Field\LinkTypes; +use CraftCms\Cms\FieldLayout\FieldLayout; use CraftCms\Cms\FieldLayout\LayoutElements\TitleField; +use CraftCms\Cms\FieldLayout\NativeFields; use CraftCms\Cms\GarbageCollection\Actions\DeleteOrphanedFieldLayouts; use CraftCms\Cms\GarbageCollection\Actions\DeletePartialElements; use CraftCms\Cms\GarbageCollection\Actions\HardDelete; @@ -80,6 +81,7 @@ use Illuminate\Support\Facades\Gate; use Illuminate\Support\Facades\Schema; use PDOException; +use yii\base\Component; use function CraftCms\Cms\t; @@ -116,7 +118,7 @@ private static function supports(string $table): bool } /** - * @return class-string<\yii\base\Component> + * @return class-string */ private static function legacyArgumentManagerClass(): string { @@ -125,7 +127,7 @@ private static function legacyArgumentManagerClass(): string } /** - * @return class-string<\yii\base\Component> + * @return class-string */ private static function legacyElementQueryConditionBuilderClass(): string { @@ -152,20 +154,13 @@ public function boot(): void Gate::policy(Tag::class, TagPolicy::class); } - Event::listen(FieldTypesResolving::class, function(FieldTypesResolving $event) { - if (DeprecatedConcepts::supportsCategories()) { - $event->types->add(CategoriesField::class); - } - if (DeprecatedConcepts::supportsTags()) { - $event->types->add(TagsField::class); - } - }); - - Event::listen(LinkTypesResolving::class, function(LinkTypesResolving $event) { - if (DeprecatedConcepts::supportsCategories()) { - $event->types[] = CategoryLinkType::class; - } - }); + if (DeprecatedConcepts::supportsCategories()) { + app(FieldTypes::class)->register(CategoriesField::class); + app(LinkTypes::class)->register(CategoryLinkType::class); + } + if (DeprecatedConcepts::supportsTags()) { + app(FieldTypes::class)->register(TagsField::class); + } Event::listen(RunningGarbageCollection::class, function(RunningGarbageCollection $event) { $event->garbageCollection->runActions(array_filter([ @@ -489,7 +484,7 @@ function(RegisterCpSettingsEvent $event) { $event->data['editableCategoryGroups'] = collect(Craft::$app->getCategories()->getEditableGroups()) ->map(fn(CategoryGroup $group) => [ 'handle' => $group->handle, - 'id' => (int)$group->id, + 'id' => (int) $group->id, 'name' => Craft::t('site', $group->name), 'uid' => $group->uid, ]) @@ -556,13 +551,17 @@ function(RegisterUrlRulesEvent $event) { // Legacy `view` global remains available through the adapter layer only. Twig::registerExtension(new Extension()); - Event::listen(function(NativeFieldsResolving $event) { - switch ($event->fieldLayout->type) { + $nativeFields = app(NativeFields::class); + $nativeFields->remove('yii2-adapter:deprecated-concepts'); + $nativeFields->register('yii2-adapter:deprecated-concepts', function(FieldLayout $fieldLayout, array $fields): array { + switch ($fieldLayout->type) { case Category::class: case Tag::class: - $event->fields[] = TitleField::class; + $fields[] = TitleField::class; break; } + + return $fields; }); if (DeprecatedConcepts::supportsTags()) { From 5227075464bc7c4d22c2188eaefd61976a126e18 Mon Sep 17 00:00:00 2001 From: Rias Date: Sat, 25 Jul 2026 09:05:17 +0200 Subject: [PATCH 07/21] Register dashboard widget types explicitly --- src/Dashboard/Dashboard.php | 25 --------- src/Dashboard/Events/WidgetTypesResolving.php | 32 ----------- .../Dashboard/DashboardController.php | 4 +- .../Dashboard/WidgetsController.php | 4 +- src/Plugin/Concerns/HasWidgets.php | 11 +--- tests/Feature/Dashboard/DashboardTest.php | 38 +++---------- tests/Unit/Plugin/Concerns/HasWidgetsTest.php | 32 ++--------- yii2-adapter/legacy/services/Dashboard.php | 56 +++++++++---------- 8 files changed, 47 insertions(+), 155 deletions(-) delete mode 100644 src/Dashboard/Events/WidgetTypesResolving.php diff --git a/src/Dashboard/Dashboard.php b/src/Dashboard/Dashboard.php index ff56f4f3091..b063f5bb472 100644 --- a/src/Dashboard/Dashboard.php +++ b/src/Dashboard/Dashboard.php @@ -9,12 +9,8 @@ use CraftCms\Cms\Dashboard\Events\WidgetDeleting; use CraftCms\Cms\Dashboard\Events\WidgetSaved; use CraftCms\Cms\Dashboard\Events\WidgetSaving; -use CraftCms\Cms\Dashboard\Events\WidgetTypesResolving; use CraftCms\Cms\Dashboard\Widgets\CraftSupport as CraftSupportWidget; use CraftCms\Cms\Dashboard\Widgets\Feed as FeedWidget; -use CraftCms\Cms\Dashboard\Widgets\MyDrafts; -use CraftCms\Cms\Dashboard\Widgets\NewUsers as NewUsersWidget; -use CraftCms\Cms\Dashboard\Widgets\QuickPost as QuickPostWidget; use CraftCms\Cms\Dashboard\Widgets\RecentEntries as RecentEntriesWidget; use CraftCms\Cms\Dashboard\Widgets\Updates as UpdatesWidget; use CraftCms\Cms\Dashboard\Widgets\Widget; @@ -38,27 +34,6 @@ #[Singleton] readonly class Dashboard { - /** - * @return Collection> - */ - public function getAllWidgetTypes(): Collection - { - /** @var Collection> $widgetTypes */ - $widgetTypes = Collection::make([ - FeedWidget::class, - CraftSupportWidget::class, - NewUsersWidget::class, - QuickPostWidget::class, - RecentEntriesWidget::class, - MyDrafts::class, - UpdatesWidget::class, - ]); - - event($event = new WidgetTypesResolving($widgetTypes)); - - return $event->types; - } - /** * Creates a widget with a given config. * diff --git a/src/Dashboard/Events/WidgetTypesResolving.php b/src/Dashboard/Events/WidgetTypesResolving.php deleted file mode 100644 index 7cae20bb96b..00000000000 --- a/src/Dashboard/Events/WidgetTypesResolving.php +++ /dev/null @@ -1,32 +0,0 @@ -types->add(MyWidgetType::class); - * }); - * ``` - */ -class WidgetTypesResolving -{ - public function __construct( - /** @var Collection> */ - public Collection $types, - ) {} -} diff --git a/src/Http/Controllers/Dashboard/DashboardController.php b/src/Http/Controllers/Dashboard/DashboardController.php index 80c20411bb8..337084a37f1 100644 --- a/src/Http/Controllers/Dashboard/DashboardController.php +++ b/src/Http/Controllers/Dashboard/DashboardController.php @@ -7,6 +7,7 @@ use CraftCms\Cms\Cms; use CraftCms\Cms\Dashboard\Contracts\WidgetInterface; use CraftCms\Cms\Dashboard\Dashboard; +use CraftCms\Cms\Dashboard\WidgetTypes; use CraftCms\Cms\Support\Facades\InputNamespace; use CraftCms\Cms\Support\Json; use CraftCms\Cms\View\HtmlStack; @@ -24,6 +25,7 @@ public function __construct( private HtmlStack $HtmlStack, private Dashboard $dashboard, + private WidgetTypes $widgetTypes, ) {} public function index() @@ -31,7 +33,7 @@ public function index() /** * @var Collection $widgetTypeInfo */ - $widgetTypeInfo = $this->dashboard->getAllWidgetTypes() + $widgetTypeInfo = $this->widgetTypes->types() /** @var class-string $widgetType */ ->filter(fn (string $widgetType) => $widgetType::isSelectable()) /** @phpstan-ignore argument.unresolvableType */ diff --git a/src/Http/Controllers/Dashboard/WidgetsController.php b/src/Http/Controllers/Dashboard/WidgetsController.php index e835c2d5dca..1cfd8506704 100644 --- a/src/Http/Controllers/Dashboard/WidgetsController.php +++ b/src/Http/Controllers/Dashboard/WidgetsController.php @@ -6,6 +6,7 @@ use CraftCms\Cms\Dashboard\Contracts\WidgetInterface; use CraftCms\Cms\Dashboard\Dashboard; +use CraftCms\Cms\Dashboard\WidgetTypes; use CraftCms\Cms\Support\Json; use CraftCms\Cms\View\HtmlStack; use Illuminate\Http\JsonResponse; @@ -21,6 +22,7 @@ public function __construct( private HtmlStack $HtmlStack, private Dashboard $dashboard, + private WidgetTypes $widgetTypes, ) {} public function store(Request $request): JsonResponse @@ -33,7 +35,7 @@ public function store(Request $request): JsonResponse /** @var class-string $type */ $type = $data['type']; - if (! in_array($type, $this->dashboard->getAllWidgetTypes()->all())) { + if ($this->widgetTypes->types()->doesntContain($type)) { throw ValidationException::withMessages([ 'type' => 'Invalid widget type.', ]); diff --git a/src/Plugin/Concerns/HasWidgets.php b/src/Plugin/Concerns/HasWidgets.php index b9a2a133ad1..3e0c70deb87 100644 --- a/src/Plugin/Concerns/HasWidgets.php +++ b/src/Plugin/Concerns/HasWidgets.php @@ -5,9 +5,8 @@ namespace CraftCms\Cms\Plugin\Concerns; use CraftCms\Cms\Dashboard\Contracts\WidgetInterface; -use CraftCms\Cms\Dashboard\Events\WidgetTypesResolving; +use CraftCms\Cms\Dashboard\WidgetTypes; use CraftCms\Cms\Plugin\Plugin; -use Illuminate\Support\Facades\Event; /** * @mixin Plugin @@ -25,12 +24,6 @@ trait HasWidgets public function bootHasWidgets(): void { - if (! $this->widgets) { - return; - } - - Event::listen(WidgetTypesResolving::class, function (WidgetTypesResolving $event) { - $event->types->push(...$this->widgets); - }); + $this->app->make(WidgetTypes::class)->register(...$this->widgets); } } diff --git a/tests/Feature/Dashboard/DashboardTest.php b/tests/Feature/Dashboard/DashboardTest.php index eddfd32ecb9..2223361f3bf 100644 --- a/tests/Feature/Dashboard/DashboardTest.php +++ b/tests/Feature/Dashboard/DashboardTest.php @@ -6,12 +6,10 @@ use CraftCms\Cms\Dashboard\Events\WidgetDeleting; use CraftCms\Cms\Dashboard\Events\WidgetSaved; use CraftCms\Cms\Dashboard\Events\WidgetSaving; -use CraftCms\Cms\Dashboard\Events\WidgetTypesResolving; use CraftCms\Cms\Dashboard\Models\Widget as WidgetModel; use CraftCms\Cms\Dashboard\Widgets\CraftSupport; use CraftCms\Cms\Dashboard\Widgets\Feed; use CraftCms\Cms\Dashboard\Widgets\MissingWidget; -use CraftCms\Cms\Dashboard\Widgets\Widget; use CraftCms\Cms\User\Elements\User; use CraftCms\Cms\User\Models\User as UserModel; use Illuminate\Support\Facades\Event; @@ -28,20 +26,6 @@ WidgetModel::all()->each->delete(); }); -it('can get all widget types', function () { - expect($this->dashboard->getAllWidgetTypes())->not()->toBeEmpty(); -}); - -it('can register additional widgets', function () { - class MyWidget extends Widget {} - - Event::listen(WidgetTypesResolving::class, function (WidgetTypesResolving $event) { - $event->types[] = MyWidget::class; - }); - - expect($this->dashboard->getAllWidgetTypes())->toContain(MyWidget::class); -}); - it('can create a widget from config', function () { $config = [ 'type' => Feed::class, @@ -51,23 +35,17 @@ class MyWidget extends Widget {} ], ]; - /** @var Feed $widget */ $widget = $this->dashboard->createWidget($config); - expect($widget)->toBeInstanceOf(Feed::class); - tap($widget, function (Feed $widget) { - expect($widget->url)->toBe('https://craftcms.com/news.rss'); - expect($widget->title)->toBe('Craft News'); - }); + + expect($widget) + ->toBeInstanceOf(Feed::class) + ->url->toBe('https://craftcms.com/news.rss') + ->title->toBe('Craft News'); }); it('can create a widget from type', function () { - $widget = $this->dashboard->createWidget([ - 'type' => Feed::class, - 'settings' => [ - 'title' => 'Craft News', - 'url' => 'https://craftcms.com/news.rss', - ], - ]); + $widget = $this->dashboard->createWidget(Feed::class); + expect($widget)->toBeInstanceOf(Feed::class); }); @@ -281,7 +259,7 @@ class MyWidget extends Widget {} expect(WidgetModel::query()->orderBy('sortOrder')->pluck('id')->all())->toBe([$widget1->id, $widget2->id]); - $this->dashboard->reorderWidgets([$widget2->id, $widget1->id, 10]); + $this->dashboard->reorderWidgets([$widget2->id, $widget1->id, max($widget1->id, $widget2->id) + 1]); expect(WidgetModel::query()->orderBy('sortOrder')->pluck('id')->all())->toBe([$widget2->id, $widget1->id]); }); diff --git a/tests/Unit/Plugin/Concerns/HasWidgetsTest.php b/tests/Unit/Plugin/Concerns/HasWidgetsTest.php index df8e7c1504f..8d0df45d638 100644 --- a/tests/Unit/Plugin/Concerns/HasWidgetsTest.php +++ b/tests/Unit/Plugin/Concerns/HasWidgetsTest.php @@ -2,46 +2,24 @@ declare(strict_types=1); -use CraftCms\Cms\Dashboard\Events\WidgetTypesResolving; -use CraftCms\Cms\Dashboard\Widgets\Updates; +use CraftCms\Cms\Dashboard\Widgets\Widget; +use CraftCms\Cms\Dashboard\WidgetTypes; use CraftCms\Cms\Tests\TestClasses\TestPlugin\src\TestPlugin; -use Illuminate\Contracts\Events\Dispatcher; -use Illuminate\Support\Collection; beforeEach(function () { app()->forgetInstance(TestPlugin::class); }); -afterEach(function () { - app(Dispatcher::class)->forget(WidgetTypesResolving::class); - app()->forgetInstance(TestPlugin::class); -}); - it('registers configured widget types', function () { $plugin = TestPlugin::create([ 'handle' => 'test-plugin', 'name' => 'Test Plugin', ]); - $plugin->setWidgets([Updates::class]); + $plugin->setWidgets([TestPluginWidgetType::class]); $plugin->bootHasWidgets(); - $event = new WidgetTypesResolving(new Collection); - event($event); - - expect($event->types->all())->toContain(Updates::class); + expect(app(WidgetTypes::class)->types())->toContain(TestPluginWidgetType::class); }); -it('does not register widget listeners when none are configured', function () { - $plugin = TestPlugin::create([ - 'handle' => 'test-plugin', - 'name' => 'Test Plugin', - ]); - - $plugin->bootHasWidgets(); - - $event = new WidgetTypesResolving(new Collection); - event($event); - - expect($event->types->all())->toBe([]); -}); +abstract class TestPluginWidgetType extends Widget {} diff --git a/yii2-adapter/legacy/services/Dashboard.php b/yii2-adapter/legacy/services/Dashboard.php index bfc27de31ee..5deec95dbef 100644 --- a/yii2-adapter/legacy/services/Dashboard.php +++ b/yii2-adapter/legacy/services/Dashboard.php @@ -1,6 +1,8 @@ getDashboard()`]]. * * @author Pixel & Tonic, Inc. + * * @since 3.0.0 * @deprecated in 6.0.0. Use `app(\CraftCms\Cms\Dashboard\Dashboard::class)` instead. */ @@ -80,20 +83,23 @@ class Dashboard extends Component * Returns all available widget type classes. * * @return string[] + * * @phpstan-return class-string[] */ public function getAllWidgetTypes(): array { - return app(\CraftCms\Cms\Dashboard\Dashboard::class)->getAllWidgetTypes()->all(); + return app(WidgetTypes::class)->types()->all(); } /** * Creates a widget with a given config. * * @template T of WidgetInterface - * @param class-string|array $config The widget’s class name, or its config, with a `type` value and optionally a `settings` value. + * + * @param class-string|array $config The widget’s class name, or its config, with a `type` value and optionally a `settings` value. * * @phpstan-param class-string|array{type:class-string,id?:int,dateCreated?:DateTime,dateUpdated?:DateTime,colspan?:int,settings?:array|string} $config + * * @return T */ public function createWidget(mixed $config): WidgetInterface @@ -114,8 +120,7 @@ public function getAllWidgets(): array /** * Returns whether the current user has a widget of the given type. * - * @param class-string $type The widget type - * + * @param class-string $type The widget type * @return bool Whether the current user has a widget of the given type */ public function doesUserHaveWidget(string $type): bool @@ -126,8 +131,7 @@ public function doesUserHaveWidget(string $type): bool /** * Returns a widget by its ID. * - * @param int $id The widget’s ID - * + * @param int $id The widget’s ID * @return WidgetInterface|null The widget, or null if it doesn’t exist */ public function getWidgetById(int $id): ?WidgetInterface @@ -138,10 +142,10 @@ public function getWidgetById(int $id): ?WidgetInterface /** * Saves a widget for the current user. * - * @param WidgetInterface $widget The widget to be saved - * @param bool $runValidation Whether the widget should be validated - * + * @param WidgetInterface $widget The widget to be saved + * @param bool $runValidation Whether the widget should be validated * @return bool Whether the widget was saved successfully + * * @throws Throwable if reasons */ public function saveWidget(WidgetInterface $widget, bool $runValidation = true): bool @@ -152,7 +156,7 @@ public function saveWidget(WidgetInterface $widget, bool $runValidation = true): /** * Deletes a widget by its ID. * - * @param int $widgetId The widget’s ID + * @param int $widgetId The widget’s ID * @return bool Whether the widget was deleted successfully */ public function deleteWidgetById(int $widgetId): bool @@ -163,9 +167,9 @@ public function deleteWidgetById(int $widgetId): bool /** * Deletes a widget. * - * @param WidgetInterface $widget The widget to be deleted - * + * @param WidgetInterface $widget The widget to be deleted * @return bool Whether the widget was deleted successfully + * * @throws Throwable if reasons */ public function deleteWidget(WidgetInterface $widget): bool @@ -176,8 +180,9 @@ public function deleteWidget(WidgetInterface $widget): bool /** * Reorders widgets. * - * @param int[] $widgetIds The widget IDs + * @param int[] $widgetIds The widget IDs * @return bool Whether the widgets were reordered successfully + * * @throws Throwable if reasons */ public function reorderWidgets(array $widgetIds): bool @@ -187,29 +192,20 @@ public function reorderWidgets(array $widgetIds): bool /** * Changes the colspan of a widget. - * - * @param int $widgetId - * @param int $colspan - * @return bool */ public function changeWidgetColspan(int $widgetId, int $colspan): bool { return app(\CraftCms\Cms\Dashboard\Dashboard::class)->changeWidgetColspan($widgetId, $colspan); } - public static function registerEvents(): void + /** @internal */ + public static function finalizeRegistrationEvents(): void { - // Fire a 'registerWidgetTypes' event - Event::listen(WidgetTypesResolving::class, function(WidgetTypesResolving $event) { - $yiiEvent = new RegisterComponentTypesEvent(['types' => $event->types->all()]); - Craft::$app->getDashboard()->trigger(self::EVENT_REGISTER_WIDGET_TYPES, $yiiEvent); - - /** @var array> $types */ - $types = $yiiEvent->types; - - $event->types = Collection::make($types); - }); + TypeRegistryCompatibility::reconcile(app(WidgetTypes::class), Craft::$app->getDashboard(), self::EVENT_REGISTER_WIDGET_TYPES); + } + public static function registerEvents(): void + { // Fire a 'beforeSaveWidget' event Event::listen(WidgetSaving::class, function(WidgetSaving $event) { Craft::$app->getDashboard()->trigger(self::EVENT_BEFORE_SAVE_WIDGET, $yiiEvent = new WidgetEvent([ From 4aa5c753d9097f03b8f0dd0cf05a59945205d71c Mon Sep 17 00:00:00 2001 From: Rias Date: Sat, 25 Jul 2026 09:05:28 +0200 Subject: [PATCH 08/21] Register filesystem types explicitly --- .../Events/FilesystemTypesResolving.php | 19 --------------- src/Filesystem/Filesystems.php | 11 ++------- tests/Feature/Filesystem/FilesystemsTest.php | 17 +++++++------- yii2-adapter/legacy/services/Fs.php | 23 ++++++++----------- 4 files changed, 19 insertions(+), 51 deletions(-) delete mode 100644 src/Filesystem/Events/FilesystemTypesResolving.php diff --git a/src/Filesystem/Events/FilesystemTypesResolving.php b/src/Filesystem/Events/FilesystemTypesResolving.php deleted file mode 100644 index b4b836c3bdf..00000000000 --- a/src/Filesystem/Events/FilesystemTypesResolving.php +++ /dev/null @@ -1,19 +0,0 @@ -> */ - public Collection $types, - ) {} -} diff --git a/src/Filesystem/Filesystems.php b/src/Filesystem/Filesystems.php index d172c405c5e..b1700b3a3d9 100644 --- a/src/Filesystem/Filesystems.php +++ b/src/Filesystem/Filesystems.php @@ -8,11 +8,9 @@ use CraftCms\Cms\Component\Exceptions\MissingComponentException; use CraftCms\Cms\Filesystem\Contracts\FsInterface; use CraftCms\Cms\Filesystem\Events\FilesystemRenamed; -use CraftCms\Cms\Filesystem\Events\FilesystemTypesResolving; use CraftCms\Cms\Filesystem\Exceptions\FilesystemException; use CraftCms\Cms\Filesystem\Exceptions\InvalidSubpathException; use CraftCms\Cms\Filesystem\Filesystems\DiskFilesystem; -use CraftCms\Cms\Filesystem\Filesystems\Local; use CraftCms\Cms\Filesystem\Filesystems\MissingFs; use CraftCms\Cms\ProjectConfig\Events\ConfigEvent; use CraftCms\Cms\ProjectConfig\ProjectConfig; @@ -50,6 +48,7 @@ public function __construct( private readonly ProjectConfig $projectConfig, private readonly ConfigRepository $config, private readonly FilesystemManager $filesystemManager, + private readonly FilesystemTypes $filesystemTypes, ) {} public function createFilesystemConfig(FsInterface $fs): array @@ -78,13 +77,7 @@ public function createFilesystemConfig(FsInterface $fs): array */ public function getAllFilesystemTypes(): Collection { - $event = new FilesystemTypesResolving(Collection::make([ - Local::class, - ])); - - event($event); - - return $event->types->values(); + return $this->filesystemTypes->types(); } /** diff --git a/tests/Feature/Filesystem/FilesystemsTest.php b/tests/Feature/Filesystem/FilesystemsTest.php index 0d03030b199..1c82ce7dc7e 100644 --- a/tests/Feature/Filesystem/FilesystemsTest.php +++ b/tests/Feature/Filesystem/FilesystemsTest.php @@ -5,13 +5,13 @@ use CraftCms\Cms\Cms; use CraftCms\Cms\Filesystem\Contracts\FsInterface; use CraftCms\Cms\Filesystem\Events\FilesystemRenamed; -use CraftCms\Cms\Filesystem\Events\FilesystemTypesResolving; use CraftCms\Cms\Filesystem\Exceptions\FilesystemException; use CraftCms\Cms\Filesystem\Filesystems; use CraftCms\Cms\Filesystem\Filesystems\DiskFilesystem; use CraftCms\Cms\Filesystem\Filesystems\Local; use CraftCms\Cms\Filesystem\Filesystems\MissingFs; use CraftCms\Cms\Filesystem\Filesystems\Temp; +use CraftCms\Cms\Filesystem\FilesystemTypes; use CraftCms\Cms\ProjectConfig\Events\ItemRemoved; use CraftCms\Cms\ProjectConfig\Events\ItemUpdated; use CraftCms\Cms\ProjectConfig\ProjectConfig; @@ -29,16 +29,15 @@ ->and($this->service)->toBe(FilesystemsFacade::getFacadeRoot()); }); -it('can register extra filesystem types through an event', function () { - expect($this->service->getAllFilesystemTypes()) - ->toBeInstanceOf(Collection::class) - ->not()->toContain(Temp::class); +it('uses the current registry types', function () { + $registry = app(FilesystemTypes::class); + $registry->register(Temp::class); + + expect($this->service->getAllFilesystemTypes())->toContain(Local::class, Temp::class); - Event::listen(FilesystemTypesResolving::class, function (FilesystemTypesResolving $event) { - $event->types->add(Temp::class); - }); + $registry->remove(Temp::class); - expect($this->service->getAllFilesystemTypes())->toContain(Temp::class); + expect($this->service->getAllFilesystemTypes())->not()->toContain(Temp::class); }); it('returns filesystems as a collection', function () { diff --git a/yii2-adapter/legacy/services/Fs.php b/yii2-adapter/legacy/services/Fs.php index 8c0d8450c2a..7385c7713c4 100644 --- a/yii2-adapter/legacy/services/Fs.php +++ b/yii2-adapter/legacy/services/Fs.php @@ -14,11 +14,11 @@ use craft\events\RegisterComponentTypesEvent; use CraftCms\Cms\Filesystem\Contracts\FsInterface; use CraftCms\Cms\Filesystem\Events\FilesystemRenamed; -use CraftCms\Cms\Filesystem\Events\FilesystemTypesResolving; use CraftCms\Cms\Filesystem\Filesystems; +use CraftCms\Cms\Filesystem\FilesystemTypes; use CraftCms\Cms\ProjectConfig\Events\ConfigEvent; +use CraftCms\Yii2Adapter\Event\TypeRegistryCompatibility; use Illuminate\Contracts\Filesystem\Filesystem as LaravelFilesystem; -use Illuminate\Support\Collection; use Illuminate\Support\Facades\Event as EventFacade; use Throwable; use yii\base\Component; @@ -34,7 +34,7 @@ * @author Pixel & Tonic, Inc. * * @since 4.0.0 - * @deprecated in 6.0.0. Use {@see \CraftCms\Cms\Filesystem\Filesystems} instead. + * @deprecated in 6.0.0. Use {@see Filesystems} instead. */ class Fs extends Component { @@ -168,19 +168,14 @@ public function handleDeletedFilesystem(ConfigEvent $event): void $this->service()->handleDeletedFilesystem($event); } - public static function registerEvents(): void + /** @internal */ + public static function finalizeRegistrationEvents(): void { - EventFacade::listen(FilesystemTypesResolving::class, function(FilesystemTypesResolving $event) { - if (!Craft::$app->getFs()->hasEventHandlers(self::EVENT_REGISTER_FILESYSTEM_TYPES)) { - return; - } - - $yiiEvent = new RegisterComponentTypesEvent(['types' => $event->types->all()]); - Craft::$app->getFs()->trigger(self::EVENT_REGISTER_FILESYSTEM_TYPES, $yiiEvent); - - $event->types = Collection::make($yiiEvent->types); - }); + TypeRegistryCompatibility::reconcile(app(FilesystemTypes::class), Craft::$app->getFs(), self::EVENT_REGISTER_FILESYSTEM_TYPES); + } + public static function registerEvents(): void + { EventFacade::listen(FilesystemRenamed::class, function(FilesystemRenamed $event) { if (!Craft::$app->getFs()->hasEventHandlers(self::EVENT_RENAME_FILESYSTEM)) { return; From 43d81c1cfcd5aa2a05b73a6b05a344f8ded57047 Mon Sep 17 00:00:00 2001 From: Rias Date: Sat, 25 Jul 2026 09:05:43 +0200 Subject: [PATCH 09/21] Register image transformers explicitly --- .../Events/ImageTransformersResolving.php | 15 ----- src/Image/ImageTransforms.php | 10 +--- tests/Feature/Image/ImageTransformsTest.php | 41 ++++--------- .../legacy/services/ImageTransforms.php | 58 +++++++------------ 4 files changed, 32 insertions(+), 92 deletions(-) delete mode 100644 src/Image/Events/ImageTransformersResolving.php diff --git a/src/Image/Events/ImageTransformersResolving.php b/src/Image/Events/ImageTransformersResolving.php deleted file mode 100644 index 9aa567c28c3..00000000000 --- a/src/Image/Events/ImageTransformersResolving.php +++ /dev/null @@ -1,15 +0,0 @@ -types; + return $this->imageTransformerTypes->types()->all(); } /** diff --git a/tests/Feature/Image/ImageTransformsTest.php b/tests/Feature/Image/ImageTransformsTest.php index c9691d448b7..3e72a424c2b 100644 --- a/tests/Feature/Image/ImageTransformsTest.php +++ b/tests/Feature/Image/ImageTransformsTest.php @@ -2,17 +2,16 @@ declare(strict_types=1); -use CraftCms\Cms\Asset\Elements\Asset; use CraftCms\Cms\Asset\Exceptions\ImageTransformException; use CraftCms\Cms\Image\Contracts\ImageTransformerInterface; use CraftCms\Cms\Image\Data\ImageTransform; -use CraftCms\Cms\Image\Events\ImageTransformersResolving; use CraftCms\Cms\Image\Events\TransformDeleted; use CraftCms\Cms\Image\Events\TransformDeleting; use CraftCms\Cms\Image\Events\TransformDeletionApplying; use CraftCms\Cms\Image\Events\TransformSaved; use CraftCms\Cms\Image\Events\TransformSaving; use CraftCms\Cms\Image\ImageTransformer; +use CraftCms\Cms\Image\ImageTransformers; use CraftCms\Cms\Image\ImageTransforms; use CraftCms\Cms\Image\Models\ImageTransform as ImageTransformModel; use CraftCms\Cms\Support\Facades\ImageTransforms as ImageTransformsFacade; @@ -346,38 +345,16 @@ }); describe('getAllImageTransformers', function () { - it('includes the default ImageTransformer', function () { - $transformers = $this->service->getAllImageTransformers(); + it('uses the current registry types', function () { + $registry = app(ImageTransformers::class); + $registry->register(RegisteredImageTransformer::class); - expect($transformers)->toContain(ImageTransformer::class); - }); - - it('fires ImageTransformersResolving event', function () { - Event::fake([ImageTransformersResolving::class]); - - $this->service->getAllImageTransformers(); - - Event::assertDispatchedOnce(ImageTransformersResolving::class); - }); + expect($this->service->getAllImageTransformers()) + ->toContain(ImageTransformer::class, RegisteredImageTransformer::class); - it('allows adding custom transformers via event', function () { - $customTransformer = (new class implements ImageTransformerInterface - { - public function getTransformUrl(Asset $asset, ImageTransform $imageTransform, bool $immediately): string - { - return ''; - } + $registry->remove(RegisteredImageTransformer::class); - public function invalidateAssetTransforms(Asset $asset): void {} - })::class; - - Event::listen(ImageTransformersResolving::class, function (ImageTransformersResolving $event) use ($customTransformer) { - $event->types[] = $customTransformer; - }); - - $transformers = $this->service->getAllImageTransformers(); - - expect($transformers)->toContain($customTransformer); + expect($this->service->getAllImageTransformers())->not()->toContain(RegisteredImageTransformer::class); }); }); @@ -416,3 +393,5 @@ public function invalidateAssetTransforms(Asset $asset): void {} expect($this->service->getAllTransforms())->toBeEmpty(); }); }); + +abstract class RegisteredImageTransformer implements ImageTransformerInterface {} diff --git a/yii2-adapter/legacy/services/ImageTransforms.php b/yii2-adapter/legacy/services/ImageTransforms.php index 19d5ea9e827..f6979d3b128 100644 --- a/yii2-adapter/legacy/services/ImageTransforms.php +++ b/yii2-adapter/legacy/services/ImageTransforms.php @@ -1,6 +1,8 @@ getImageTransforms()`]]. * * @property-read ImageTransformData[] $allTransforms + * * @author Pixel & Tonic, Inc. + * * @since 4.0.0 * @deprecated 6.0.0 use {@see ImageTransformsService} instead. */ @@ -93,9 +98,6 @@ public function getAllTransforms(): array /** * Returns an asset transform by its handle. - * - * @param string $handle - * @return ImageTransformData|null */ public function getTransformByHandle(string $handle): ?ImageTransformData { @@ -104,9 +106,6 @@ public function getTransformByHandle(string $handle): ?ImageTransformData /** * Returns an asset transform by its ID. - * - * @param int $id - * @return ImageTransformData|null */ public function getTransformById(int $id): ?ImageTransformData { @@ -115,9 +114,6 @@ public function getTransformById(int $id): ?ImageTransformData /** * Returns an asset transform by its UID. - * - * @param string $uid - * @return ImageTransformData|null */ public function getTransformByUid(string $uid): ?ImageTransformData { @@ -127,9 +123,8 @@ public function getTransformByUid(string $uid): ?ImageTransformData /** * Saves an asset transform. * - * @param ImageTransformData $transform The transform to be saved - * @param bool $runValidation Whether the transform should be validated - * @return bool + * @param ImageTransformData $transform The transform to be saved + * @param bool $runValidation Whether the transform should be validated */ public function saveTransform(ImageTransformData $transform, bool $runValidation = true): bool { @@ -138,8 +133,6 @@ public function saveTransform(ImageTransformData $transform, bool $runValidation /** * Handle transform change. - * - * @param ConfigEvent $event */ public function handleChangedTransform(ConfigEvent $event): void { @@ -149,7 +142,7 @@ public function handleChangedTransform(ConfigEvent $event): void /** * Deletes an asset transform by its ID. * - * @param int $transformId The transform's ID + * @param int $transformId The transform's ID * @return bool Whether the transform was deleted. */ public function deleteTransformById(int $transformId): bool @@ -160,7 +153,7 @@ public function deleteTransformById(int $transformId): bool /** * Deletes an asset transform. * - * @param ImageTransformData $transform The transform + * @param ImageTransformData $transform The transform * @return bool Whether the transform was deleted */ public function deleteTransform(ImageTransformData $transform): bool @@ -170,8 +163,6 @@ public function deleteTransform(ImageTransformData $transform): bool /** * Handle transform being deleted. - * - * @param ConfigEvent $event */ public function handleDeletedTransform(ConfigEvent $event): void { @@ -181,8 +172,8 @@ public function handleDeletedTransform(ConfigEvent $event): void /** * Eager-loads transform indexes the given list of assets. * - * @param array $assets The assets or asset data to eager-load transforms for - * @param array $transforms The transform definitions to eager-load + * @param array $assets The assets or asset data to eager-load transforms for + * @param array $transforms The transform definitions to eager-load */ public function eagerLoadTransforms(array $assets, array $transforms): void { @@ -191,8 +182,8 @@ public function eagerLoadTransforms(array $assets, array $transforms): void /** * @template T of ImageTransformerInterface - * @param class-string $type - * @param array $config + * + * @param class-string $type * @return T */ public function getImageTransformer(string $type, array $config = []): ImageTransformerInterface @@ -202,8 +193,6 @@ public function getImageTransformer(string $type, array $config = []): ImageTran /** * Delete *ALL* transform data (including thumbs and sources) associated with the Asset. - * - * @param Asset $asset */ public function deleteAllTransformData(Asset $asset): void { @@ -212,8 +201,6 @@ public function deleteAllTransformData(Asset $asset): void /** * Delete all the generated thumbnails for the Asset. - * - * @param Asset $asset */ public function deleteResizedAssetVersion(Asset $asset): void { @@ -222,8 +209,6 @@ public function deleteResizedAssetVersion(Asset $asset): void /** * Delete created transforms for an Asset. - * - * @param Asset $asset */ public function deleteCreatedTransformsForAsset(Asset $asset): void { @@ -234,6 +219,7 @@ public function deleteCreatedTransformsForAsset(Asset $asset): void * Return all available image transformers. * * @return string[] + * * @phpstan-return class-string[] */ public function getAllImageTransformers(): array @@ -304,16 +290,12 @@ public static function registerEvents(): void 'asset' => $event->asset, ])); }); + } - EventFacade::listen(ImageTransformersResolving::class, function(ImageTransformersResolving $event) { - if (!Craft::$app->getImageTransforms()->hasEventHandlers(self::EVENT_REGISTER_IMAGE_TRANSFORMERS)) { - return; - } - - $legacyEvent = new RegisterComponentTypesEvent(['types' => $event->types]); - Craft::$app->getImageTransforms()->trigger(self::EVENT_REGISTER_IMAGE_TRANSFORMERS, $legacyEvent); - $event->types = $legacyEvent->types; - }); + /** @internal */ + public static function finalizeRegistrationEvents(): void + { + TypeRegistryCompatibility::reconcile(app(ImageTransformers::class), Craft::$app->getImageTransforms(), self::EVENT_REGISTER_IMAGE_TRANSFORMERS); } private function service(): ImageTransformsService From e281536f5913d81909557b054bc7466ef5d83b73 Mon Sep 17 00:00:00 2001 From: Rias Date: Sat, 25 Jul 2026 09:06:01 +0200 Subject: [PATCH 10/21] Register utility types and cache options explicitly --- src/Plugin/Concerns/HasUtilities.php | 11 +- .../Events/ClearCachesOptionsResolving.php | 29 -- .../Events/ClearCachesTagOptionsResolving.php | 25 -- src/Utility/Events/UtilitiesResolving.php | 31 -- src/Utility/Utilities.php | 52 +-- src/Utility/Utilities/ClearCaches.php | 136 ++++++-- tests/Feature/Utility/UtilitiesTest.php | 50 +-- .../Unit/Plugin/Concerns/HasUtilitiesTest.php | 49 ++- tests/Unit/Utility/ClearCachesTest.php | 40 +++ .../events/RegisterCacheOptionsEvent.php | 2 +- yii2-adapter/legacy/services/Utilities.php | 28 +- yii2-adapter/legacy/utilities/ClearCaches.php | 24 +- .../src/Utility/LegacyUtilityTypes.php | 38 +++ .../Legacy/TypeRegistryCompatibilityTest.php | 319 ++++++++++++++++++ 14 files changed, 577 insertions(+), 257 deletions(-) delete mode 100644 src/Utility/Events/ClearCachesOptionsResolving.php delete mode 100644 src/Utility/Events/ClearCachesTagOptionsResolving.php delete mode 100644 src/Utility/Events/UtilitiesResolving.php create mode 100644 tests/Unit/Utility/ClearCachesTest.php create mode 100644 yii2-adapter/src/Utility/LegacyUtilityTypes.php create mode 100644 yii2-adapter/tests-laravel/Legacy/TypeRegistryCompatibilityTest.php diff --git a/src/Plugin/Concerns/HasUtilities.php b/src/Plugin/Concerns/HasUtilities.php index 8757bc710bc..b4efdf5f0d2 100644 --- a/src/Plugin/Concerns/HasUtilities.php +++ b/src/Plugin/Concerns/HasUtilities.php @@ -5,9 +5,8 @@ namespace CraftCms\Cms\Plugin\Concerns; use CraftCms\Cms\Plugin\Plugin; -use CraftCms\Cms\Utility\Events\UtilitiesResolving; use CraftCms\Cms\Utility\Utility; -use Illuminate\Support\Facades\Event; +use CraftCms\Cms\Utility\UtilityTypes; /** * @mixin Plugin @@ -25,12 +24,6 @@ trait HasUtilities public function bootHasUtilities(): void { - if (! $this->utilities) { - return; - } - - Event::listen(UtilitiesResolving::class, function (UtilitiesResolving $event) { - $event->types->push(...$this->utilities); - }); + $this->app->make(UtilityTypes::class)->register(...$this->utilities); } } diff --git a/src/Utility/Events/ClearCachesOptionsResolving.php b/src/Utility/Events/ClearCachesOptionsResolving.php deleted file mode 100644 index b563dda2f21..00000000000 --- a/src/Utility/Events/ClearCachesOptionsResolving.php +++ /dev/null @@ -1,29 +0,0 @@ -types[] = MyUtilityType::class; - * }); - * ``` - */ -class UtilitiesResolving -{ - public function __construct( - public Collection $types, - ) {} -} diff --git a/src/Utility/Utilities.php b/src/Utility/Utilities.php index 13cc85cbd3a..46058b529a8 100644 --- a/src/Utility/Utilities.php +++ b/src/Utility/Utilities.php @@ -5,21 +5,7 @@ namespace CraftCms\Cms\Utility; use CraftCms\Cms\Config\GeneralConfig; -use CraftCms\Cms\Edition; -use CraftCms\Cms\Support\Facades\Volumes; -use CraftCms\Cms\Utility\Events\UtilitiesResolving; -use CraftCms\Cms\Utility\Utilities\AssetIndexes; -use CraftCms\Cms\Utility\Utilities\ClearCaches; -use CraftCms\Cms\Utility\Utilities\DbBackup; -use CraftCms\Cms\Utility\Utilities\DeprecationErrors; -use CraftCms\Cms\Utility\Utilities\FindAndReplace; -use CraftCms\Cms\Utility\Utilities\Migrations; -use CraftCms\Cms\Utility\Utilities\PhpInfo; use CraftCms\Cms\Utility\Utilities\ProjectConfig as ProjectConfigUtility; -use CraftCms\Cms\Utility\Utilities\QueueManager; -use CraftCms\Cms\Utility\Utilities\SystemMessages as SystemMessagesUtility; -use CraftCms\Cms\Utility\Utilities\SystemReport; -use CraftCms\Cms\Utility\Utilities\Updates as UpdatesUtility; use Illuminate\Container\Attributes\Singleton; use Illuminate\Support\Collection; @@ -30,6 +16,7 @@ { public function __construct( private GeneralConfig $generalConfig, + private UtilityTypes $utilityTypes, ) {} /** @@ -39,42 +26,7 @@ public function __construct( */ public function getAllUtilityTypes(): Collection { - $utilityTypes = Collection::make() - ->push( - UpdatesUtility::class, - SystemReport::class, - ProjectConfigUtility::class, - PhpInfo::class, - ) - ->when( - Edition::isAtLeast(Edition::Pro), - fn (Collection $c) => $c->push(SystemMessagesUtility::class) - ) - ->unless( - Volumes::getAllVolumes()->isEmpty(), - fn (Collection $c) => $c->push(AssetIndexes::class) - ) - ->push( - QueueManager::class, - ClearCaches::class, - DeprecationErrors::class, - ) - ->when( - $this->generalConfig->backupCommand !== false, - fn (Collection $c) => $c->push(DbBackup::class) - ) - ->push( - FindAndReplace::class, - Migrations::class, - ); - - event($event = new UtilitiesResolving($utilityTypes)); - - $disabledUtilities = array_flip($this->generalConfig->disabledUtilities); - - return $event->types - /** @var class-string $class */ - ->filter(fn (string $class) => ! isset($disabledUtilities[$class::id()]) && $class::isSelectable()); + return $this->utilityTypes->types(); } /** diff --git a/src/Utility/Utilities/ClearCaches.php b/src/Utility/Utilities/ClearCaches.php index 13b08c1c87d..3866837a6ec 100644 --- a/src/Utility/Utilities/ClearCaches.php +++ b/src/Utility/Utilities/ClearCaches.php @@ -4,6 +4,7 @@ namespace CraftCms\Cms\Utility\Utilities; +use Closure; use CraftCms\Aliases\Aliases; use CraftCms\Cms\Cms; use CraftCms\Cms\Database\Table; @@ -11,21 +12,42 @@ use CraftCms\Cms\Support\File; use CraftCms\Cms\Support\Html; use CraftCms\Cms\Support\Str; -use CraftCms\Cms\Utility\Events\ClearCachesOptionsResolving; -use CraftCms\Cms\Utility\Events\ClearCachesTagOptionsResolving; use CraftCms\Cms\Utility\Utility; use Illuminate\Support\Facades\Cache; use Illuminate\Support\Facades\DB; +use InvalidArgumentException; use Override; use Symfony\Component\Filesystem\Path; use function CraftCms\Cms\t; /** - * ClearCaches represents a ClearCaches dashboard widget. + * Provides cache-clearing actions and registers additional cache options. + * + * ```php + * public function boot(): void + * { + * ClearCaches::add('my-plugin', [ + * 'label' => 'My Plugin caches', + * 'action' => MyCache::clear(...), + * ]); + * } + * ``` */ class ClearCaches extends Utility { + /** @var array */ + private static array $additionalCacheOptions = []; + + /** @var array */ + private static array $additionalTagOptions = []; + + /** @var (Closure(array): array)|null */ + private static ?Closure $optionTransformer = null; + + /** @var (Closure(array): array)|null */ + private static ?Closure $tagTransformer = null; + #[Override] public static function displayName(): string { @@ -78,16 +100,77 @@ public static function contentHtml(): string */ public static function cacheOptions(): array { - /** - * TODO: Remove this when dependency on app('Craft') is gone. - */ - if (app()->runningUnitTests()) { - return []; + $options = app()->runningUnitTests() ? [] : array_column(self::defaultCacheOptions(), null, 'key'); + + foreach (self::$additionalCacheOptions as $key => $option) { + $option = $option instanceof Closure ? app()->call($option) : $option; + $action = $option['action'] ?? null; + + if (! isset($option['label']) || + ! is_string($option['label']) || + (! is_string($action) && ! is_callable($action)) + ) { + throw new InvalidArgumentException("Invalid cache option [$key]."); + } + + $options[$key] = [...$option, 'key' => $key]; + } + + $options = array_values($options); + + if (isset(self::$optionTransformer)) { + $options = app()->call(self::$optionTransformer, ['options' => $options]); } + return Arr::sort($options, 'label'); + } + + /** + * @param array{label:string, action:callable|string, info?:string, params?:array}|Closure():array{label:string, action:callable|string, info?:string, params?:array} $option + */ + public static function add(string $key, array|Closure $option): void + { + if ($key === '') { + throw new InvalidArgumentException('Cache option keys cannot be empty.'); + } + + self::$additionalCacheOptions[$key] = $option; + } + + public static function addTag(string $tag, string|Closure $label): void + { + if ($tag === '') { + throw new InvalidArgumentException('Cache tags cannot be empty.'); + } + + self::$additionalTagOptions[$tag] = $label; + } + + /** @internal */ + public static function transformOptions(Closure $transformer): void + { + self::$optionTransformer = $transformer; + } + + /** @internal */ + public static function transformTagOptions(Closure $transformer): void + { + self::$tagTransformer = $transformer; + } + + public static function flushState(): void + { + self::$additionalCacheOptions = []; + self::$additionalTagOptions = []; + self::$optionTransformer = null; + self::$tagTransformer = null; + } + + private static function defaultCacheOptions(): array + { $pathService = app(\CraftCms\Cms\Support\Path::class); - $options = [ + return [ [ 'key' => 'data', 'label' => t('Data caches'), @@ -165,10 +248,6 @@ public static function cacheOptions(): array }, ], ]; - - event($event = new ClearCachesOptionsResolving($options)); - - return Arr::sort($event->options, 'label'); } /** @@ -177,21 +256,32 @@ public static function cacheOptions(): array public static function tagOptions(): array { $options = [ - [ - 'tag' => 'template', - 'label' => t('Template caches'), - ], + 'template' => t('Template caches'), ]; if (Cms::config()->enableGql) { - $options[] = [ - 'tag' => 'graphql', - 'label' => t('GraphQL queries'), - ]; + $options['graphql'] = t('GraphQL queries'); } - event($event = new ClearCachesTagOptionsResolving($options)); + foreach (self::$additionalTagOptions as $tag => $label) { + $label = $label instanceof Closure ? app()->call($label) : $label; + + if (! is_string($label)) { + throw new InvalidArgumentException("Invalid cache tag label [$tag]."); + } + + $options[$tag] = $label; + } + + $options = collect($options) + ->map(fn (string $label, string $tag) => compact('tag', 'label')) + ->values() + ->all(); + + if (isset(self::$tagTransformer)) { + $options = app()->call(self::$tagTransformer, ['options' => $options]); + } - return Arr::sort($event->options, 'label'); + return Arr::sort($options, 'label'); } } diff --git a/tests/Feature/Utility/UtilitiesTest.php b/tests/Feature/Utility/UtilitiesTest.php index 02f960976fa..32fafc25e04 100644 --- a/tests/Feature/Utility/UtilitiesTest.php +++ b/tests/Feature/Utility/UtilitiesTest.php @@ -5,13 +5,12 @@ use CraftCms\Cms\Cms; use CraftCms\Cms\Edition; use CraftCms\Cms\User\Elements\User; -use CraftCms\Cms\Utility\Events\UtilitiesResolving; use CraftCms\Cms\Utility\Utilities; use CraftCms\Cms\Utility\Utilities\AssetIndexes; +use CraftCms\Cms\Utility\Utilities\DbBackup; use CraftCms\Cms\Utility\Utilities\SystemMessages; -use CraftCms\Cms\Utility\Utilities\SystemReport; use CraftCms\Cms\Utility\Utility; -use Illuminate\Support\Facades\Event; +use CraftCms\Cms\Utility\UtilityTypes; use function Pest\Laravel\actingAs; @@ -19,10 +18,6 @@ $this->utilities = app(Utilities::class); }); -it('can get all utility types', function () { - expect($this->utilities->getAllUtilityTypes())->not()->toBeEmpty(); -}); - it('contains system messages when craft is pro', function () { Edition::set(Edition::Solo); @@ -33,18 +28,20 @@ expect($this->utilities->getAllUtilityTypes())->toContain(SystemMessages::class); }); -it('does not contains assetIndexes utility when there are no volumes', function () { +it('does not contain the asset indexes utility when there are no volumes', function () { expect($this->utilities->getAllUtilityTypes())->not()->toContain(AssetIndexes::class); }); -it('can register extra utilities', function () { - expect($this->utilities->getAllUtilityTypes())->not()->toContain(DummyUtility::class); +it('filters unavailable registered utilities', function () { + app(UtilityTypes::class)->register(DummyUtility::class, UnselectableUtility::class); - Event::listen(UtilitiesResolving::class, function (UtilitiesResolving $event) { - $event->types[] = DummyUtility::class; - }); + Cms::config()->backupCommand = false; + Cms::config()->disabledUtilities[] = DummyUtility::id(); - expect($this->utilities->getAllUtilityTypes())->toContain(DummyUtility::class); + expect($this->utilities->getAllUtilityTypes()) + ->not()->toContain(DbBackup::class) + ->not()->toContain(DummyUtility::class) + ->not()->toContain(UnselectableUtility::class); }); it('can get authorized utility types', function () { @@ -55,16 +52,6 @@ expect($this->utilities->getAuthorizedUtilityTypes())->not()->toBeEmpty(); }); -test('disabled utilities are not included', function () { - actingAs(User::find()->one()); - - expect($this->utilities->getAuthorizedUtilityTypes())->toContain(SystemReport::class); - - Cms::config()->disabledUtilities[] = 'system-report'; - - expect($this->utilities->getAuthorizedUtilityTypes())->not()->toContain(SystemReport::class); -}); - class DummyUtility extends Utility { #[Override] @@ -85,3 +72,18 @@ public static function contentHtml(): string return ''; } } + +class UnselectableUtility extends DummyUtility +{ + #[Override] + public static function id(): string + { + return 'unselectable'; + } + + #[Override] + public static function isSelectable(): bool + { + return false; + } +} diff --git a/tests/Unit/Plugin/Concerns/HasUtilitiesTest.php b/tests/Unit/Plugin/Concerns/HasUtilitiesTest.php index d4112d2570e..065c5d4263b 100644 --- a/tests/Unit/Plugin/Concerns/HasUtilitiesTest.php +++ b/tests/Unit/Plugin/Concerns/HasUtilitiesTest.php @@ -2,19 +2,15 @@ declare(strict_types=1); +use CraftCms\Cms\Support\Facades\Volumes; use CraftCms\Cms\Tests\TestClasses\TestPlugin\src\TestPlugin; -use CraftCms\Cms\Utility\Events\UtilitiesResolving; -use CraftCms\Cms\Utility\Utilities\PhpInfo; -use Illuminate\Contracts\Events\Dispatcher; +use CraftCms\Cms\Utility\Utilities; +use CraftCms\Cms\Utility\Utility; use Illuminate\Support\Collection; beforeEach(function () { app()->forgetInstance(TestPlugin::class); -}); - -afterEach(function () { - app(Dispatcher::class)->forget(UtilitiesResolving::class); - app()->forgetInstance(TestPlugin::class); + Volumes::shouldReceive('getAllVolumes')->andReturn(Collection::make()); }); it('registers configured utility types', function () { @@ -23,25 +19,26 @@ 'name' => 'Test Plugin', ]); - $plugin->setUtilities([PhpInfo::class]); + $plugin->setUtilities([TestPluginUtilityType::class]); $plugin->bootHasUtilities(); - $event = new UtilitiesResolving(new Collection); - event($event); - - expect($event->types->all())->toContain(PhpInfo::class); + expect(app(Utilities::class)->getAllUtilityTypes())->toContain(TestPluginUtilityType::class); }); -it('does not register utility listeners when none are configured', function () { - $plugin = TestPlugin::create([ - 'handle' => 'test-plugin', - 'name' => 'Test Plugin', - ]); - - $plugin->bootHasUtilities(); - - $event = new UtilitiesResolving(new Collection); - event($event); - - expect($event->types->all())->toBe([]); -}); +class TestPluginUtilityType extends Utility +{ + public static function displayName(): string + { + return 'Test plugin utility'; + } + + public static function id(): string + { + return 'test-plugin-utility'; + } + + public static function contentHtml(): string + { + return ''; + } +} diff --git a/tests/Unit/Utility/ClearCachesTest.php b/tests/Unit/Utility/ClearCachesTest.php new file mode 100644 index 00000000000..72057f01cf2 --- /dev/null +++ b/tests/Unit/Utility/ClearCachesTest.php @@ -0,0 +1,40 @@ + 'Plugin caches', + 'action' => fn () => null, + ]; + }); + + expect($resolved)->toBeFalse(); + + $option = ClearCaches::cacheOptions()[0]; + + expect(Arr::only($option, ['key', 'label']))->toBe([ + 'label' => 'Plugin caches', + 'key' => 'plugin', + ])->and($resolved)->toBeTrue(); +}); + +it('adds cache tags lazily', function () { + ClearCaches::addTag('plugin', fn () => 'Plugin caches'); + + expect(ClearCaches::tagOptions())->toContain([ + 'tag' => 'plugin', + 'label' => 'Plugin caches', + ]); +}); diff --git a/yii2-adapter/legacy/events/RegisterCacheOptionsEvent.php b/yii2-adapter/legacy/events/RegisterCacheOptionsEvent.php index 6a27b08e8b4..ac372c8866f 100644 --- a/yii2-adapter/legacy/events/RegisterCacheOptionsEvent.php +++ b/yii2-adapter/legacy/events/RegisterCacheOptionsEvent.php @@ -14,7 +14,7 @@ * * @author Pixel & Tonic, Inc. * @since 3.0.0 - * @deprecated in 6.0.0. [[\CraftCms\Cms\Utility\Events\ClearCachesOptionsResolving]] or [[\CraftCms\Cms\Utility\Events\ClearCachesTagOptionsResolving]] should be used instead. + * @deprecated in 6.0.0. [[\CraftCms\Cms\Utility\Utilities\ClearCaches::add()]] or [[\CraftCms\Cms\Utility\Utilities\ClearCaches::addTag()]] should be used instead. */ class RegisterCacheOptionsEvent extends Event { diff --git a/yii2-adapter/legacy/services/Utilities.php b/yii2-adapter/legacy/services/Utilities.php index 39914a91a09..f5794af6090 100644 --- a/yii2-adapter/legacy/services/Utilities.php +++ b/yii2-adapter/legacy/services/Utilities.php @@ -1,6 +1,8 @@ + * * @since 3.0.0 * @deprecated in 6.0.0. [[\CraftCms\Cms\Utility\Utilities]] should be used instead. */ @@ -66,7 +67,7 @@ public function getAllUtilityTypes(): array /** * Returns all utility type classes that the user has permission to use. * - * @return array> + * @return array> */ public function getAuthorizedUtilityTypes(): array { @@ -76,7 +77,7 @@ public function getAuthorizedUtilityTypes(): array /** * Returns whether the current user is authorized to use a given utility. * - * @param class-string<\craft\base\UtilityInterface> $class The utility class + * @param class-string $class The utility class */ public function checkAuthorization(string $class): bool { @@ -87,26 +88,11 @@ public function checkAuthorization(string $class): bool /** * Returns a utility class by its ID * - * @return class-string<\craft\base\UtilityInterface>|null + * @return class-string|null */ public function getUtilityTypeById(string $id): ?string { /** @phpstan-ignore-next-line */ return $this->utilities->getUtilityTypeById($id); } - - public static function registerEvents(): void - { - EventFacade::listen(UtilitiesResolving::class, function(UtilitiesResolving $event) { - $yiiEvent = new RegisterComponentTypesEvent(['types' => $event->types->all()]); - - Craft::$app->getUtilities()->trigger(self::EVENT_REGISTER_UTILITIES, $yiiEvent); - - $event->types = Collection::make($yiiEvent->types); - - if ($yiiEvent->handled) { - return false; - } - }); - } } diff --git a/yii2-adapter/legacy/utilities/ClearCaches.php b/yii2-adapter/legacy/utilities/ClearCaches.php index e88e44a8ae8..d4db01fd520 100644 --- a/yii2-adapter/legacy/utilities/ClearCaches.php +++ b/yii2-adapter/legacy/utilities/ClearCaches.php @@ -9,8 +9,6 @@ use craft\base\Utility; use craft\events\RegisterCacheOptionsEvent; -use CraftCms\Cms\Utility\Events\ClearCachesOptionsResolving; -use CraftCms\Cms\Utility\Events\ClearCachesTagOptionsResolving; use yii\base\Event; /** @@ -104,28 +102,18 @@ public static function tagOptions(): array public static function registerEvents(): void { - // Fire a 'registerCacheOptions' event - \Illuminate\Support\Facades\Event::listen(ClearCachesOptionsResolving::class, function(ClearCachesOptionsResolving $event) { - $yiiEvent = new RegisterCacheOptionsEvent(['options' => $event->options]); + \CraftCms\Cms\Utility\Utilities\ClearCaches::transformOptions(function(array $options): array { + $yiiEvent = new RegisterCacheOptionsEvent(['options' => $options]); Event::trigger(self::class, self::EVENT_REGISTER_CACHE_OPTIONS, $yiiEvent); - $event->options = $yiiEvent->options; - - if ($yiiEvent->handled) { - return false; - } + return $yiiEvent->options; }); - // Fire a 'registerTagOptions' event - \Illuminate\Support\Facades\Event::listen(ClearCachesTagOptionsResolving::class, function(ClearCachesTagOptionsResolving $event) { - $yiiEvent = new RegisterCacheOptionsEvent(['options' => $event->options]); + \CraftCms\Cms\Utility\Utilities\ClearCaches::transformTagOptions(function(array $options): array { + $yiiEvent = new RegisterCacheOptionsEvent(['options' => $options]); Event::trigger(self::class, self::EVENT_REGISTER_TAG_OPTIONS, $yiiEvent); - $event->options = $yiiEvent->options; - - if ($yiiEvent->handled) { - return false; - } + return $yiiEvent->options; }); } } diff --git a/yii2-adapter/src/Utility/LegacyUtilityTypes.php b/yii2-adapter/src/Utility/LegacyUtilityTypes.php new file mode 100644 index 00000000000..417e0b20b6c --- /dev/null +++ b/yii2-adapter/src/Utility/LegacyUtilityTypes.php @@ -0,0 +1,38 @@ +getUtilities(); + + if (!$service->hasEventHandlers(LegacyUtilities::EVENT_REGISTER_UTILITIES)) { + return $types; + } + + $event = new RegisterComponentTypesEvent(['types' => $types->all()]); + $service->trigger(LegacyUtilities::EVENT_REGISTER_UTILITIES, $event); + $disabledUtilities = array_flip(Cms::config()->disabledUtilities); + + return collect($event->types) + /** @var class-string $type */ + ->filter(fn(string $type) => !isset($disabledUtilities[$type::id()]) && $type::isSelectable()) + ->values(); + } +} diff --git a/yii2-adapter/tests-laravel/Legacy/TypeRegistryCompatibilityTest.php b/yii2-adapter/tests-laravel/Legacy/TypeRegistryCompatibilityTest.php new file mode 100644 index 00000000000..d69e7dd7872 --- /dev/null +++ b/yii2-adapter/tests-laravel/Legacy/TypeRegistryCompatibilityTest.php @@ -0,0 +1,319 @@ +andReturn(Collection::make())->byDefault(); +}); + +afterEach(function() { + foreach ([ + LegacyElements::class => LegacyElements::EVENT_REGISTER_ELEMENT_TYPES, + LegacyFields::class => LegacyFields::EVENT_REGISTER_FIELD_TYPES, + LegacyDashboard::class => LegacyDashboard::EVENT_REGISTER_WIDGET_TYPES, + LegacyUtilities::class => LegacyUtilities::EVENT_REGISTER_UTILITIES, + LegacyFilesystems::class => LegacyFilesystems::EVENT_REGISTER_FILESYSTEM_TYPES, + LegacyImageTransforms::class => LegacyImageTransforms::EVENT_REGISTER_IMAGE_TRANSFORMERS, + LegacyLink::class => LegacyLink::EVENT_REGISTER_LINK_TYPES, + ] as $class => $event) { + YiiEvent::off($class, $event); + } +}); + +it('exposes Craft 5 element aliases through the legacy service and event', function() { + $eventTypes = []; + YiiEvent::on(LegacyElements::class, LegacyElements::EVENT_REGISTER_ELEMENT_TYPES, function(RegisterComponentTypesEvent $event) use (&$eventTypes) { + $eventTypes = $event->types; + }); + + LegacyElements::finalizeRegistrationEvents(); + + expect($eventTypes) + ->toContain(craft\elements\Address::class, craft\elements\Entry::class) + ->not()->toContain(Address::class, Entry::class) + ->and(Craft::$app->getElements()->getAllElementTypes()) + ->toContain(craft\elements\Address::class, craft\elements\Entry::class) + ->not()->toContain(Address::class, Entry::class) + ->and(app(ElementTypes::class)->types()) + ->toContain(Address::class, Entry::class); +}); + +it('applies legacy utility listeners to the currently available types', function() { + $seenTypes = []; + YiiEvent::on(LegacyUtilities::class, LegacyUtilities::EVENT_REGISTER_UTILITIES, function(RegisterComponentTypesEvent $event) use (&$seenTypes) { + $seenTypes[] = $event->types; + $event->types[] = AdapterDisabledUtility::class; + $key = array_search(SystemReport::class, $event->types, true); + + if ($key !== false) { + unset($event->types[$key]); + } + }); + + $config = Cms::config(); + $disabledUtilities = $config->disabledUtilities; + + try { + $first = app(UtilityTypes::class)->types(); + $config->disabledUtilities = [PhpInfo::id(), AdapterDisabledUtility::id()]; + $second = app(UtilityTypes::class)->types(); + + expect($first)->toContain(PhpInfo::class, AdapterDisabledUtility::class) + ->not()->toContain(SystemReport::class) + ->and($second)->not()->toContain(PhpInfo::class, SystemReport::class, AdapterDisabledUtility::class) + ->and($seenTypes[0])->toContain(PhpInfo::class, SystemReport::class) + ->and($seenTypes[1])->not()->toContain(PhpInfo::class); + } finally { + $config->disabledUtilities = $disabledUtilities; + } +}); + +class AdapterDisabledUtility extends Utility +{ + public static function displayName(): string + { + return 'Adapter utility'; + } + + public static function id(): string + { + return 'adapter-utility'; + } + + public static function contentHtml(): string + { + return ''; + } +} + +it('applies the legacy link type event through the registry and live link catalog while preserving the URL type', function() { + app(LinkTypes::class)->register(AdapterRegistryLink::class); + + $eventCalls = 0; + $modernTypeWasVisible = false; + + YiiEvent::on(LegacyLink::class, LegacyLink::EVENT_REGISTER_LINK_TYPES, function(RegisterComponentTypesEvent $event) use (&$eventCalls, &$modernTypeWasVisible) { + $eventCalls++; + $modernTypeWasVisible = in_array(AdapterRegistryLink::class, $event->types, true); + $event->types = [AdapterRegistryLink::class]; + }); + + LegacyLink::finalizeRegistrationEvents(); + + $registryTypes = app(LinkTypes::class)->types(); + $types = LegacyLink::types(); + + expect($registryTypes)->toContain(AdapterRegistryLink::class, Url::class) + ->not()->toContain(LinkAsset::class) + ->and($types)->toHaveKey('adapterRegistry', AdapterRegistryLink::class) + ->toHaveKey('url', Url::class) + ->not()->toHaveKeys(['asset', 'email', 'entry', 'phone', 'sms']) + ->and(array_key_last($types))->toBe('url') + ->and($eventCalls)->toBe(1) + ->and($modernTypeWasVisible)->toBeTrue(); + + YiiEvent::off(LegacyLink::class, LegacyLink::EVENT_REGISTER_LINK_TYPES); + + expect(LegacyLink::types())->toHaveKey('adapterRegistry', AdapterRegistryLink::class) + ->toHaveKey('url', Url::class) + ->not()->toHaveKeys(['asset', 'email', 'entry', 'phone', 'sms']); +}); + +it('rejects legacy link types that claim the protected URL identity', function() { + $registry = app(LinkTypes::class); + $types = $registry->types()->all(); + + YiiEvent::on(LegacyLink::class, LegacyLink::EVENT_REGISTER_LINK_TYPES, function(RegisterComponentTypesEvent $event) { + $event->types[] = AdapterUrlRegistryLink::class; + }); + + expect(fn() => LegacyLink::finalizeRegistrationEvents()) + ->toThrow(InvalidArgumentException::class) + ->and($registry->types()->all())->toBe($types) + ->and(LegacyLink::types()['url'])->toBe(Url::class); +}); + +it('applies legacy type registration events to a fresh modern registry snapshot', function( + string $registryClass, + string $modernType, + string $legacyServiceClass, + string $legacyEvent, + string $serviceClass, + string $getter, + string $retainedType, + string $removedType, +) { + $registry = app($registryClass); + $registry->register($modernType, $retainedType); + + $calls = 0; + $modernTypeWasVisible = []; + + YiiEvent::on($legacyServiceClass, $legacyEvent, function(RegisterComponentTypesEvent $event) use ( + &$calls, + &$modernTypeWasVisible, + $modernType, + $retainedType, + ) { + $calls++; + $modernTypeWasVisible[] = in_array($modernType, $event->types, true); + $event->types = [$modernType, $retainedType]; + }); + + $legacyServiceClass::finalizeRegistrationEvents(); + + $service = app($serviceClass); + $firstRead = $registry->types(); + $secondRead = $service->{$getter}(); + $firstTypes = $firstRead instanceof Collection ? $firstRead->all() : $firstRead; + $secondTypes = $secondRead instanceof Collection ? $secondRead->all() : $secondRead; + + expect($firstTypes) + ->toContain($modernType, $retainedType) + ->not()->toContain($removedType) + ->and($secondTypes) + ->toContain($modernType, $retainedType) + ->not()->toContain($removedType) + ->and($calls)->toBe(1) + ->and($modernTypeWasVisible)->toBe([true]); + + YiiEvent::off($legacyServiceClass, $legacyEvent); + + $thirdRead = $registry->types(); + $thirdTypes = $thirdRead instanceof Collection ? $thirdRead->all() : $thirdRead; + + expect($thirdTypes)->toContain($modernType, $retainedType) + ->not()->toContain($removedType); +})->with([ + 'elements' => [ + ElementTypes::class, + AdapterRegistryElement::class, + LegacyElements::class, + LegacyElements::EVENT_REGISTER_ELEMENT_TYPES, + Elements::class, + 'getAllElementTypes', + Entry::class, + Address::class, + ], + 'fields' => [ + FieldTypes::class, + AdapterRegistryField::class, + LegacyFields::class, + LegacyFields::EVENT_REGISTER_FIELD_TYPES, + Fields::class, + 'getAllFieldTypes', + PlainText::class, + Color::class, + ], + 'widgets' => [ + WidgetTypes::class, + AdapterRegistryWidget::class, + LegacyDashboard::class, + LegacyDashboard::EVENT_REGISTER_WIDGET_TYPES, + LegacyDashboard::class, + 'getAllWidgetTypes', + Feed::class, + CraftSupport::class, + ], + 'filesystems' => [ + FilesystemTypes::class, + AdapterRegistryFilesystem::class, + LegacyFilesystems::class, + LegacyFilesystems::EVENT_REGISTER_FILESYSTEM_TYPES, + Filesystems::class, + 'getAllFilesystemTypes', + AdapterRetainedFilesystem::class, + Local::class, + ], + 'image transformers' => [ + ImageTransformers::class, + AdapterRegistryImageTransformer::class, + LegacyImageTransforms::class, + LegacyImageTransforms::EVENT_REGISTER_IMAGE_TRANSFORMERS, + ImageTransforms::class, + 'getAllImageTransformers', + AdapterRetainedImageTransformer::class, + ImageTransformer::class, + ], +]); + +abstract class AdapterRegistryElement extends Element +{ +} + +abstract class AdapterRegistryField extends Field +{ +} + +abstract class AdapterRegistryWidget extends Widget +{ +} + +abstract class AdapterRegistryFilesystem extends Local +{ +} + +abstract class AdapterRetainedFilesystem extends Local +{ +} + +abstract class AdapterRegistryImageTransformer implements ImageTransformerInterface +{ +} + +abstract class AdapterRetainedImageTransformer implements ImageTransformerInterface +{ +} + +class AdapterRegistryLink extends Url +{ + public static function id(): string + { + return 'adapterRegistry'; + } +} + +class AdapterUrlRegistryLink extends Url +{ +} From 2c767ca5dff6eeb7b6d350c11c072f16cead32bc Mon Sep 17 00:00:00 2001 From: Rias Date: Sat, 25 Jul 2026 09:06:18 +0200 Subject: [PATCH 11/21] Register GraphQL extensions explicitly --- src/Gql/ArgumentManager.php | 63 +++--- .../Events/GqlArgumentHandlersResolving.php | 16 -- src/Gql/Events/GqlDirectivesResolving.php | 16 -- src/Gql/Events/GqlMutationsResolving.php | 16 -- src/Gql/Events/GqlQueriesResolving.php | 16 -- src/Gql/Events/GqlTypesResolving.php | 16 -- src/Gql/Gql.php | 119 +++------- src/Gql/GqlArguments.php | 83 +++++++ src/Gql/GqlEntityRegistry.php | 13 ++ .../Controllers/Gql/SchemasController.php | 11 +- tests/Feature/Gql/ArgumentManagerTest.php | 213 +++++++++++++----- tests/Feature/Gql/GqlTest.php | 108 ++++++--- tests/TestClasses/Gql/MockType.php | 3 +- tests/Unit/Gql/GqlArgumentsTest.php | 82 +++++++ .../RegisterGqlArgumentHandlersEvent.php | 5 +- .../events/RegisterGqlDirectivesEvent.php | 2 +- yii2-adapter/legacy/services/Gql.php | 74 ++---- yii2-adapter/src/Event/LegacyGqlEvents.php | 15 -- yii2-adapter/src/Gql/LegacyGql.php | 48 ++++ yii2-adapter/src/Gql/LegacyGqlArguments.php | 31 +++ yii2-adapter/src/Gql/LegacyGqlDirectives.php | 33 +++ yii2-adapter/src/Gql/LegacyGqlTypes.php | 32 +++ .../Gql/LegacyGqlCompatibilityTest.php | 171 +++++++++++++- .../tests/unit/gql/ArgumentHandlerTest.php | 14 +- 24 files changed, 822 insertions(+), 378 deletions(-) delete mode 100644 src/Gql/Events/GqlArgumentHandlersResolving.php delete mode 100644 src/Gql/Events/GqlDirectivesResolving.php delete mode 100644 src/Gql/Events/GqlMutationsResolving.php delete mode 100644 src/Gql/Events/GqlQueriesResolving.php delete mode 100644 src/Gql/Events/GqlTypesResolving.php create mode 100644 src/Gql/GqlArguments.php create mode 100644 tests/Unit/Gql/GqlArgumentsTest.php create mode 100644 yii2-adapter/src/Gql/LegacyGql.php create mode 100644 yii2-adapter/src/Gql/LegacyGqlArguments.php create mode 100644 yii2-adapter/src/Gql/LegacyGqlDirectives.php create mode 100644 yii2-adapter/src/Gql/LegacyGqlTypes.php diff --git a/src/Gql/ArgumentManager.php b/src/Gql/ArgumentManager.php index 076bab189fc..6d258d43d6b 100644 --- a/src/Gql/ArgumentManager.php +++ b/src/Gql/ArgumentManager.php @@ -4,22 +4,18 @@ namespace CraftCms\Cms\Gql; +use Closure; use CraftCms\Cms\Component\Component; use CraftCms\Cms\Gql\Contracts\ArgumentHandlerInterface; -use CraftCms\Cms\Gql\Events\GqlArgumentHandlersResolving; use CraftCms\Cms\Gql\Exceptions\GqlException; -use CraftCms\Cms\Gql\Handlers\RelatedAssets; -use CraftCms\Cms\Gql\Handlers\RelatedEntries; -use CraftCms\Cms\Gql\Handlers\RelatedUsers; use CraftCms\Cms\Gql\Handlers\RelationArgumentHandler; -use CraftCms\Cms\Gql\Handlers\Site; -use CraftCms\Cms\Gql\Handlers\SiteId; use CraftCms\Cms\Support\Str; +use InvalidArgumentException; class ArgumentManager extends Component { /** - * @var array|ArgumentHandlerInterface> + * @var array|Closure|ArgumentHandlerInterface> */ private array $_argumentHandlers = []; @@ -29,16 +25,7 @@ public function __construct(array|object $config = []) { parent::__construct($config); - $this->_argumentHandlers = [ - 'relatedToEntries' => RelatedEntries::class, - 'relatedToAssets' => RelatedAssets::class, - 'relatedToUsers' => RelatedUsers::class, - 'site' => Site::class, - 'siteId' => SiteId::class, - ]; - - event($event = new GqlArgumentHandlersResolving(handlers: $this->_argumentHandlers)); - $this->_argumentHandlers = $event->handlers; + $this->_argumentHandlers = app(GqlArguments::class)->handlers()->all(); } protected function createHandlers(): void @@ -47,24 +34,17 @@ protected function createHandlers(): void return; } - foreach ($this->_argumentHandlers as &$handler) { - // Instantiate in place, if a class name is added. - if (is_string($handler)) { - $handler = $this->createHandler($handler); - } + foreach ($this->_argumentHandlers as $argumentName => &$handler) { + $handler = $this->resolveHandler($argumentName, $handler); } unset($handler); $this->_handlersCreated = true; } - public function setHandler(string $argumentName, ArgumentHandlerInterface|string $handler): void + public function setHandler(string $argumentName, ArgumentHandlerInterface|string|Closure $handler): void { - if (is_string($handler)) { - $handler = $this->createHandler($handler); - } - - $this->_argumentHandlers[$argumentName] = $handler; + $this->_argumentHandlers[$argumentName] = $this->resolveHandler($argumentName, $handler); } /** @@ -121,10 +101,33 @@ public function prepareArguments(array $arguments): array protected function createHandler(string $handler): ArgumentHandlerInterface|string { if (is_a($handler, ArgumentHandlerInterface::class, true)) { - $handler = new $handler; - $handler->setArgumentManager($this); + return app()->build($handler); } return $handler; } + + /** + * @param class-string|Closure|ArgumentHandlerInterface $handler + */ + private function resolveHandler(string $argumentName, ArgumentHandlerInterface|string|Closure $handler): ArgumentHandlerInterface + { + $handler = match (true) { + $handler instanceof Closure => app()->call($handler, [self::class => $this]), + is_string($handler) => $this->createHandler($handler), + default => $handler, + }; + + if (! $handler instanceof ArgumentHandlerInterface) { + throw new InvalidArgumentException(sprintf( + 'Argument handler [%s] must resolve to an instance of [%s].', + $argumentName, + ArgumentHandlerInterface::class, + )); + } + + $handler->setArgumentManager($this); + + return $handler; + } } diff --git a/src/Gql/Events/GqlArgumentHandlersResolving.php b/src/Gql/Events/GqlArgumentHandlersResolving.php deleted file mode 100644 index b2130e0a456..00000000000 --- a/src/Gql/Events/GqlArgumentHandlersResolving.php +++ /dev/null @@ -1,16 +0,0 @@ - */ - public array $handlers, - ) {} -} diff --git a/src/Gql/Events/GqlDirectivesResolving.php b/src/Gql/Events/GqlDirectivesResolving.php deleted file mode 100644 index 0a5e14ea6b7..00000000000 --- a/src/Gql/Events/GqlDirectivesResolving.php +++ /dev/null @@ -1,16 +0,0 @@ - */ - public array $directives, - ) {} -} diff --git a/src/Gql/Events/GqlMutationsResolving.php b/src/Gql/Events/GqlMutationsResolving.php deleted file mode 100644 index df07fc9ab21..00000000000 --- a/src/Gql/Events/GqlMutationsResolving.php +++ /dev/null @@ -1,16 +0,0 @@ - */ - public array $mutations, - ) {} -} diff --git a/src/Gql/Events/GqlQueriesResolving.php b/src/Gql/Events/GqlQueriesResolving.php deleted file mode 100644 index 52f75aaf0b2..00000000000 --- a/src/Gql/Events/GqlQueriesResolving.php +++ /dev/null @@ -1,16 +0,0 @@ - */ - public array $queries, - ) {} -} diff --git a/src/Gql/Events/GqlTypesResolving.php b/src/Gql/Events/GqlTypesResolving.php deleted file mode 100644 index f0e015ee94c..00000000000 --- a/src/Gql/Events/GqlTypesResolving.php +++ /dev/null @@ -1,16 +0,0 @@ - */ - public array $types, - ) {} -} diff --git a/src/Gql/Gql.php b/src/Gql/Gql.php index 15cfcb5d407..c9b9cf4beba 100644 --- a/src/Gql/Gql.php +++ b/src/Gql/Gql.php @@ -22,40 +22,14 @@ use CraftCms\Cms\Gql\Data\GqlSchema; use CraftCms\Cms\Gql\Data\GqlToken; use CraftCms\Cms\Gql\Directives\Directive; -use CraftCms\Cms\Gql\Directives\FormatDateTime; -use CraftCms\Cms\Gql\Directives\Markdown; -use CraftCms\Cms\Gql\Directives\Money; -use CraftCms\Cms\Gql\Directives\ParseRefs; -use CraftCms\Cms\Gql\Directives\StripTags; -use CraftCms\Cms\Gql\Directives\Transform; -use CraftCms\Cms\Gql\Directives\Trim; use CraftCms\Cms\Gql\Events\ExecutedGqlQuery; -use CraftCms\Cms\Gql\Events\GqlDirectivesResolving; -use CraftCms\Cms\Gql\Events\GqlMutationsResolving; -use CraftCms\Cms\Gql\Events\GqlQueriesResolving; use CraftCms\Cms\Gql\Events\GqlQueryExecuting; use CraftCms\Cms\Gql\Events\GqlSchemaComponentsResolving; -use CraftCms\Cms\Gql\Events\GqlTypesResolving; use CraftCms\Cms\Gql\Events\GqlValidationRulesResolving; use CraftCms\Cms\Gql\Exceptions\GqlException; -use CraftCms\Cms\Gql\Interfaces\Element as ElementInterface; -use CraftCms\Cms\Gql\Interfaces\Elements\Address as AddressInterface; -use CraftCms\Cms\Gql\Interfaces\Elements\Asset as AssetInterface; -use CraftCms\Cms\Gql\Interfaces\Elements\Entry as EntryInterface; -use CraftCms\Cms\Gql\Interfaces\Elements\User as UserInterface; use CraftCms\Cms\Gql\Models\GqlSchema as GqlSchemaModel; use CraftCms\Cms\Gql\Models\GqlToken as GqlTokenModel; -use CraftCms\Cms\Gql\Mutations\Asset as AssetMutation; -use CraftCms\Cms\Gql\Mutations\Entry as EntryMutation; -use CraftCms\Cms\Gql\Mutations\Ping as PingMutation; -use CraftCms\Cms\Gql\Queries\Address as AddressQuery; -use CraftCms\Cms\Gql\Queries\Asset as AssetQuery; -use CraftCms\Cms\Gql\Queries\Entry as EntryQuery; -use CraftCms\Cms\Gql\Queries\Ping as PingQuery; -use CraftCms\Cms\Gql\Queries\User as UserQuery; -use CraftCms\Cms\Gql\Types\DateTime; use CraftCms\Cms\Gql\Types\Mutation; -use CraftCms\Cms\Gql\Types\Number; use CraftCms\Cms\Gql\Types\ObjectType; use CraftCms\Cms\Gql\Types\Query; use CraftCms\Cms\Gql\Types\QueryArgument; @@ -164,6 +138,10 @@ class Gql public function __construct( private readonly ProjectConfig $projectConfig, private readonly ElementCaches $elementCaches, + private readonly GqlDirectives $gqlDirectives, + private readonly GqlTypes $gqlTypes, + private readonly GqlQueries $gqlQueries, + private readonly GqlMutations $gqlMutations, ) {} /** @@ -1044,27 +1022,22 @@ private function _getCacheKey( return $cacheKey; } + protected function queryDefinitions(): array + { + return $this->operationDefinitions($this->gqlQueries->types(), 'getQueries'); + } + + protected function mutationDefinitions(): array + { + return $this->operationDefinitions($this->gqlMutations->types(), 'getMutations'); + } + /** * @return array the list of registered types. */ private function _registerGqlTypes(): array { - $types = [ - // Scalars - DateTime::class, - Number::class, - QueryArgument::class, - - // Interfaces - AddressInterface::class, - ElementInterface::class, - EntryInterface::class, - AssetInterface::class, - UserInterface::class, - ]; - - event($event = new GqlTypesResolving(types: $types)); - $types = $event->types; + $types = $this->gqlTypes->types()->all(); foreach ($types as $type) { /** @var class-string $type */ @@ -1076,40 +1049,28 @@ private function _registerGqlTypes(): array private function _registerGqlQueries(): void { - $queryList = [ - // Queries - AddressQuery::getQueries(), - PingQuery::getQueries(), - EntryQuery::getQueries(), - AssetQuery::getQueries(), - UserQuery::getQueries(), - ]; - - // Flatten them - $queries = array_merge(...$queryList); - - event($event = new GqlQueriesResolving(queries: $queries)); - $queries = $event->queries; + $queries = $this->queryDefinitions(); TypeLoader::registerType('Query', fn () => call_user_func(Query::class.'::getType', $queries)); } private function _registerGqlMutations(): void { - $mutationList = [ - // Mutations - PingMutation::getMutations(), - EntryMutation::getMutations(), - AssetMutation::getMutations(), - ]; + $mutations = $this->mutationDefinitions(); + + TypeLoader::registerType('Mutation', fn () => call_user_func(Mutation::class.'::getType', $mutations)); + } - // Flatten them - $mutations = array_merge(...$mutationList); + /** @param iterable $providers */ + private function operationDefinitions(iterable $providers, string $method): array + { + $definitions = []; - event($event = new GqlMutationsResolving(mutations: $mutations)); - $mutations = $event->mutations; + foreach ($providers as $provider) { + $definitions = array_merge($definitions, $provider::$method()); + } - TypeLoader::registerType('Mutation', fn () => call_user_func(Mutation::class.'::getType', $mutations)); + return $definitions; } /** @@ -1118,27 +1079,9 @@ private function _registerGqlMutations(): void private function _loadGqlDirectives(?GqlSchema $schema): array { /** @var class-string[] $directiveClasses */ - $directiveClasses = [ - // Directives - FormatDateTime::class, - Markdown::class, - Money::class, - StripTags::class, - Trim::class, - ]; - - if ($schema !== null) { - if (in_array('directive:parseRefs', $schema->scope)) { - $directiveClasses[] = ParseRefs::class; - } - - if (in_array('directive:transform', $schema->scope)) { - $directiveClasses[] = Transform::class; - } - } - - event($event = new GqlDirectivesResolving(directives: $directiveClasses)); - $directiveClasses = $event->directives; + $directiveClasses = $this->gqlDirectives + ->forSchema($schema) + ->all(); $directives = GqlDirective::builtInDirectives(); diff --git a/src/Gql/GqlArguments.php b/src/Gql/GqlArguments.php new file mode 100644 index 00000000000..e1a518ee601 --- /dev/null +++ b/src/Gql/GqlArguments.php @@ -0,0 +1,83 @@ +register('relatedToProducts', RelatedProducts::class); + * } + * ``` + */ +#[Singleton] +class GqlArguments +{ + /** @var array|Closure> */ + private array $handlers = []; + + public function __construct() + { + $this->register('relatedToEntries', RelatedEntries::class); + $this->register('relatedToAssets', RelatedAssets::class); + $this->register('relatedToUsers', RelatedUsers::class); + $this->register('site', Site::class); + $this->register('siteId', SiteId::class); + } + + /** @param class-string|Closure $handler */ + public function register(string $argumentName, string|Closure $handler): void + { + $this->validateName($argumentName); + $this->validateHandler($handler); + + $this->handlers[$argumentName] = $handler; + } + + public function remove(string ...$argumentNames): void + { + foreach ($argumentNames as $argumentName) { + $this->validateName($argumentName); + } + + foreach ($argumentNames as $argumentName) { + unset($this->handlers[$argumentName]); + } + } + + /** @return Collection|Closure> */ + public function handlers(): Collection + { + return new Collection($this->handlers); + } + + private function validateName(string $argumentName): void + { + if ($argumentName === '') { + throw new InvalidArgumentException('Argument handler names cannot be empty.'); + } + } + + /** @param class-string|Closure $handler */ + private function validateHandler(string|Closure $handler): void + { + if (is_string($handler) && ! is_a($handler, ArgumentHandlerInterface::class, true)) { + throw new InvalidArgumentException(sprintf('Argument handler [%s] must implement [%s].', $handler, ArgumentHandlerInterface::class)); + } + } +} diff --git a/src/Gql/GqlEntityRegistry.php b/src/Gql/GqlEntityRegistry.php index 19ecd71dcd2..d5925f0befd 100644 --- a/src/Gql/GqlEntityRegistry.php +++ b/src/Gql/GqlEntityRegistry.php @@ -6,6 +6,19 @@ use CraftCms\Cms\Cms; +/** + * Stores generated GraphQL entities for reuse while building a schema. + * + * ```php + * public static function create(): ObjectType + * { + * return GqlEntityRegistry::getOrCreate('Product', fn () => new ObjectType([ + * 'name' => 'Product', + * 'fields' => [], + * ])); + * } + * ``` + */ class GqlEntityRegistry { private static array $_entities = []; diff --git a/src/Http/Controllers/Gql/SchemasController.php b/src/Http/Controllers/Gql/SchemasController.php index 1abf3f95c3f..717f50d84df 100644 --- a/src/Http/Controllers/Gql/SchemasController.php +++ b/src/Http/Controllers/Gql/SchemasController.php @@ -10,6 +10,7 @@ use CraftCms\Cms\Http\RespondsWithFlash; use CraftCms\Cms\Http\Responses\CpScreenResponse; use CraftCms\Cms\Support\DateTimeHelper; +use CraftCms\Cms\Support\Str; use CraftCms\Cms\Support\Url; use CraftCms\Cms\User\Data\Permission; use CraftCms\Cms\User\Data\PermissionGroup; @@ -183,8 +184,9 @@ private function schemaPermissionGroups(): Collection return $this->permissionGroups($schemaComponents['queries']) ->merge($this->permissionGroups($schemaComponents['mutations'])) ->push(new PermissionGroup( - t('Optional Features'), - $this->permissionList($optionalPermissions), + handle: 'optionalFeatures', + heading: t('Optional Features'), + permissions: $this->permissionList($optionalPermissions), )); } @@ -194,8 +196,9 @@ private function permissionGroups(array $categories): Collection return collect($categories) ->filter() ->map(fn (array $permissions, string $heading) => new PermissionGroup( - $heading, - $this->permissionList($permissions), + handle: Str::toHandle($heading), + heading: $heading, + permissions: $this->permissionList($permissions), )) ->values(); } diff --git a/tests/Feature/Gql/ArgumentManagerTest.php b/tests/Feature/Gql/ArgumentManagerTest.php index c187ba2da87..9d53f5c4f0c 100644 --- a/tests/Feature/Gql/ArgumentManagerTest.php +++ b/tests/Feature/Gql/ArgumentManagerTest.php @@ -3,75 +3,100 @@ declare(strict_types=1); use CraftCms\Cms\Asset\Elements\Asset; -use CraftCms\Cms\Cms; use CraftCms\Cms\Entry\Elements\Entry; use CraftCms\Cms\Gql\ArgumentManager; use CraftCms\Cms\Gql\Contracts\ArgumentHandlerInterface; -use CraftCms\Cms\Gql\Data\GqlSchema; -use CraftCms\Cms\Gql\Events\GqlArgumentHandlersResolving; -use CraftCms\Cms\Gql\Events\GqlQueriesResolving; -use CraftCms\Cms\Gql\Gql; +use CraftCms\Cms\Gql\GqlArguments; use CraftCms\Cms\Gql\Handlers\RelatedAssets; use CraftCms\Cms\Gql\Handlers\RelatedEntries; -use GraphQL\Type\Definition\ResolveInfo; -use GraphQL\Type\Definition\Type; -use Illuminate\Support\Facades\Event; - -beforeEach(function () { - app(Gql::class)->flushCaches(); - app(Gql::class)->setActiveSchema(new GqlSchema); - Cms::config()->enableGraphqlCaching = false; +use CraftCms\Cms\Gql\Handlers\RelationArgumentHandler; + +it('supports class and factory handlers registered by argument name', function () { + $registry = app(GqlArguments::class); + $registry->register('initial', MultiplyArgumentHandler::class); + + expect(new ArgumentManager()->prepareArguments([ + 'initial' => 5, + 'multiplier' => 2, + ]))->toBe([ + 'initial' => 5, + 'multiplier' => 2, + 'result' => 10, + ]); + + $factoryManager = null; + $factory = function (ArgumentManager $argumentManager) use (&$factoryManager) { + $factoryManager = $argumentManager; + + return new ReplaceArgumentHandler; + }; + $registry->register('initial', $factory); + $argumentManager = new ArgumentManager; + + expect($argumentManager->prepareArguments([ + 'initial' => 5, + 'multiplier' => 2, + ]))->toBe([ + 'initial' => 5, + 'multiplier' => 2, + 'result' => 99, + ])->and($factoryManager)->toBe($argumentManager); }); -it('registers custom argument handlers for gql execution', function () { - Event::listen(GqlQueriesResolving::class, function (GqlQueriesResolving $event) { - $event->queries['integrationQuery'] = [ - 'type' => Type::string(), - 'args' => [ - 'initial' => Type::int(), - 'multiplier' => Type::int(), - 'result' => Type::int(), - 'wipeInitial' => Type::boolean(), - ], - 'resolve' => function ($source, array $arguments, $context, ResolveInfo $resolveInfo) { - if (! empty($context['argumentManager'])) { - $arguments = $context['argumentManager']->prepareArguments($arguments); - } - - ksort($arguments); - - return json_encode($arguments); - }, - ]; - }); +it('injects container dependencies into class handlers', function () { + $dependency = new ArgumentHandlerDependency; + app()->instance(ArgumentHandlerDependency::class, $dependency); + app(GqlArguments::class)->register('containerBuilt', ContainerBuiltArgumentHandler::class); - $handler = new class implements ArgumentHandlerInterface - { - public function handleArgumentCollection(array $argumentList = []): array - { - $argumentList['result'] = $argumentList['initial'] * $argumentList['multiplier']; + expect(new ArgumentManager()->prepareArguments(['containerBuilt' => true])) + ->toHaveKey('dependency', $dependency); +}); - if (! empty($argumentList['wipeInitial'])) { - unset($argumentList['initial']); - } +it('reuses relation handler memoization within one manager but not across managers', function () { + MemoizedRelationArgumentHandler::$lookups = 0; + app(GqlArguments::class)->register('memoizedRelation', MemoizedRelationArgumentHandler::class); - return $argumentList; - } + $firstManager = new ArgumentManager; + $secondManager = new ArgumentManager; + $firstManager->prepareArguments(['memoizedRelation' => [1]]); + $firstManager->prepareArguments(['memoizedRelation' => [1]]); + $secondManager->prepareArguments(['memoizedRelation' => [1]]); - public function setArgumentManager(ArgumentManager $argumentManager): void {} - }; + expect(MemoizedRelationArgumentHandler::$lookups)->toBe(2); +}); + +it('binds manager-local handlers when they are set', function () { + $argumentManager = new ArgumentManager; + $argumentManager->prepareArguments([]); + + $argumentManager->setHandler('object', new ManagerLocalArgumentHandler); + $argumentManager->setHandler('class', ManagerLocalArgumentHandler::class); + $factoryReceivedManager = false; + $argumentManager->setHandler('factory', function (ArgumentManager $manager) use (&$factoryReceivedManager, $argumentManager) { + $factoryReceivedManager = $manager === $argumentManager; - Event::listen(GqlArgumentHandlersResolving::class, function (GqlArgumentHandlersResolving $event) use ($handler) { - $event->handlers['initial'] = $handler; + return new ManagerLocalArgumentHandler; }); - $result = app(Gql::class)->executeQuery(new GqlSchema(['id' => 1]), '{integrationQuery (initial: 5 multiplier: 2)}'); + expect($argumentManager->prepareArguments([ + 'object' => true, + 'class' => true, + 'factory' => true, + ]))->toHaveKey('boundHandlers', 3) + ->and($factoryReceivedManager)->toBeTrue(); +}); - expect(json_decode((string) $result['data']['integrationQuery'], true))->toBe([ - 'initial' => 5, - 'multiplier' => 2, - 'result' => 10, - ]); +it('rejects invalid factory results when handlers are first resolved', function () { + app(GqlArguments::class)->register('invalid', fn () => new stdClass); + + expect(fn () => new ArgumentManager()->prepareArguments(['invalid' => true])) + ->toThrow( + InvalidArgumentException::class, + sprintf( + 'Argument handler [invalid] must resolve to an instance of [%s].', + ArgumentHandlerInterface::class, + ), + ); }); it('prepares relation arguments with the registered handlers', function () { @@ -96,9 +121,6 @@ protected function getIds(string $elementType, array $criteriaList = []): array } }; - $relatedAssets->setArgumentManager($argumentManager); - $relatedEntries->setArgumentManager($argumentManager); - $argumentManager->setHandler('relatedToAssets', $relatedAssets); $argumentManager->setHandler('relatedToEntries', $relatedEntries); @@ -114,3 +136,80 @@ protected function getIds(string $elementType, array $criteriaList = []): array ], ]); }); + +class MultiplyArgumentHandler implements ArgumentHandlerInterface +{ + public function handleArgumentCollection(array $argumentList = []): array + { + $argumentList['result'] = $argumentList['initial'] * $argumentList['multiplier']; + + return $argumentList; + } + + public function setArgumentManager(ArgumentManager $argumentManager): void {} +} + +class ReplaceArgumentHandler implements ArgumentHandlerInterface +{ + public function handleArgumentCollection(array $argumentList = []): array + { + $argumentList['result'] = 99; + + return $argumentList; + } + + public function setArgumentManager(ArgumentManager $argumentManager): void {} +} + +class ArgumentHandlerDependency {} + +class ContainerBuiltArgumentHandler implements ArgumentHandlerInterface +{ + public function __construct( + public ArgumentHandlerDependency $dependency, + ) {} + + public function handleArgumentCollection(array $argumentList = []): array + { + $argumentList['dependency'] = $this->dependency; + + return $argumentList; + } + + public function setArgumentManager(ArgumentManager $argumentManager): void {} +} + +class MemoizedRelationArgumentHandler extends RelationArgumentHandler +{ + public static int $lookups = 0; + + #[Override] + protected string $argumentName = 'memoizedRelation'; + + #[Override] + protected function handleArgument($argumentValue): mixed + { + self::$lookups++; + + return [[$argumentValue[0]]]; + } +} + +class ManagerLocalArgumentHandler implements ArgumentHandlerInterface +{ + public ?ArgumentManager $argumentManager = null; + + public function handleArgumentCollection(array $argumentList = []): array + { + if ($this->argumentManager !== null) { + $argumentList['boundHandlers'] = ($argumentList['boundHandlers'] ?? 0) + 1; + } + + return $argumentList; + } + + public function setArgumentManager(ArgumentManager $argumentManager): void + { + $this->argumentManager = $argumentManager; + } +} diff --git a/tests/Feature/Gql/GqlTest.php b/tests/Feature/Gql/GqlTest.php index a6aaf7404d9..f8db804672a 100644 --- a/tests/Feature/Gql/GqlTest.php +++ b/tests/Feature/Gql/GqlTest.php @@ -10,17 +10,19 @@ use CraftCms\Cms\Gql\Data\GqlSchema; use CraftCms\Cms\Gql\Data\GqlToken; use CraftCms\Cms\Gql\Events\ExecutedGqlQuery; -use CraftCms\Cms\Gql\Events\GqlDirectivesResolving; -use CraftCms\Cms\Gql\Events\GqlMutationsResolving; -use CraftCms\Cms\Gql\Events\GqlQueriesResolving; use CraftCms\Cms\Gql\Events\GqlQueryExecuting; use CraftCms\Cms\Gql\Events\GqlSchemaComponentsResolving; -use CraftCms\Cms\Gql\Events\GqlTypesResolving; use CraftCms\Cms\Gql\Events\GqlValidationRulesResolving; use CraftCms\Cms\Gql\Exceptions\GqlException; use CraftCms\Cms\Gql\Gql; +use CraftCms\Cms\Gql\GqlDirectives; use CraftCms\Cms\Gql\GqlEntityRegistry; +use CraftCms\Cms\Gql\GqlMutations; +use CraftCms\Cms\Gql\GqlQueries; +use CraftCms\Cms\Gql\GqlTypes; use CraftCms\Cms\Gql\Interfaces\Elements\User as UserInterface; +use CraftCms\Cms\Gql\Mutations\Mutation as BaseMutation; +use CraftCms\Cms\Gql\Queries\Query as BaseQuery; use CraftCms\Cms\Gql\TypeLoader; use CraftCms\Cms\Section\Data\Section; use CraftCms\Cms\Section\Enums\SectionType; @@ -52,49 +54,51 @@ app(Gql::class)->getActiveSchema(); })->throws(GqlException::class, 'No schema is active.'); -it('dispatches query registration events', function () { - Event::listen(GqlQueriesResolving::class, function (GqlQueriesResolving $event) { - $event->queries['mockQuery'] = [ - 'type' => Type::string(), - 'args' => [], - 'resolve' => static fn () => 'mocked', - ]; - }); +it('uses registered query providers', function () { + app(GqlQueries::class)->register(MockQuery::class); $queries = app(Gql::class)->getSchemaDef()->getQueryType()->getFields(); expect($queries)->toHaveKey('mockQuery'); }); -it('dispatches mutation registration events', function () { - Event::listen(GqlMutationsResolving::class, function (GqlMutationsResolving $event) { - $event->mutations['mockMutation'] = [ - 'type' => Type::string(), - 'args' => [], - 'resolve' => static fn () => 'mocked', - ]; - }); +it('uses registered mutation providers', function () { + app(GqlMutations::class)->register(MockMutation::class); $mutations = app(Gql::class)->getSchemaDef()->getMutationType()->getFields(); expect($mutations)->toHaveKey('mockMutation'); }); -it('dispatches directive and type registration events', function () { - Event::listen(GqlDirectivesResolving::class, function (GqlDirectivesResolving $event) { - $event->directives[] = MockDirective::class; - }); +it('uses currently registered directives', function () { + $registry = app(GqlDirectives::class); + $registry->register(MockDirective::class); - Event::listen(GqlTypesResolving::class, function (GqlTypesResolving $event) { - $event->types[] = MockType::class; - }); + $gql = app(Gql::class); + $schema = $gql->getSchemaDef(new GqlSchema); + + expect($schema->getDirective(MockDirective::name()))->not()->toBeNull(); + + $registry->remove(MockDirective::class); + $gql->flushCaches(); + + $schema = $gql->getSchemaDef(new GqlSchema([ + 'scope' => ['directive:parseRefs', 'directive:transform'], + ])); + + expect($schema->getDirective(MockDirective::name()))->toBeNull() + ->and($schema->getDirective('parseRefs'))->not->toBeNull() + ->and($schema->getDirective('transform'))->not->toBeNull(); +}); + +it('uses registered types', function () { + app(GqlTypes::class)->register(MockType::class); MockType::getType(); $schema = app(Gql::class)->getSchemaDef(); - expect($schema->getDirective(MockDirective::name()))->not->toBeNull() - ->and($schema->getType(MockType::getName()))->not->toBeNull(); + expect($schema->getType(MockType::getName()))->not->toBeNull(); }); it('dispatches schema component registration events', function () { @@ -118,11 +122,7 @@ }); it('validates schemas when a registered field definition is invalid', function () { - Event::listen(GqlQueriesResolving::class, function (GqlQueriesResolving $event) { - $event->queries['mockQuery'] = [ - 'type' => 'no bueno', - ]; - }); + app(GqlQueries::class)->register(InvalidMockQuery::class); app(Gql::class)->getSchemaDef(null, true); })->throws(GqlException::class); @@ -345,3 +345,43 @@ ->and($gql->deleteSchemaById($schemaId))->toBeTrue() ->and($gql->getSchemaById($schemaId))->toBeNull(); }); + +class MockQuery extends BaseQuery +{ + public static function getQueries(bool $checkToken = true): array + { + return [ + 'mockQuery' => [ + 'type' => Type::string(), + 'args' => [], + 'resolve' => static fn () => 'mocked', + ], + ]; + } +} + +class InvalidMockQuery extends BaseQuery +{ + public static function getQueries(bool $checkToken = true): array + { + return [ + 'mockQuery' => [ + 'type' => 'no bueno', + ], + ]; + } +} + +class MockMutation extends BaseMutation +{ + public static function getMutations(): array + { + return [ + 'mockMutation' => [ + 'type' => Type::string(), + 'args' => [], + 'resolve' => static fn () => 'mocked', + ], + ]; + } +} diff --git a/tests/TestClasses/Gql/MockType.php b/tests/TestClasses/Gql/MockType.php index 459a3be3b52..afcfef601ae 100644 --- a/tests/TestClasses/Gql/MockType.php +++ b/tests/TestClasses/Gql/MockType.php @@ -4,13 +4,14 @@ namespace CraftCms\Cms\Tests\TestClasses\Gql; +use CraftCms\Cms\Gql\Contracts\SingularTypeInterface; use CraftCms\Cms\Gql\GqlEntityRegistry; use GraphQL\Type\Definition\ScalarType; /** * Class MockType */ -class MockType extends ScalarType +class MockType extends ScalarType implements SingularTypeInterface { #[\Override] public string $name = 'mockType'; diff --git a/tests/Unit/Gql/GqlArgumentsTest.php b/tests/Unit/Gql/GqlArgumentsTest.php new file mode 100644 index 00000000000..a2306c8f0fd --- /dev/null +++ b/tests/Unit/Gql/GqlArgumentsTest.php @@ -0,0 +1,82 @@ +handlers()->all())->toBe([ + 'relatedToEntries' => RelatedEntries::class, + 'relatedToAssets' => RelatedAssets::class, + 'relatedToUsers' => RelatedUsers::class, + 'site' => Site::class, + 'siteId' => SiteId::class, + ]); +}); + +it('updates and removes handlers without changing their position or instantiating them', function () { + RegistryArgumentHandler::$instances = 0; + $factoryCalls = 0; + $factory = function () use (&$factoryCalls) { + $factoryCalls++; + + return new RegistryArgumentHandler; + }; + $registry = app(GqlArguments::class); + + $registry->register('custom', RegistryArgumentHandler::class); + $registry->register('afterCustom', $factory); + $keys = $registry->handlers()->keys()->all(); + $registry->register('custom', AnotherRegistryArgumentHandler::class); + $keysAfterUpdate = $registry->handlers()->keys()->all(); + $registry->remove('site', 'missing'); + + $snapshot = $registry->handlers(); + $snapshot->put('snapshotOnly', RegistryArgumentHandler::class); + + expect($keysAfterUpdate)->toBe($keys) + ->and($registry->handlers()->get('custom'))->toBe(AnotherRegistryArgumentHandler::class) + ->and($registry->handlers())->not()->toHaveKey('site') + ->and($registry->handlers())->not()->toHaveKey('snapshotOnly') + ->and(RegistryArgumentHandler::$instances)->toBe(0) + ->and($factoryCalls)->toBe(0); +}); + +it('rejects invalid handler registrations', function (string $name, string $handler) { + expect(fn () => app(GqlArguments::class)->register($name, $handler)) + ->toThrow(InvalidArgumentException::class); +})->with([ + 'empty name' => ['', RegistryArgumentHandler::class], + 'invalid contract' => ['custom', stdClass::class], +]); + +it('rejects invalid handler removals', function () { + expect(fn () => app(GqlArguments::class)->remove('')) + ->toThrow(InvalidArgumentException::class); +}); + +class RegistryArgumentHandler implements ArgumentHandlerInterface +{ + public static int $instances = 0; + + public function __construct() + { + self::$instances++; + } + + public function handleArgumentCollection(array $argumentList = []): array + { + return $argumentList; + } + + public function setArgumentManager(ArgumentManager $argumentManager): void {} +} + +class AnotherRegistryArgumentHandler extends RegistryArgumentHandler {} diff --git a/yii2-adapter/legacy/events/RegisterGqlArgumentHandlersEvent.php b/yii2-adapter/legacy/events/RegisterGqlArgumentHandlersEvent.php index a66ff4c4693..bb6713ecda5 100644 --- a/yii2-adapter/legacy/events/RegisterGqlArgumentHandlersEvent.php +++ b/yii2-adapter/legacy/events/RegisterGqlArgumentHandlersEvent.php @@ -7,6 +7,7 @@ namespace craft\events; +use Closure; use craft\base\Event; use craft\gql\base\ArgumentHandlerInterface; @@ -15,12 +16,12 @@ * * @author Pixel & Tonic, Inc. * @since 3.6.0 - * @deprecated 6.0.0 use {@see \CraftCms\Cms\Gql\Events\GqlArgumentHandlersResolving} instead. + * @deprecated 6.0.0 use {@see \CraftCms\Cms\Gql\GqlArguments::register()} instead. */ class RegisterGqlArgumentHandlersEvent extends Event { /** - * @var array|ArgumentHandlerInterface> List of Argument handler class names. + * @var array|Closure|ArgumentHandlerInterface> List of argument handlers. */ public array $handlers = []; } diff --git a/yii2-adapter/legacy/events/RegisterGqlDirectivesEvent.php b/yii2-adapter/legacy/events/RegisterGqlDirectivesEvent.php index b31f5a7860e..ed274b7c27c 100644 --- a/yii2-adapter/legacy/events/RegisterGqlDirectivesEvent.php +++ b/yii2-adapter/legacy/events/RegisterGqlDirectivesEvent.php @@ -14,7 +14,7 @@ * * @author Pixel & Tonic, Inc. * @since 3.3.0 - * @deprecated 6.0.0 Use {@see \CraftCms\Cms\Gql\Events\GqlDirectivesResolving} instead. + * @deprecated 6.0.0 use {@see \CraftCms\Cms\Gql\GqlDirectives::register()} instead. */ class RegisterGqlDirectivesEvent extends Event { diff --git a/yii2-adapter/legacy/services/Gql.php b/yii2-adapter/legacy/services/Gql.php index 3ce352b5040..ee07d34b7f5 100644 --- a/yii2-adapter/legacy/services/Gql.php +++ b/yii2-adapter/legacy/services/Gql.php @@ -1,31 +1,26 @@ getGql()`]]. * * @author Pixel & Tonic, Inc. + * * @since 3.3.0 - * @deprecated 6.0.0 use {@see \CraftCms\Cms\Gql\Gql} instead. + * @deprecated 6.0.0 use {@see NewGql} instead. */ class Gql extends Component { public const EVENT_REGISTER_GQL_TYPES = 'registerGqlTypes'; + public const EVENT_REGISTER_GQL_QUERIES = 'registerGqlQueries'; + public const EVENT_REGISTER_GQL_MUTATIONS = 'registerGqlMutations'; + public const EVENT_REGISTER_GQL_DIRECTIVES = 'registerGqlDirectives'; + public const EVENT_REGISTER_GQL_SCHEMA_COMPONENTS = 'registerGqlSchemaComponents'; + public const EVENT_DEFINE_GQL_VALIDATION_RULES = 'defineGqlValidationRules'; + public const EVENT_BEFORE_EXECUTE_GQL_QUERY = 'beforeExecuteGqlQuery'; + public const EVENT_AFTER_EXECUTE_GQL_QUERY = 'afterExecuteGqlQuery'; + public const CACHE_TAG = NewGql::CACHE_TAG; + public const GRAPHQL_COUNT_FIELD = NewGql::GRAPHQL_COUNT_FIELD; + public const GRAPHQL_COMPLEXITY_SIMPLE_FIELD = NewGql::GRAPHQL_COMPLEXITY_SIMPLE_FIELD; + public const GRAPHQL_COMPLEXITY_QUERY = NewGql::GRAPHQL_COMPLEXITY_QUERY; + public const GRAPHQL_COMPLEXITY_EAGER_LOAD = NewGql::GRAPHQL_COMPLEXITY_EAGER_LOAD; + public const GRAPHQL_COMPLEXITY_CPU_HEAVY = NewGql::GRAPHQL_COMPLEXITY_CPU_HEAVY; + public const GRAPHQL_COMPLEXITY_NPLUS1 = NewGql::GRAPHQL_COMPLEXITY_NPLUS1; public function getSchemaDef(?GqlSchema $schema = null, bool $prebuildSchema = false): Schema @@ -265,50 +275,6 @@ public function prepareFieldDefinitions(array $fields, string $typeName): array public static function registerEvents(): void { - Event::listen(GqlTypesResolving::class, function(GqlTypesResolving $event) { - $service = self::service(); - if (!$service->hasEventHandlers(self::EVENT_REGISTER_GQL_TYPES)) { - return; - } - - $yiiEvent = new RegisterGqlTypesEvent(['types' => $event->types]); - $service->trigger(self::EVENT_REGISTER_GQL_TYPES, $yiiEvent); - $event->types = $yiiEvent->types; - }); - - Event::listen(GqlQueriesResolving::class, function(GqlQueriesResolving $event) { - $service = self::service(); - if (!$service->hasEventHandlers(self::EVENT_REGISTER_GQL_QUERIES)) { - return; - } - - $yiiEvent = new RegisterGqlQueriesEvent(['queries' => $event->queries]); - $service->trigger(self::EVENT_REGISTER_GQL_QUERIES, $yiiEvent); - $event->queries = $yiiEvent->queries; - }); - - Event::listen(GqlMutationsResolving::class, function(GqlMutationsResolving $event) { - $service = self::service(); - if (!$service->hasEventHandlers(self::EVENT_REGISTER_GQL_MUTATIONS)) { - return; - } - - $yiiEvent = new RegisterGqlMutationsEvent(['mutations' => $event->mutations]); - $service->trigger(self::EVENT_REGISTER_GQL_MUTATIONS, $yiiEvent); - $event->mutations = $yiiEvent->mutations; - }); - - Event::listen(GqlDirectivesResolving::class, function(GqlDirectivesResolving $event) { - $service = self::service(); - if (!$service->hasEventHandlers(self::EVENT_REGISTER_GQL_DIRECTIVES)) { - return; - } - - $yiiEvent = new RegisterGqlDirectivesEvent(['directives' => $event->directives]); - $service->trigger(self::EVENT_REGISTER_GQL_DIRECTIVES, $yiiEvent); - $event->directives = $yiiEvent->directives; - }); - Event::listen(GqlSchemaComponentsResolving::class, function(GqlSchemaComponentsResolving $event) { $service = self::service(); if (!$service->hasEventHandlers(self::EVENT_REGISTER_GQL_SCHEMA_COMPONENTS)) { diff --git a/yii2-adapter/src/Event/LegacyGqlEvents.php b/yii2-adapter/src/Event/LegacyGqlEvents.php index dbcd7b73db0..0407874c58d 100644 --- a/yii2-adapter/src/Event/LegacyGqlEvents.php +++ b/yii2-adapter/src/Event/LegacyGqlEvents.php @@ -8,16 +8,13 @@ use craft\events\DefineGqlArgumentsEvent; use craft\events\DefineGqlTypeFieldsEvent; use craft\events\MutationPopulateElementEvent; -use craft\events\RegisterGqlArgumentHandlersEvent; use craft\events\RegisterGqlEagerLoadableFields as LegacyRegisterGqlEagerLoadableFields; -use craft\gql\ArgumentManager as LegacyArgumentManager; use craft\gql\base\ElementArguments as LegacyElementArguments; use craft\gql\base\ElementMutationResolver as LegacyElementMutationResolver; use craft\gql\ElementQueryConditionBuilder as LegacyElementQueryConditionBuilder; use craft\gql\TypeManager as LegacyTypeManager; use CraftCms\Cms\Gql\Events\ElementPopulated; use CraftCms\Cms\Gql\Events\ElementPopulating; -use CraftCms\Cms\Gql\Events\GqlArgumentHandlersResolving; use CraftCms\Cms\Gql\Events\GqlArgumentsResolving; use CraftCms\Cms\Gql\Events\GqlEagerLoadableFieldsResolving; use CraftCms\Cms\Gql\Events\GqlTypeFieldsResolving; @@ -40,18 +37,6 @@ public static function register(): void $event->fields = $yiiEvent->fields; }); - Event::listen(GqlArgumentHandlersResolving::class, function(GqlArgumentHandlersResolving $event) { - if (!YiiEvent::hasHandlers(LegacyArgumentManager::class, LegacyArgumentManager::EVENT_DEFINE_GQL_ARGUMENT_HANDLERS)) { - return; - } - - $yiiEvent = new RegisterGqlArgumentHandlersEvent([ - 'handlers' => $event->handlers, - ]); - YiiEvent::trigger(LegacyArgumentManager::class, LegacyArgumentManager::EVENT_DEFINE_GQL_ARGUMENT_HANDLERS, $yiiEvent); - $event->handlers = $yiiEvent->handlers; - }); - Event::listen(GqlEagerLoadableFieldsResolving::class, function(GqlEagerLoadableFieldsResolving $event) { if (!YiiEvent::hasHandlers(LegacyElementQueryConditionBuilder::class, LegacyElementQueryConditionBuilder::EVENT_REGISTER_GQL_EAGERLOADABLE_FIELDS)) { return; diff --git a/yii2-adapter/src/Gql/LegacyGql.php b/yii2-adapter/src/Gql/LegacyGql.php new file mode 100644 index 00000000000..bb7d0977959 --- /dev/null +++ b/yii2-adapter/src/Gql/LegacyGql.php @@ -0,0 +1,48 @@ +getGql(); + + if (!$service->hasEventHandlers(LegacyGqlService::EVENT_REGISTER_GQL_QUERIES)) { + return $queries; + } + + $event = new RegisterGqlQueriesEvent(['queries' => $queries]); + $service->trigger(LegacyGqlService::EVENT_REGISTER_GQL_QUERIES, $event); + + return $event->queries; + } + + #[Override] + protected function mutationDefinitions(): array + { + $mutations = parent::mutationDefinitions(); + $service = Craft::$app->getGql(); + + if (!$service->hasEventHandlers(LegacyGqlService::EVENT_REGISTER_GQL_MUTATIONS)) { + return $mutations; + } + + $event = new RegisterGqlMutationsEvent(['mutations' => $mutations]); + $service->trigger(LegacyGqlService::EVENT_REGISTER_GQL_MUTATIONS, $event); + + return $event->mutations; + } +} diff --git a/yii2-adapter/src/Gql/LegacyGqlArguments.php b/yii2-adapter/src/Gql/LegacyGqlArguments.php new file mode 100644 index 00000000000..1138d4dfdc5 --- /dev/null +++ b/yii2-adapter/src/Gql/LegacyGqlArguments.php @@ -0,0 +1,31 @@ + $handlers->all()]); + YiiEvent::trigger(LegacyArgumentManager::class, LegacyArgumentManager::EVENT_DEFINE_GQL_ARGUMENT_HANDLERS, $event); + + return collect($event->handlers); + } +} diff --git a/yii2-adapter/src/Gql/LegacyGqlDirectives.php b/yii2-adapter/src/Gql/LegacyGqlDirectives.php new file mode 100644 index 00000000000..63e25edbf06 --- /dev/null +++ b/yii2-adapter/src/Gql/LegacyGqlDirectives.php @@ -0,0 +1,33 @@ +getGql(); + + if (!$service->hasEventHandlers(LegacyGqlService::EVENT_REGISTER_GQL_DIRECTIVES)) { + return $directives; + } + + $event = new RegisterGqlDirectivesEvent(['directives' => $directives->all()]); + $service->trigger(LegacyGqlService::EVENT_REGISTER_GQL_DIRECTIVES, $event); + + return collect($event->directives)->values(); + } +} diff --git a/yii2-adapter/src/Gql/LegacyGqlTypes.php b/yii2-adapter/src/Gql/LegacyGqlTypes.php new file mode 100644 index 00000000000..61aee1438c9 --- /dev/null +++ b/yii2-adapter/src/Gql/LegacyGqlTypes.php @@ -0,0 +1,32 @@ +getGql(); + + if (!$service->hasEventHandlers(LegacyGqlService::EVENT_REGISTER_GQL_TYPES)) { + return $types; + } + + $event = new RegisterGqlTypesEvent(['types' => $types->all()]); + $service->trigger(LegacyGqlService::EVENT_REGISTER_GQL_TYPES, $event); + + return new Collection($event->types); + } +} diff --git a/yii2-adapter/tests-laravel/Gql/LegacyGqlCompatibilityTest.php b/yii2-adapter/tests-laravel/Gql/LegacyGqlCompatibilityTest.php index 7f21ace4457..2aa1b8fe5d7 100644 --- a/yii2-adapter/tests-laravel/Gql/LegacyGqlCompatibilityTest.php +++ b/yii2-adapter/tests-laravel/Gql/LegacyGqlCompatibilityTest.php @@ -3,16 +3,29 @@ declare(strict_types=1); use craft\events\ExecuteGqlQueryEvent; +use craft\events\RegisterGqlArgumentHandlersEvent; +use craft\events\RegisterGqlDirectivesEvent; +use craft\events\RegisterGqlMutationsEvent; use craft\events\RegisterGqlQueriesEvent; +use craft\events\RegisterGqlTypesEvent; +use craft\gql\ArgumentManager as LegacyArgumentManager; use craft\gql\TypeLoader as LegacyTypeLoader; use craft\helpers\Gql as LegacyGqlHelper; use craft\models\GqlSchema; use craft\models\GqlToken as LegacyGqlToken; use craft\services\Gql as LegacyGql; use CraftCms\Cms\Cms; +use CraftCms\Cms\Gql\ArgumentManager; +use CraftCms\Cms\Gql\Contracts\ArgumentHandlerInterface; use CraftCms\Cms\Gql\Data\GqlToken; +use CraftCms\Cms\Gql\Directives\ParseRefs; +use CraftCms\Cms\Gql\Gql; +use CraftCms\Cms\Gql\GqlArguments; +use CraftCms\Cms\Gql\GqlDirectives; use CraftCms\Cms\Gql\GqlEntityRegistry; use CraftCms\Cms\Tests\TestCase; +use CraftCms\Cms\Tests\TestClasses\Gql\MockDirective; +use CraftCms\Cms\Tests\TestClasses\Gql\MockType; use GraphQL\Type\Definition\ObjectType; use GraphQL\Type\Definition\Type; use yii\base\Event; @@ -20,8 +33,8 @@ uses(TestCase::class); beforeEach(function() { - app(\CraftCms\Cms\Gql\Gql::class)->flushCaches(); - app(\CraftCms\Cms\Gql\Gql::class)->setActiveSchema(new GqlSchema()); + app(Gql::class)->flushCaches(); + app(Gql::class)->setActiveSchema(new GqlSchema()); Cms::config()->enableGraphqlCaching = false; }); @@ -37,7 +50,7 @@ Event::on(LegacyGql::class, LegacyGql::EVENT_REGISTER_GQL_QUERIES, $handler); try { - $queries = app(\CraftCms\Cms\Gql\Gql::class)->getSchemaDef()->getQueryType()->getFields(); + $queries = app(Gql::class)->getSchemaDef()->getQueryType()->getFields(); expect($queries)->toHaveKey('legacyMockQuery'); } finally { @@ -45,8 +58,103 @@ } }); +it('bridges legacy mutation registration listeners', function() { + $handler = function(RegisterGqlMutationsEvent $event) { + $event->mutations['legacyMockMutation'] = [ + 'type' => Type::string(), + 'args' => [], + 'resolve' => static fn() => 'legacy', + ]; + }; + + Event::on(LegacyGql::class, LegacyGql::EVENT_REGISTER_GQL_MUTATIONS, $handler); + + try { + expect(app(Gql::class)->getSchemaDef()->getMutationType()->getFields()) + ->toHaveKey('legacyMockMutation'); + } finally { + Event::off(LegacyGql::class, LegacyGql::EVENT_REGISTER_GQL_MUTATIONS, $handler); + } +}); + +it('bridges legacy type registration listeners', function() { + $handler = function(RegisterGqlTypesEvent $event) { + $event->types[] = MockType::class; + }; + + Event::on(LegacyGql::class, LegacyGql::EVENT_REGISTER_GQL_TYPES, $handler); + + try { + expect(app(Gql::class)->getSchemaDef(null, true)->getType(MockType::getName()))->not()->toBeNull(); + } finally { + Event::off(LegacyGql::class, LegacyGql::EVENT_REGISTER_GQL_TYPES, $handler); + } +}); + +it('applies legacy directive listeners to each schema without changing the registry', function() { + app(GqlDirectives::class)->register(AdapterGqlDirective::class); + + $seenDirectives = []; + $handler = function(RegisterGqlDirectivesEvent $event) use (&$seenDirectives) { + $seenDirectives[] = $event->directives; + $event->directives = []; + }; + + Event::on(LegacyGql::class, LegacyGql::EVENT_REGISTER_GQL_DIRECTIVES, $handler); + + try { + $gql = app(Gql::class); + $gql->getSchemaDef(new GqlSchema(), true); + $gql->flushCaches(); + $gql->getSchemaDef(new GqlSchema([ + 'scope' => ['directive:parseRefs'], + ]), true); + + expect($seenDirectives)->toHaveCount(2) + ->and($seenDirectives[0])->toContain(AdapterGqlDirective::class) + ->not()->toContain(ParseRefs::class) + ->and($seenDirectives[1])->toContain(AdapterGqlDirective::class, ParseRefs::class) + ->and(app(GqlDirectives::class)->types())->toContain(AdapterGqlDirective::class); + } finally { + Event::off(LegacyGql::class, LegacyGql::EVENT_REGISTER_GQL_DIRECTIVES, $handler); + } +}); + +it('uses configured legacy handler instances independently for each manager', function() { + app(GqlArguments::class)->register('adapter', AdapterArgumentHandler::class); + + $registeredHandlerWasVisible = false; + $handler = function(RegisterGqlArgumentHandlersEvent $event) use (&$registeredHandlerWasVisible) { + $registeredHandlerWasVisible = $event->handlers['adapter'] === AdapterArgumentHandler::class; + $event->handlers['adapter'] = new LegacyReplacementArgumentHandler(configuration: 'configured'); + }; + + Event::on(LegacyArgumentManager::class, LegacyArgumentManager::EVENT_DEFINE_GQL_ARGUMENT_HANDLERS, $handler); + + try { + $firstResult = new LegacyArgumentManager()->prepareArguments(['adapter' => true]); + $secondResult = new LegacyArgumentManager()->prepareArguments(['adapter' => true]); + + expect($firstResult)->toMatchArray([ + 'handledBy' => 'legacy', + 'configuration' => 'configured', + 'calls' => 1, + 'managerBound' => true, + ]) + ->and($secondResult)->toMatchArray([ + 'handledBy' => 'legacy', + 'configuration' => 'configured', + 'calls' => 1, + 'managerBound' => true, + ]) + ->and($registeredHandlerWasVisible)->toBeTrue(); + } finally { + Event::off(LegacyArgumentManager::class, LegacyArgumentManager::EVENT_DEFINE_GQL_ARGUMENT_HANDLERS, $handler); + } +}); + it('bridges legacy before-execute listeners', function() { - $schema = app(\CraftCms\Cms\Gql\Gql::class)->getPublicSchema(); + $schema = app(Gql::class)->getPublicSchema(); $handler = function(ExecuteGqlQueryEvent $event) { $event->result = ['data' => 'legacy override']; }; @@ -54,14 +162,14 @@ Event::on(LegacyGql::class, LegacyGql::EVENT_BEFORE_EXECUTE_GQL_QUERY, $handler); try { - expect(app(\CraftCms\Cms\Gql\Gql::class)->executeQuery($schema, '{ping}'))->toBe(['data' => 'legacy override']); + expect(app(Gql::class)->executeQuery($schema, '{ping}'))->toBe(['data' => 'legacy override']); } finally { Event::off(LegacyGql::class, LegacyGql::EVENT_BEFORE_EXECUTE_GQL_QUERY, $handler); } }); it('keeps the legacy gql helper working against the new service', function() { - app(\CraftCms\Cms\Gql\Gql::class)->setActiveSchema(new GqlSchema([ + app(Gql::class)->setActiveSchema(new GqlSchema([ 'scope' => ['sections.news:read'], ])); @@ -70,7 +178,7 @@ }); it('returns gql token aliases from the legacy gql service', function() { - $modernToken = app(\CraftCms\Cms\Gql\Gql::class)->getPublicToken(); + $modernToken = app(Gql::class)->getPublicToken(); $legacyToken = Craft::$app->getGql()->getPublicToken(); expect($modernToken)->toBeInstanceOf(GqlToken::class) @@ -87,3 +195,52 @@ expect(LegacyTypeLoader::loadType('SharedType'))->toBeInstanceOf(ObjectType::class); }); + +class AdapterGqlDirective extends MockDirective +{ + public static function name(): string + { + return 'adapterRegistry'; + } +} + +class AdapterArgumentHandler implements ArgumentHandlerInterface +{ + public function handleArgumentCollection(array $argumentList = []): array + { + $argumentList['handledBy'] = 'modern'; + + return $argumentList; + } + + public function setArgumentManager(ArgumentManager $argumentManager): void + { + } +} + +class LegacyReplacementArgumentHandler extends AdapterArgumentHandler +{ + public ?ArgumentManager $argumentManager = null; + + private int $calls = 0; + + public function __construct( + public string $configuration = '', + ) { + } + + public function handleArgumentCollection(array $argumentList = []): array + { + $argumentList['handledBy'] = 'legacy'; + $argumentList['configuration'] = $this->configuration; + $argumentList['calls'] = ++$this->calls; + $argumentList['managerBound'] = $this->argumentManager !== null; + + return $argumentList; + } + + public function setArgumentManager(ArgumentManager $argumentManager): void + { + $this->argumentManager = $argumentManager; + } +} diff --git a/yii2-adapter/tests/unit/gql/ArgumentHandlerTest.php b/yii2-adapter/tests/unit/gql/ArgumentHandlerTest.php index 007d26b9dcb..ee29532ee8a 100644 --- a/yii2-adapter/tests/unit/gql/ArgumentHandlerTest.php +++ b/yii2-adapter/tests/unit/gql/ArgumentHandlerTest.php @@ -10,7 +10,6 @@ use Craft; use craft\elements\Category; use craft\elements\Tag; -use craft\events\RegisterGqlArgumentHandlersEvent; use craft\events\RegisterGqlQueriesEvent; use craft\gql\ArgumentManager; use craft\gql\base\ArgumentHandlerInterface; @@ -25,6 +24,7 @@ use CraftCms\Cms\Asset\Elements\Asset; use CraftCms\Cms\Cms; use CraftCms\Cms\Entry\Elements\Entry; +use CraftCms\Cms\Gql\GqlArguments; use CraftCms\Cms\User\Elements\User; use Exception; use GraphQL\Type\Definition\ResolveInfo; @@ -81,11 +81,15 @@ public function testArgumentHandlerIntegration(string $argumentString, array $ex }, ]); - Event::on(ArgumentManager::class, ArgumentManager::EVENT_DEFINE_GQL_ARGUMENT_HANDLERS, function(RegisterGqlArgumentHandlersEvent $event) use ($handler) { - $event->handlers['initial'] = $handler; - }); + $registry = app(GqlArguments::class); + $registry->register('initial', static fn() => clone $handler); + + try { + $result = $gql->executeQuery(new GqlSchema(['id' => 1]), "{integrationQuery ($argumentString)}"); + } finally { + $registry->remove('initial'); + } - $result = $gql->executeQuery(new GqlSchema(['id' => 1]), "{integrationQuery ($argumentString)}"); self::assertEquals($expectedResult, json_decode($result['data']['integrationQuery'], true)); } From 573e89f0b4da236dd8b7798970576ede294e90c1 Mon Sep 17 00:00:00 2001 From: Rias Date: Sat, 25 Jul 2026 09:06:36 +0200 Subject: [PATCH 12/21] Move control panel registration into Settings --- src/Cp/Events/RegisterCpSettings.php | 42 ------- src/Cp/Events/RegisterReadonlyCpSettings.php | 19 ---- src/Cp/Settings.php | 104 ++++++++++++++++-- tests/Unit/Cp/SettingsTest.php | 72 ++++++++++++ .../legacy/events/RegisterCpSettingsEvent.php | 2 +- yii2-adapter/legacy/web/twig/variables/Cp.php | 22 ---- yii2-adapter/src/Cp/LegacySettings.php | 33 ++++++ 7 files changed, 201 insertions(+), 93 deletions(-) delete mode 100644 src/Cp/Events/RegisterCpSettings.php delete mode 100644 src/Cp/Events/RegisterReadonlyCpSettings.php create mode 100644 tests/Unit/Cp/SettingsTest.php create mode 100644 yii2-adapter/src/Cp/LegacySettings.php diff --git a/src/Cp/Events/RegisterCpSettings.php b/src/Cp/Events/RegisterCpSettings.php deleted file mode 100644 index cc3606b8079..00000000000 --- a/src/Cp/Events/RegisterCpSettings.php +++ /dev/null @@ -1,42 +0,0 @@ -settings[t('Modules')][] = [ - * 'label' => 'Item Label', - * 'url' => 'my-module', - * 'icon' => '/path/to/icon.svg', - * ]; - * } - * ); - * ``` - * - * [[RegisterCpSettings::$settings]] is an array whose keys define the section labels, and values are sub-arrays that define the - * individual links. - * - * Each link array should have the following keys: - * - * - `label` – The item’s label. - * - `url` – The URL or path of the control panel page the item should link to. - * - `icon` – The path to the SVG icon that should be used for the item. - */ -class RegisterCpSettings -{ - public function __construct( - /** @var array $settings The registered control panel settings */ - public array $settings = [] - ) {} -} diff --git a/src/Cp/Events/RegisterReadonlyCpSettings.php b/src/Cp/Events/RegisterReadonlyCpSettings.php deleted file mode 100644 index e4f1543b871..00000000000 --- a/src/Cp/Events/RegisterReadonlyCpSettings.php +++ /dev/null @@ -1,19 +0,0 @@ -registerSetting('My Plugin', 'general', fn () => [ + * 'label' => 'General', + * 'url' => route('my-plugin.settings'), + * ]); + * } + * ``` + * + * Use {@see registerReadOnlySetting()} for links shown when admin changes are disabled. + */ +#[Singleton] +class Settings { + /** @var array> */ + private array $providers = []; + + /** @var array> */ + private array $readonlyProviders = []; + public function __construct( - private GeneralConfig $generalConfig, - private Plugins $pluginsService + private readonly GeneralConfig $generalConfig, + private readonly Plugins $pluginsService, ) {} + /** @param Closure(): array{label:string, url?:string, icon?:string, iconName?:string} $provider */ + public function registerSetting(string $section, string $handle, Closure $provider): void + { + $this->registerProvider($this->providers, $section, $handle, $provider); + } + + /** @param Closure(): array{label:string, url?:string, icon?:string, iconName?:string} $provider */ + public function registerReadOnlySetting(string $section, string $handle, Closure $provider): void + { + $this->registerProvider($this->readonlyProviders, $section, $handle, $provider); + } + + public function remove(string $section, string ...$handles): void + { + foreach ($handles as $handle) { + unset($this->providers[$section][$handle], $this->readonlyProviders[$section][$handle]); + } + } + public function all(): array { $readOnly = ! $this->generalConfig->allowAdminChanges; @@ -105,10 +147,54 @@ public function all(): array } } - event($event = $readOnly - ? new RegisterReadonlyCpSettings($settings) - : new RegisterCpSettings($settings)); + return $this->apply($settings, $readOnly); + } + + public function apply(array $settings, bool $readOnly): array + { + foreach ($readOnly ? $this->readonlyProviders : $this->providers as $section => $providers) { + $section = t($section); + + foreach ($providers as $handle => $provider) { + $setting = app()->call($provider); + + if (! is_array($setting) || + ! isset($setting['label']) || + ! is_string($setting['label']) || + array_any(['url', 'icon', 'iconName'], fn (string $key) => array_key_exists($key, $setting) && ! is_string($setting[$key])) + ) { + throw new InvalidArgumentException("Invalid CP setting [$section.$handle]."); + } + + if (isset($settings[$section][$handle])) { + throw new InvalidArgumentException("CP setting [$section.$handle] is already registered."); + } + + $settings[$section][$handle] = $setting; + } + } + + return $settings; + } + + /** + * @param array> $providers + * @param Closure(): array{label:string, url?:string, icon?:string, iconName?:string} $provider + */ + private function registerProvider(array &$providers, string $section, string $handle, Closure $provider): void + { + if ($section === '') { + throw new InvalidArgumentException('CP settings sections cannot be empty.'); + } + + if ($handle === '') { + throw new InvalidArgumentException('CP setting handles cannot be empty.'); + } + + if (isset($providers[$section][$handle])) { + throw new InvalidArgumentException("CP setting [$section.$handle] is already registered."); + } - return $event->settings; + $providers[$section][$handle] = $provider; } } diff --git a/tests/Unit/Cp/SettingsTest.php b/tests/Unit/Cp/SettingsTest.php new file mode 100644 index 00000000000..6a67ade359e --- /dev/null +++ b/tests/Unit/Cp/SettingsTest.php @@ -0,0 +1,72 @@ +registerSetting('Modules', 'normal', function (Settings $settings) use (&$resolved) { + $resolved = $settings === app(Settings::class); + + return ['label' => 'Normal']; + }); + $settings->registerReadOnlySetting('Modules', 'readonly', fn () => ['label' => 'Readonly']); + + expect($resolved)->toBeFalse() + ->and($settings->apply([], false))->toBe(['Modules' => ['normal' => ['label' => 'Normal']]]) + ->and($resolved)->toBeTrue() + ->and($settings->apply([], true))->toBe(['Modules' => ['readonly' => ['label' => 'Readonly']]]); +}); + +it('preserves section and item registration order', function () { + $settings = app(Settings::class); + $settings->registerSetting('Modules', 'first', fn () => ['label' => 'First']); + $settings->registerSetting('Modules', 'second', fn () => ['label' => 'Second']); + $settings->registerSetting('Plugins', 'plugin', fn () => ['label' => 'Plugin']); + + expect($settings->apply([], false))->toBe([ + 'Modules' => [ + 'first' => ['label' => 'First'], + 'second' => ['label' => 'Second'], + ], + 'Plugins' => ['plugin' => ['label' => 'Plugin']], + ]); +}); + +it('rejects duplicate and invalid settings', function () { + $settings = app(Settings::class); + $settings->registerSetting('Modules', 'plugin', fn () => ['label' => 'Plugin']); + + expect(fn () => $settings->registerSetting('Modules', 'plugin', fn () => ['label' => 'Plugin'])) + ->toThrow(InvalidArgumentException::class); + + $settings->registerSetting('Modules', 'invalid', fn () => ['label' => null]); + + expect(fn () => $settings->apply([], false)) + ->toThrow(InvalidArgumentException::class); +}); + +it('rejects settings that overwrite an existing item', function () { + $settings = app(Settings::class); + $settings->registerSetting('System', 'general', fn () => ['label' => 'General']); + + expect(fn () => $settings->apply(['System' => ['general' => ['label' => 'General']]], false)) + ->toThrow(InvalidArgumentException::class); +}); + +it('resolves section labels for the current locale', function () { + $locale = app()->getLocale(); + $settings = app(Settings::class); + $settings->registerSetting('System', 'plugin', fn () => ['label' => 'Plugin']); + app()->setLocale('fr'); + + try { + expect($settings->apply([], false))->toHaveKey(t('System')); + } finally { + app()->setLocale($locale); + } +}); diff --git a/yii2-adapter/legacy/events/RegisterCpSettingsEvent.php b/yii2-adapter/legacy/events/RegisterCpSettingsEvent.php index d41674aa447..1d774c7f03f 100644 --- a/yii2-adapter/legacy/events/RegisterCpSettingsEvent.php +++ b/yii2-adapter/legacy/events/RegisterCpSettingsEvent.php @@ -14,7 +14,7 @@ * * @author Pixel & Tonic, Inc. * @since 3.1.0 - * @deprecated 6.0.0 use {@see \CraftCms\Cms\Cp\Events\RegisterCpSettings} or {@see \CraftCms\Cms\Cp\Events\RegisterReadonlyCpSettings} instead. + * @deprecated 6.0.0 use {@see \CraftCms\Cms\Cp\Settings} instead. */ class RegisterCpSettingsEvent extends Event { diff --git a/yii2-adapter/legacy/web/twig/variables/Cp.php b/yii2-adapter/legacy/web/twig/variables/Cp.php index 03a14e63b0d..785d0e1667a 100644 --- a/yii2-adapter/legacy/web/twig/variables/Cp.php +++ b/yii2-adapter/legacy/web/twig/variables/Cp.php @@ -17,8 +17,6 @@ use CraftCms\Cms\Cp\Data\NavItem; use CraftCms\Cms\Cp\Events\CpNavItemsResolving; use CraftCms\Cms\Cp\Events\FormActionsResolving; -use CraftCms\Cms\Cp\Events\RegisterCpSettings; -use CraftCms\Cms\Cp\Events\RegisterReadonlyCpSettings; use CraftCms\Cms\Cp\FieldLayoutDesigner\FieldLayoutDesigner; use CraftCms\Cms\Entry\Elements\Entry; use CraftCms\Cms\FieldLayout\FieldLayout; @@ -151,26 +149,6 @@ public static function registerEvents(): void return app(FieldLayoutDesigner::class)->html($fieldLayout, $config); }); - Event::listen(function(RegisterCpSettings $event) { - if (\yii\base\Event::hasHandlers(self::class, self::EVENT_REGISTER_CP_SETTINGS)) { - $yiiEvent = new RegisterCpSettingsEvent(['settings' => $event->settings]); - - \yii\base\Event::trigger(self::class, self::EVENT_REGISTER_CP_SETTINGS, $yiiEvent); - - $event->settings = $yiiEvent->settings; - } - }); - - Event::listen(function(RegisterReadonlyCpSettings $event) { - if (\yii\base\Event::hasHandlers(self::class, self::EVENT_REGISTER_READ_ONLY_CP_SETTINGS)) { - $yiiEvent = new RegisterCpSettingsEvent(['settings' => $event->settings]); - - \yii\base\Event::trigger(self::class, self::EVENT_REGISTER_READ_ONLY_CP_SETTINGS, $yiiEvent); - - $event->settings = $yiiEvent->settings; - } - }); - Event::listen(function(CpNavItemsResolving $event) { if (YiiEvent::hasHandlers(self::class, 'registerCpNavItems')) { $items = array_map(fn(NavItem $navItem) => $navItem->toArray(), $event->navItems); diff --git a/yii2-adapter/src/Cp/LegacySettings.php b/yii2-adapter/src/Cp/LegacySettings.php new file mode 100644 index 00000000000..054e20c93f4 --- /dev/null +++ b/yii2-adapter/src/Cp/LegacySettings.php @@ -0,0 +1,33 @@ + $settings]); + Event::trigger(Cp::class, $eventName, $event); + + return $event->settings; + } +} From d59568a4b9cea4ac113ac21c5108a2bdc057b33e Mon Sep 17 00:00:00 2001 From: Rias Date: Sat, 25 Jul 2026 09:06:47 +0200 Subject: [PATCH 13/21] Move system message registration into SystemMessages --- .../Events/SystemMessagesResolving.php | 52 ---------- src/SystemMessage/SystemMessageCatalog.php | 98 +++++++++++++++++++ src/SystemMessage/SystemMessages.php | 97 ++++++++---------- .../SystemMessage/SystemMessagesTest.php | 76 +++++++++----- .../Unit/SystemMessage/SystemMessagesTest.php | 79 +++++++++++++++ .../events/RegisterEmailMessagesEvent.php | 2 +- .../legacy/services/SystemMessages.php | 22 ----- .../SystemMessage/LegacySystemMessages.php | 38 +++++++ yii2-adapter/tests/unit/mail/MailerTest.php | 28 ------ 9 files changed, 308 insertions(+), 184 deletions(-) delete mode 100644 src/SystemMessage/Events/SystemMessagesResolving.php create mode 100644 src/SystemMessage/SystemMessageCatalog.php create mode 100644 tests/Unit/SystemMessage/SystemMessagesTest.php create mode 100644 yii2-adapter/src/SystemMessage/LegacySystemMessages.php diff --git a/src/SystemMessage/Events/SystemMessagesResolving.php b/src/SystemMessage/Events/SystemMessagesResolving.php deleted file mode 100644 index 4504a91492c..00000000000 --- a/src/SystemMessage/Events/SystemMessagesResolving.php +++ /dev/null @@ -1,52 +0,0 @@ -messages->push(new SystemMessage( - * key: 'account_approved', - * heading: 'When a member’s account is approved', - * subject: 'Your account is approved!', - * body: "Hey {{user.friendlyName|e}},\n\nYour account with {{systemName}} has been approved by {{approver}}!", - * )); - * }, - * ); - * ``` - * - * Once a system message is registered, it will be editable from the System Messages utility. - * - * System messages can be sent via [[\CraftCms\Cms\SystemMessage\SystemMessages]]: - * - * ```php - * use CraftCms\Cms\SystemMessage\SystemMessages; - * use Illuminate\Support\Facades\Mail; - * - * Mail::send( - * app(SystemMessages::class)->mailable('account_approved', $user, [ - * 'approver' => $approver->friendlyName, - * ]) - * ); - * ``` - */ -class SystemMessagesResolving -{ - public function __construct( - /** @var Collection */ - public Collection $messages, - ) {} -} diff --git a/src/SystemMessage/SystemMessageCatalog.php b/src/SystemMessage/SystemMessageCatalog.php new file mode 100644 index 00000000000..8743182dc2b --- /dev/null +++ b/src/SystemMessage/SystemMessageCatalog.php @@ -0,0 +1,98 @@ + */ + private array $messages = []; + + public function __construct( + private readonly Container $container, + ) { + foreach (['account_activation', 'verify_new_email', 'forgot_password', 'test_email'] as $key) { + $this->register($key, fn () => new SystemMessage([ + 'key' => $key, + 'heading' => t("{$key}_heading"), + 'subject' => t("{$key}_subject"), + 'body' => t("{$key}_body"), + ])); + } + } + + /** @param Closure $resolve A container-invoked factory that returns a system message. */ + public function register(string $key, Closure $resolve): void + { + $this->validateKey($key); + + $this->messages[$key] = $resolve; + } + + public function remove(string ...$keys): void + { + foreach ($keys as $key) { + $this->validateKey($key); + } + + foreach ($keys as $key) { + unset($this->messages[$key]); + } + } + + /** @return Collection */ + public function messages(): Collection + { + $language = app()->getLocale(); + + if (I18N::getSiteLocaleIds()->doesntContain($language)) { + app()->setLocale(Sites::getPrimarySite()->getLanguage()); + } + + try { + return collect($this->messages) + ->map(function (Closure $resolve, string $key): SystemMessage { + $message = $this->container->call($resolve); + + if (! $message instanceof SystemMessage) { + throw new InvalidArgumentException(sprintf('System message factory [%s] must return [%s].', $key, SystemMessage::class)); + } + + if ($message->key !== $key) { + throw new InvalidArgumentException(sprintf('System message factory [%s] returned message key [%s].', $key, $message->key)); + } + + return $message; + }) + ->keyBy('key') + ->sortKeys(); + } finally { + app()->setLocale($language); + } + } + + private function validateKey(string $key): void + { + if ($key === '') { + throw new InvalidArgumentException('System message keys cannot be empty.'); + } + } +} diff --git a/src/SystemMessage/SystemMessages.php b/src/SystemMessage/SystemMessages.php index b0c4ffc0c05..5c7fa38e786 100644 --- a/src/SystemMessage/SystemMessages.php +++ b/src/SystemMessage/SystemMessages.php @@ -4,14 +4,13 @@ namespace CraftCms\Cms\SystemMessage; +use Closure; use CraftCms\Cms\Edition; -use CraftCms\Cms\Support\Facades\I18N; use CraftCms\Cms\Support\Facades\Sites; -use CraftCms\Cms\SystemMessage\Events\SystemMessagesResolving; use CraftCms\Cms\SystemMessage\Mailables\SystemMessageMailable; use CraftCms\Cms\SystemMessage\Models\SystemMessage; use CraftCms\Cms\User\Elements\User; -use Illuminate\Container\Attributes\Singleton; +use Illuminate\Container\Attributes\Scoped; use Illuminate\Database\Eloquent\Builder; use Illuminate\Support\Collection; use Tpetry\QueryExpressions\Language\CaseGroup; @@ -19,14 +18,48 @@ use Tpetry\QueryExpressions\Operator\Comparison\Equal; use Tpetry\QueryExpressions\Value\Value; -use function CraftCms\Cms\t; - -#[Singleton] +/** + * Manages system messages and registers additional lazily resolved messages. + * + * ```php + * public function boot(SystemMessages $systemMessages): void + * { + * $systemMessages->register('order_shipped', fn () => new SystemMessage([ + * 'key' => 'order_shipped', + * 'heading' => 'Order shipped', + * 'subject' => 'Your order has shipped', + * 'body' => 'Your order is on its way.', + * ])); + * } + * ``` + */ +#[Scoped] class SystemMessages { /** @var Collection|null */ private ?Collection $defaultMessages = null; + public function __construct( + private readonly SystemMessageCatalog $messageCatalog, + ) {} + + /** @param Closure $resolve A container-invoked factory that returns a system message. */ + public function register(string $key, Closure $resolve): void + { + $this->messageCatalog->register($key, $resolve); + } + + public function remove(string ...$keys): void + { + $this->messageCatalog->remove(...$keys); + } + + /** @return Collection */ + public function messages(): Collection + { + return $this->messageCatalog->messages(); + } + /** * Returns all the default system email messages, without subject/body overrides. * @@ -38,57 +71,7 @@ public function getAllDefaultMessages(): Collection return $this->defaultMessages; } - // If the current language isn't one of the site's languages, switch to the primary site's language - $language = app()->getLocale(); - if (I18N::getSiteLocaleIds()->doesntContain($language)) { - app()->setLocale(Sites::getPrimarySite()->getLanguage()); - } - - $messages = collect([ - new SystemMessage([ - 'key' => 'account_activation', - 'heading' => t('account_activation_heading'), - 'subject' => t('account_activation_subject'), - 'body' => t('account_activation_body'), - ]), - new SystemMessage([ - 'key' => 'verify_new_email', - 'heading' => t('verify_new_email_heading'), - 'subject' => t('verify_new_email_subject'), - 'body' => t('verify_new_email_body'), - ]), - new SystemMessage([ - 'key' => 'forgot_password', - 'heading' => t('forgot_password_heading'), - 'subject' => t('forgot_password_subject'), - 'body' => t('forgot_password_body'), - ]), - new SystemMessage([ - 'key' => 'test_email', - 'heading' => t('test_email_heading'), - 'subject' => t('test_email_subject'), - 'body' => t('test_email_body'), - ]), - ]); - - event($event = new SystemMessagesResolving($messages)); - - // Sort them all by key - $messages = $event->messages - ->keyBy('key') - ->sortBy('key'); - - // Make sure they're SystemMessage objects - foreach ($messages as $key => $message) { - if (is_array($message)) { - $messages[$key] = new SystemMessage($message); - } - } - - // Put the original language back - app()->setLocale($language); - - return $this->defaultMessages = $messages; + return $this->defaultMessages = $this->messages(); } /** diff --git a/tests/Feature/SystemMessage/SystemMessagesTest.php b/tests/Feature/SystemMessage/SystemMessagesTest.php index 2d4e240d90b..23ed7bb5feb 100644 --- a/tests/Feature/SystemMessage/SystemMessagesTest.php +++ b/tests/Feature/SystemMessage/SystemMessagesTest.php @@ -3,10 +3,10 @@ declare(strict_types=1); use CraftCms\Cms\Edition; -use CraftCms\Cms\SystemMessage\Events\SystemMessagesResolving; +use CraftCms\Cms\Support\Facades\I18N; +use CraftCms\Cms\Support\Facades\Sites; use CraftCms\Cms\SystemMessage\Models\SystemMessage; use CraftCms\Cms\SystemMessage\SystemMessages; -use Illuminate\Support\Facades\Event; use function CraftCms\Cms\t; @@ -14,31 +14,59 @@ $this->systemMessages = app(SystemMessages::class); }); -it('retrieves all the default system messages', function () { - $messages = $this->systemMessages->getAllDefaultMessages(); - - expect($messages->has('account_activation'))->toBeTrue(); - expect($this->systemMessages->getDefaultMessage('account_activation'))->not()->toBeNull(); - expect($messages->has('verify_new_email'))->toBeTrue(); - expect($this->systemMessages->getDefaultMessage('verify_new_email'))->not()->toBeNull(); - expect($messages->has('forgot_password'))->toBeTrue(); - expect($this->systemMessages->getDefaultMessage('forgot_password'))->not()->toBeNull(); - expect($messages->has('test_email'))->toBeTrue(); - expect($this->systemMessages->getDefaultMessage('test_email'))->not()->toBeNull(); -}); +it('resolves registered messages in the site locale', function () { + $localeBeforeTest = app()->getLocale(); + $unsupportedLocale = 'zz-ZZ'; + app()->setLocale($unsupportedLocale); + + $resolvedLocale = null; + app(SystemMessages::class)->register('modern', function () use (&$resolvedLocale) { + $resolvedLocale = app()->getLocale(); -it('can add additional messages through an event', function () { - Event::listen(SystemMessagesResolving::class, function (SystemMessagesResolving $event) { - $event->messages->push(new SystemMessage([ - 'key' => 'foo', - 'heading' => 'A test system message', - 'subject' => 'A test system message', - 'body' => 'A test system message', - ])); + return new SystemMessage([ + 'key' => 'modern', + 'heading' => 'Modern message', + 'subject' => 'Modern message', + 'body' => 'Modern message', + ]); }); - expect($this->systemMessages->getAllDefaultMessages()->has('foo'))->toBeTrue(); - expect($this->systemMessages->getDefaultMessage('foo'))->not()->toBeNull(); + try { + expect($this->systemMessages->getAllDefaultMessages())->toHaveKey('modern') + ->and($resolvedLocale)->toBe(Sites::getPrimarySite()->getLanguage()) + ->and(app()->getLocale())->toBe($unsupportedLocale); + } finally { + app()->setLocale($localeBeforeTest); + } +}); + +it('caches defaults within a scope and resolves them again for the next locale scope', function () { + $originalLocale = app()->getLocale(); + I18N::shouldReceive('getSiteLocaleIds')->andReturn(collect(['en-US', 'fr'])); + I18N::shouldReceive('translate')->andReturnUsing(fn (string $message) => $message); + app(SystemMessages::class)->register('scoped', fn () => new SystemMessage([ + 'key' => 'scoped', + 'heading' => app()->getLocale(), + 'subject' => app()->getLocale(), + 'body' => app()->getLocale(), + ])); + + try { + app()->setLocale('en-US'); + $systemMessages = app(SystemMessages::class); + + expect($systemMessages->getAllDefaultMessages()['scoped']->subject)->toBe('en-US'); + + app()->setLocale('fr'); + + expect($systemMessages->getAllDefaultMessages()['scoped']->subject)->toBe('en-US'); + + app()->forgetScopedInstances(); + + expect(app(SystemMessages::class)->getAllDefaultMessages()['scoped']->subject)->toBe('fr'); + } finally { + app()->setLocale($originalLocale); + } }); it('can get messages including overrides', function () { diff --git a/tests/Unit/SystemMessage/SystemMessagesTest.php b/tests/Unit/SystemMessage/SystemMessagesTest.php new file mode 100644 index 00000000000..0fc68c7c057 --- /dev/null +++ b/tests/Unit/SystemMessage/SystemMessagesTest.php @@ -0,0 +1,79 @@ +andReturn(collect([app()->getLocale()])); + I18N::shouldReceive('translate')->andReturnUsing(fn (string $message) => $message); +}); + +it('resolves registered factories in message-key order and omits removed messages', function () { + $registry = app(SystemMessages::class); + + $registry->register('custom', fn () => systemMessage('custom', 'First')); + $registry->register('after_custom', function (Application $application) { + expect($application)->toBe(app()); + + return systemMessage('after_custom', 'After'); + }); + $registry->register('custom', fn () => systemMessage('custom', 'Updated')); + $registry->remove('test_email', 'missing'); + + $messages = $registry->messages(); + $keys = $messages->keys()->all(); + $sortedKeys = $keys; + sort($sortedKeys); + + expect($keys)->toBe($sortedKeys) + ->toContain('custom', 'after_custom') + ->not()->toContain('test_email') + ->and($messages['custom']->heading)->toBe('Updated'); +}); + +it('does not invoke factories during registration', function () { + $resolved = false; + $registry = app(SystemMessages::class); + + $registry->register('lazy', fn () => systemMessage('lazy')); + $registry->register('lazy', function () use (&$resolved) { + $resolved = true; + + return systemMessage('lazy'); + }); + + expect($resolved)->toBeFalse(); +}); + +it('rejects empty keys immediately', function () { + $registry = app(SystemMessages::class); + + expect(fn () => $registry->register('', fn () => systemMessage('message'))) + ->toThrow(InvalidArgumentException::class) + ->and(fn () => $registry->remove('')) + ->toThrow(InvalidArgumentException::class); +}); + +it('rejects invalid factory results when resolving', function (string $key, Closure $resolve) { + $registry = app(SystemMessages::class); + $registry->register($key, $resolve); + + expect(fn () => $registry->messages())->toThrow(InvalidArgumentException::class); +})->with([ + 'invalid result' => ['invalid', fn () => new stdClass], + 'mismatched key' => ['expected', fn () => systemMessage('actual')], +]); + +function systemMessage(string $key, string $heading = 'Heading'): SystemMessage +{ + return new SystemMessage([ + 'key' => $key, + 'heading' => $heading, + 'subject' => 'Subject', + 'body' => 'Body', + ]); +} diff --git a/yii2-adapter/legacy/events/RegisterEmailMessagesEvent.php b/yii2-adapter/legacy/events/RegisterEmailMessagesEvent.php index a4786d13419..46b275a413f 100644 --- a/yii2-adapter/legacy/events/RegisterEmailMessagesEvent.php +++ b/yii2-adapter/legacy/events/RegisterEmailMessagesEvent.php @@ -14,7 +14,7 @@ * * @author Pixel & Tonic, Inc. * @since 3.0.0 - * @deprecated 6.0.0 use {@see \CraftCms\Cms\SystemMessage\Events\SystemMessagesResolving} instead. + * @deprecated 6.0.0 use {@see \CraftCms\Cms\SystemMessage\SystemMessages::register()} instead. */ class RegisterEmailMessagesEvent extends Event { diff --git a/yii2-adapter/legacy/services/SystemMessages.php b/yii2-adapter/legacy/services/SystemMessages.php index fa63775ffdc..d0c5f5ace00 100644 --- a/yii2-adapter/legacy/services/SystemMessages.php +++ b/yii2-adapter/legacy/services/SystemMessages.php @@ -8,9 +8,7 @@ namespace craft\services; use craft\events\RegisterEmailMessagesEvent; -use CraftCms\Cms\SystemMessage\Events\SystemMessagesResolving; use CraftCms\Cms\SystemMessage\Models\SystemMessage; -use Illuminate\Support\Facades\Event; use yii\base\Component; /** @@ -126,24 +124,4 @@ public function saveMessage(SystemMessage $message, ?string $language = null): b return true; } - - public static function registerEvents(): void - { - Event::listen(SystemMessagesResolving::class, function(SystemMessagesResolving $event) { - $messages = $event->messages->map(function(SystemMessage $message) { - return $message->toArray(); - })->all(); - - $yiiEvent = new RegisterEmailMessagesEvent(['messages' => $messages]); - - app('Craft')->getSystemMessages()->trigger(self::EVENT_REGISTER_MESSAGES, $yiiEvent); - - $event->messages = collect($yiiEvent->messages)->map(function($message) { - return match (true) { - is_array($message) => new SystemMessage($message), - default => $message, - }; - }); - }); - } } diff --git a/yii2-adapter/src/SystemMessage/LegacySystemMessages.php b/yii2-adapter/src/SystemMessage/LegacySystemMessages.php new file mode 100644 index 00000000000..844ac6309a2 --- /dev/null +++ b/yii2-adapter/src/SystemMessage/LegacySystemMessages.php @@ -0,0 +1,38 @@ +getSystemMessages(); + + if (!$service->hasEventHandlers(LegacySystemMessagesService::EVENT_REGISTER_MESSAGES)) { + return $messages; + } + + $event = new RegisterEmailMessagesEvent([ + 'messages' => $messages->map(fn(SystemMessage $message) => $message->toArray())->all(), + ]); + $service->trigger(LegacySystemMessagesService::EVENT_REGISTER_MESSAGES, $event); + + return collect($event->messages) + ->map(fn(SystemMessage|array $message) => is_array($message) ? new SystemMessage($message) : $message) + ->keyBy('key') + ->sortKeys(); + } +} diff --git a/yii2-adapter/tests/unit/mail/MailerTest.php b/yii2-adapter/tests/unit/mail/MailerTest.php index 7dcc656044f..a6110e5e1c6 100644 --- a/yii2-adapter/tests/unit/mail/MailerTest.php +++ b/yii2-adapter/tests/unit/mail/MailerTest.php @@ -18,11 +18,8 @@ use CraftCms\Cms\ProjectConfig\ProjectConfig; use CraftCms\Cms\Site\Exceptions\SiteNotFoundException; use CraftCms\Cms\Support\Facades\Sites; -use CraftCms\Cms\SystemMessage\Events\SystemMessagesResolving; -use CraftCms\Cms\SystemMessage\Models\SystemMessage; use CraftCms\Cms\User\Elements\User; use Illuminate\Support\Facades\Config; -use Illuminate\Support\Facades\Event; use ReflectionException; use UnitTester; use yii\base\InvalidConfigException; @@ -110,31 +107,6 @@ public function testEmailVariables(): void self::assertSame('https://craftcms.com', $variables['link']); } - public function testMessageProperties(): void - { - $this->markTestSkipped('TODO: Rework for Laravel with system messages'); - - // app(ProjectConfig::class)->set('email', ['fromName' => '$FROM_EMAIL_NAME', 'fromEmail' => '$FROM_EMAIL_ADDRESS']); - - // Event::listen(SystemMessagesResolving::class, function(SystemMessagesResolving $event) { - // $event->messages = collect([ - // new SystemMessage([ - // 'key' => 'account_activation', - // 'body' => '{{fromEmail}} || {{fromName}}', - // 'subject' => '{{fromName}} || {{fromEmail}}', - // ]), - // ]); - // }); - - // $this->_sendMail('test@craft.test'); - - // /* @var Message $lastMessage */ - // $lastMessage = $this->tester->grabLastSentEmail(); - - // self::assertSame('Craft CMS || info@craftcms.com', $lastMessage->getSubject()); - // self::assertStringContainsString('info@craftcms.com || Craft CMS', $lastMessage->toString()); - } - /** * */ From b59a07f85d72ea34dcc44b5272beb936150312d9 Mon Sep 17 00:00:00 2001 From: Rias Date: Sat, 25 Jul 2026 09:08:00 +0200 Subject: [PATCH 14/21] Register user permission groups explicitly --- src/Plugin/Concerns/HasPermissions.php | 41 ++-- src/User/Data/PermissionGroup.php | 6 +- src/User/Events/UserPermissionsResolving.php | 19 -- src/User/PermissionGroupCatalog.php | 88 +++++++++ src/User/UserPermissions.php | 41 +++- tests/Feature/User/UserPermissionsTest.php | 32 +++ .../Plugin/Concerns/HasPermissionsTest.php | 40 ++-- .../Unit/User/PermissionGroupCatalogTest.php | 66 +++++++ .../events/RegisterUserPermissionsEvent.php | 2 +- .../legacy/services/UserPermissions.php | 88 ++------- .../src/User/LegacyUserPermissions.php | 67 +++++++ .../PermissionRegistryCompatibilityTest.php | 182 ++++++++++++++++++ 12 files changed, 541 insertions(+), 131 deletions(-) delete mode 100644 src/User/Events/UserPermissionsResolving.php create mode 100644 src/User/PermissionGroupCatalog.php create mode 100644 tests/Unit/User/PermissionGroupCatalogTest.php create mode 100644 yii2-adapter/src/User/LegacyUserPermissions.php create mode 100644 yii2-adapter/tests-laravel/Legacy/PermissionRegistryCompatibilityTest.php diff --git a/src/Plugin/Concerns/HasPermissions.php b/src/Plugin/Concerns/HasPermissions.php index 3ab0039bd07..abf898b8444 100644 --- a/src/Plugin/Concerns/HasPermissions.php +++ b/src/Plugin/Concerns/HasPermissions.php @@ -7,8 +7,7 @@ use CraftCms\Cms\Plugin\Plugin; use CraftCms\Cms\User\Data\Permission; use CraftCms\Cms\User\Data\PermissionGroup; -use CraftCms\Cms\User\Events\UserPermissionsResolving; -use Illuminate\Support\Facades\Event; +use CraftCms\Cms\User\UserPermissions; /** * @mixin Plugin @@ -27,24 +26,26 @@ protected function getPermissions(): array public function bootHasPermissions(): void { - $permissions = collect($this->getPermissions()); - - if ($permissions->isEmpty()) { - return; - } - - throw_if( - $permissions->whereInstanceOf(Permission::class)->count() !== $permissions->count(), - sprintf('Each permission returned from `getPermissions()` needs to be an instance of `%s`', Permission::class) + $this->app->make(UserPermissions::class)->registerPermissionGroup( + "plugin:$this->handle", + function (): ?PermissionGroup { + $permissions = collect($this->getPermissions()); + + if ($permissions->isEmpty()) { + return null; + } + + throw_if( + $permissions->whereInstanceOf(Permission::class)->count() !== $permissions->count(), + sprintf('Each permission returned from `getPermissions()` needs to be an instance of `%s`', Permission::class) + ); + + return new PermissionGroup( + handle: "plugin:$this->handle", + heading: $this->name ?? $this->handle, + permissions: $permissions, + ); + }, ); - - Event::listen(UserPermissionsResolving::class, function (UserPermissionsResolving $event) use ($permissions) { - $plugin = self::getInstance(); - - $event->permissions = $event->permissions->push(new PermissionGroup( - heading: $plugin->name ?? $plugin->handle, - permissions: $permissions, - )); - }); } } diff --git a/src/User/Data/PermissionGroup.php b/src/User/Data/PermissionGroup.php index 1ab438e9482..48cddbac37a 100644 --- a/src/User/Data/PermissionGroup.php +++ b/src/User/Data/PermissionGroup.php @@ -4,22 +4,18 @@ namespace CraftCms\Cms\User\Data; -use CraftCms\Cms\Support\Str; use Illuminate\Contracts\Support\Arrayable; use Illuminate\Support\Collection; class PermissionGroup implements Arrayable { public function __construct( + public string $handle, public string $heading, /** @var Collection */ public Collection $permissions = new Collection, ) {} - public string $handle { - get => Str::toHandle($this->heading); - } - /** @var string[] */ public array $keys { get => $this->permissionKeys($this->permissions); diff --git a/src/User/Events/UserPermissionsResolving.php b/src/User/Events/UserPermissionsResolving.php deleted file mode 100644 index d0a76c8867a..00000000000 --- a/src/User/Events/UserPermissionsResolving.php +++ /dev/null @@ -1,19 +0,0 @@ - */ - public Collection $permissions, - ) {} -} diff --git a/src/User/PermissionGroupCatalog.php b/src/User/PermissionGroupCatalog.php new file mode 100644 index 00000000000..f809a33c9a4 --- /dev/null +++ b/src/User/PermissionGroupCatalog.php @@ -0,0 +1,88 @@ + + */ + private array $factories = []; + + public function __construct( + private readonly Container $container, + ) {} + + /** @param Closure(): (PermissionGroup|null) $factory */ + public function register(string $handle, Closure $factory): void + { + $this->registerProvider($this->factories, $handle, $factory); + } + + public function remove(string ...$handles): void + { + foreach ($handles as $handle) { + unset($this->factories[$handle]); + } + } + + /** + * @internal + * + * @param Collection $groups + * @return Collection + */ + public function apply(Collection $groups): Collection + { + foreach ($this->factories as $handle => $factory) { + $group = $this->container->call($factory); + + if ($group === null) { + continue; + } + + if (! $group instanceof PermissionGroup) { + throw new InvalidArgumentException("Permission group factory [$handle] must return a permission group."); + } + + if ($group->handle !== $handle) { + throw new InvalidArgumentException("Permission group factory [$handle] returned group handle [$group->handle]."); + } + + $groups->push($group); + } + + $duplicateHandle = $groups->pluck('handle')->duplicates()->first(); + + if ($duplicateHandle !== null) { + throw new InvalidArgumentException("Permission group handle [$duplicateHandle] is duplicated."); + } + + return $groups->values(); + } + + /** @param array $providers */ + private function registerProvider(array &$providers, string $handle, Closure $provider): void + { + if ($handle === '') { + throw new InvalidArgumentException('Permission group handles cannot be empty.'); + } + + if (isset($this->factories[$handle])) { + throw new InvalidArgumentException("Permission group [$handle] is already registered."); + } + + $providers[$handle] = $provider; + } +} diff --git a/src/User/UserPermissions.php b/src/User/UserPermissions.php index 944daa7598f..7b7f14b21ed 100644 --- a/src/User/UserPermissions.php +++ b/src/User/UserPermissions.php @@ -4,6 +4,7 @@ namespace CraftCms\Cms\User; +use Closure; use CraftCms\Cms\Asset\Elements\Asset; use CraftCms\Cms\Database\Table; use CraftCms\Cms\Edition; @@ -26,7 +27,6 @@ use CraftCms\Cms\User\Data\PermissionGroup; use CraftCms\Cms\User\Elements\User; use CraftCms\Cms\User\Events\UserGroupPermissionsSaved; -use CraftCms\Cms\User\Events\UserPermissionsResolving; use CraftCms\Cms\User\Events\UserPermissionsSaved; use CraftCms\Cms\User\Models\UserPermission; use CraftCms\Cms\Utility\Utilities; @@ -43,6 +43,20 @@ use function CraftCms\Cms\currentUser; use function CraftCms\Cms\t; +/** + * Manages user permissions and registers additional permission groups. + * + * ```php + * public function boot(UserPermissions $userPermissions): void + * { + * $userPermissions->registerPermissionGroup('plugin:my-plugin', fn () => new PermissionGroup( + * handle: 'plugin:my-plugin', + * heading: 'My Plugin', + * permissions: collect([new Permission('manageMyPlugin', 'Manage My Plugin')]), + * )); + * } + * ``` + */ #[Scoped] class UserPermissions { @@ -70,6 +84,21 @@ class UserPermissions */ private Collection $permissionsByUserId; + public function __construct( + private readonly PermissionGroupCatalog $permissionGroupCatalog, + ) {} + + /** @param Closure(): (PermissionGroup|null) $factory */ + public function registerPermissionGroup(string $handle, Closure $factory): void + { + $this->permissionGroupCatalog->register($handle, $factory); + } + + public function removePermissionGroups(string ...$handles): void + { + $this->permissionGroupCatalog->remove(...$handles); + } + /** * Returns all of the known permissions, divided into groups. * @@ -104,9 +133,7 @@ public function getAllPermissions(): Collection $this->volumePermissions($this->allPermissions); $this->utilityPermissions($this->allPermissions); - event($event = new UserPermissionsResolving($this->allPermissions)); - - return $this->allPermissions = $event->permissions; + return $this->allPermissions = $this->permissionGroupCatalog->apply($this->allPermissions); } /** @@ -417,6 +444,7 @@ private function generalPermissions(Collection $permissions): void } $permissions->add(new PermissionGroup( + handle: 'general', heading: t('General'), permissions: $generalPermissions, )); @@ -438,6 +466,7 @@ private function userPermissions(Collection $permissions): void } $permissions->add(new PermissionGroup( + handle: 'users', heading: t('Users'), permissions: collect([ new Permission( @@ -486,6 +515,7 @@ private function sitePermissions(Collection $permissions): void } $permissions->add(new PermissionGroup( + handle: 'sites', heading: t('Sites'), permissions: Sites::getAllSites(true)->map(fn (Site $site) => new Permission( key: "editSite:$site->uid", @@ -518,6 +548,7 @@ private function entryPermissions(Collection $permissions): void [$section, $sectionPermission] = $result; return new PermissionGroup( + handle: "section:$section->uid", heading: t('Section - {section}', [ 'section' => t($section->name, category: 'site'), ]), @@ -678,6 +709,7 @@ private function volumePermissions(Collection $permissions): void foreach ($volumes as $volume) { $permissions->add(new PermissionGroup( + handle: "volume:$volume->uid", heading: t('Volume - {volume}', [ 'volume' => t($volume->name, category: 'site'), ]), @@ -715,6 +747,7 @@ private function volumePermissions(Collection $permissions): void private function utilityPermissions(Collection $permissions): void { $permissions->add(new PermissionGroup( + handle: 'utilities', heading: t('Utilities'), permissions: app(Utilities::class)->getAllUtilityTypes()->map(function (string $class) { /** @var class-string $class */ diff --git a/tests/Feature/User/UserPermissionsTest.php b/tests/Feature/User/UserPermissionsTest.php index e0ff92e08f3..73c4a724982 100644 --- a/tests/Feature/User/UserPermissionsTest.php +++ b/tests/Feature/User/UserPermissionsTest.php @@ -7,6 +7,7 @@ use CraftCms\Cms\Site\Models\Site; use CraftCms\Cms\Support\Facades\Sites; use CraftCms\Cms\Support\Str; +use CraftCms\Cms\User\Data\Permission; use CraftCms\Cms\User\Data\PermissionGroup; use CraftCms\Cms\User\Elements\User; use CraftCms\Cms\User\Models\UserGroup; @@ -25,6 +26,26 @@ expect($this->userPermissions->getAllPermissions())->not()->toBeEmpty(); }); +test('permission groups can be registered and removed', function () { + $this->userPermissions->registerPermissionGroup('plugin:modern', fn () => new PermissionGroup( + handle: 'plugin:modern', + heading: 'Modern plugin', + permissions: collect([new Permission('manageModernPlugin', 'Manage modern plugin')]), + )); + + expect($this->userPermissions->getAllPermissions()->contains('heading', 'Modern plugin'))->toBeTrue(); + + app()->forgetScopedInstances(); + $this->userPermissions = app(UserPermissions::class); + + expect($this->userPermissions->getAllPermissions()->contains('heading', 'Modern plugin'))->toBeTrue(); + + $this->userPermissions->removePermissionGroups('plugin:modern'); + $this->userPermissions->reset(); + + expect($this->userPermissions->getAllPermissions()->contains('heading', 'Modern plugin'))->toBeFalse(); +}); + test('getAllPermissions contains headings', function (string $heading) { if (str_contains($heading, 'Pages')) { Section::factory()->create(['name' => 'Pages']); @@ -49,6 +70,17 @@ 'Utilities', ]); +test('permission group handles are independent from display headings', function () { + $firstSection = Section::factory()->create(['name' => 'Shared heading']); + $secondSection = Section::factory()->create(['name' => 'Shared heading']); + + $handles = ["section:$firstSection->uid", "section:$secondSection->uid"]; + $groups = $this->userPermissions->getAllPermissions()->whereIn('handle', $handles); + + expect($groups)->toHaveCount(2) + ->and($groups->pluck('heading')->unique())->toHaveCount(1); +}); + test('getAssignablePermissions', function () { $admin = User::find()->one(); diff --git a/tests/Unit/Plugin/Concerns/HasPermissionsTest.php b/tests/Unit/Plugin/Concerns/HasPermissionsTest.php index f13b0126089..9c862d78722 100644 --- a/tests/Unit/Plugin/Concerns/HasPermissionsTest.php +++ b/tests/Unit/Plugin/Concerns/HasPermissionsTest.php @@ -4,18 +4,13 @@ use CraftCms\Cms\Tests\TestClasses\TestPlugin\src\TestPlugin; use CraftCms\Cms\User\Data\Permission; -use CraftCms\Cms\User\Events\UserPermissionsResolving; -use Illuminate\Contracts\Events\Dispatcher; -use Illuminate\Support\Collection; +use CraftCms\Cms\User\PermissionGroupCatalog; beforeEach(function () { app()->forgetInstance(TestPlugin::class); }); -afterEach(function () { - app(Dispatcher::class)->forget(UserPermissionsResolving::class); - app()->forgetInstance(TestPlugin::class); -}); +afterEach(fn () => app()->forgetInstance(TestPlugin::class)); it('registers a permission group for the plugin permissions', function () { $plugin = TestPlugin::create([ @@ -29,12 +24,31 @@ $plugin->bootHasPermissions(); - $event = new UserPermissionsResolving(new Collection); - event($event); + $groups = app(PermissionGroupCatalog::class)->apply(collect()); - expect($event->permissions)->toHaveCount(1) - ->and($event->permissions->first()->heading)->toBe('Test Plugin') - ->and($event->permissions->first()->permissions->pluck('key')->all())->toBe(['managePlugin']); + expect($groups)->toHaveCount(1) + ->and($groups->first()->handle)->toBe('plugin:test-plugin') + ->and($groups->first()->heading)->toBe('Test Plugin') + ->and($groups->first()->permissions->pluck('key')->all())->toBe(['managePlugin']); +}); + +it('resolves the current plugin permissions on each catalog rebuild', function () { + $plugin = TestPlugin::create([ + 'handle' => 'test-plugin', + 'name' => 'Test Plugin', + ]); + + $plugin->bootHasPermissions(); + $registry = app(PermissionGroupCatalog::class); + + expect($registry->apply(collect()))->toBeEmpty(); + + $plugin->setPermissions([ + new Permission('managePlugin', 'Manage plugin'), + ]); + + expect($registry->apply(collect())->first()->permissions->pluck('key')->all()) + ->toBe(['managePlugin']); }); it('rejects invalid permission definitions', function () { @@ -46,6 +60,8 @@ $plugin->setPermissions(['not-a-permission']); $plugin->bootHasPermissions(); + + app(PermissionGroupCatalog::class)->apply(collect()); })->throws(Exception::class, sprintf( 'Each permission returned from `getPermissions()` needs to be an instance of `%s`', Permission::class, diff --git a/tests/Unit/User/PermissionGroupCatalogTest.php b/tests/Unit/User/PermissionGroupCatalogTest.php new file mode 100644 index 00000000000..99f3e920ad2 --- /dev/null +++ b/tests/Unit/User/PermissionGroupCatalogTest.php @@ -0,0 +1,66 @@ +register('plugin:first', function (PermissionGroupCatalog $registry) use (&$resolved) { + $resolved = $registry === app(PermissionGroupCatalog::class); + + return new PermissionGroup('plugin:first', 'First'); + }); + $registry->register('plugin:second', fn () => new PermissionGroup('plugin:second', 'Second')); + + expect($resolved)->toBeFalse() + ->and($registry->apply(collect([new PermissionGroup('core', 'Core')]))->pluck('heading')->all()) + ->toBe(['Core', 'First', 'Second']) + ->and($resolved)->toBeTrue(); +}); + +it('does not add null permission group contributions', function () { + $registry = app(PermissionGroupCatalog::class); + $registry->register('plugin:empty', fn () => null); + + expect($registry->apply(collect()))->toBeEmpty(); +}); + +it('removes permission group contributions by handle', function () { + $registry = app(PermissionGroupCatalog::class); + $registry->register('plugin:before', fn () => new PermissionGroup('plugin:before', 'Before')); + $registry->register('plugin:removed', fn () => new PermissionGroup('plugin:removed', 'Removed')); + $registry->register('plugin:after', fn () => new PermissionGroup('plugin:after', 'After')); + $registry->remove('plugin:removed', 'plugin:missing'); + + expect($registry->apply(collect())->pluck('handle')) + ->toContain('plugin:before', 'plugin:after') + ->not()->toContain('plugin:removed'); +}); + +it('rejects duplicate permission group handles', function () { + $registry = app(PermissionGroupCatalog::class); + $registry->register('plugin:test', fn () => new PermissionGroup('plugin:test', 'Plugin')); + + $registry->register('plugin:test', fn () => new PermissionGroup('plugin:test', 'Plugin')); +})->throws(InvalidArgumentException::class, 'Permission group [plugin:test] is already registered.'); + +it('rejects invalid permission group factory results', function () { + app(PermissionGroupCatalog::class)->register('plugin:test', fn () => 'invalid'); + + app(PermissionGroupCatalog::class)->apply(collect()); +})->throws(InvalidArgumentException::class, 'Permission group factory [plugin:test] must return a permission group.'); + +it('rejects permission group factories with a different handle', function () { + app(PermissionGroupCatalog::class)->register('plugin:test', fn () => new PermissionGroup('plugin:other', 'Plugin')); + + app(PermissionGroupCatalog::class)->apply(collect()); +})->throws(InvalidArgumentException::class, 'Permission group factory [plugin:test] returned group handle [plugin:other].'); + +it('rejects duplicate resolved permission group handles', function () { + app(PermissionGroupCatalog::class)->register('plugin:test', fn () => new PermissionGroup('plugin:test', 'Plugin')); + + app(PermissionGroupCatalog::class)->apply(collect([new PermissionGroup('plugin:test', 'Existing plugin')])); +})->throws(InvalidArgumentException::class, 'Permission group handle [plugin:test] is duplicated.'); diff --git a/yii2-adapter/legacy/events/RegisterUserPermissionsEvent.php b/yii2-adapter/legacy/events/RegisterUserPermissionsEvent.php index 8fa0ee974cb..4b0818c3366 100644 --- a/yii2-adapter/legacy/events/RegisterUserPermissionsEvent.php +++ b/yii2-adapter/legacy/events/RegisterUserPermissionsEvent.php @@ -14,7 +14,7 @@ * * @author Pixel & Tonic, Inc. * @since 3.0.0 - * @deprecated 6.0.0 use {@see \CraftCms\Cms\User\Events\UserPermissionsResolving} instead. + * @deprecated 6.0.0 use {@see \CraftCms\Cms\User\UserPermissions::registerPermissionGroup()} instead. */ class RegisterUserPermissionsEvent extends Event { diff --git a/yii2-adapter/legacy/services/UserPermissions.php b/yii2-adapter/legacy/services/UserPermissions.php index 562796438bb..9baf64b7b04 100644 --- a/yii2-adapter/legacy/services/UserPermissions.php +++ b/yii2-adapter/legacy/services/UserPermissions.php @@ -1,26 +1,24 @@ getUserPermissions()`]]. * * @author Pixel & Tonic, Inc. + * * @since 3.0.0 - * @deprecated 6.0.0 use {@see \CraftCms\Cms\User\UserPermissions} instead. + * @deprecated 6.0.0 use {@see UserPermissionsService} instead. */ class UserPermissions extends Component { @@ -42,12 +41,14 @@ class UserPermissions extends Component /** * @event UserPermissionsEvent The event triggered before saving user permissions. + * * @since 4.3.0 */ public const EVENT_AFTER_SAVE_USER_PERMISSIONS = 'afterSaveUserPermissions'; /** * @event UserGroupPermissionsEvent The event triggered before saving group permissions. + * * @since 4.3.0 */ public const EVENT_AFTER_SAVE_GROUP_PERMISSIONS = 'afterSaveGroupPermissions'; @@ -68,8 +69,6 @@ class UserPermissions extends Component * - `warning` _(optional)_ – Warning text about the permission * - `nested` _(optional)_ – An array of nested permissions, which can only be assigned if the parent * permission is assigned. - * - * @return array */ public function getAllPermissions(): array { @@ -81,9 +80,7 @@ public function getAllPermissions(): array * * See [[getAllPermissions()]] for an explanation of what will be returned. * - * @param User|null $user The recipient of the permissions. If set, their current permissions will be included as well. - * - * @return array + * @param User|null $user The recipient of the permissions. If set, their current permissions will be included as well. */ public function getAssignablePermissions(?User $user = null): array { @@ -93,7 +90,6 @@ public function getAssignablePermissions(?User $user = null): array /** * Returns all of a given user group's permissions. * - * @param int $groupId * * @return string[] */ @@ -105,7 +101,6 @@ public function getPermissionsByGroupId(int $groupId): array /** * Returns all of the group permissions a given user has. * - * @param int $userId * * @return string[] */ @@ -116,11 +111,6 @@ public function getGroupPermissionsByUserId(int $userId): array /** * Returns whether a given user group has a given permission. - * - * @param int $groupId - * @param string $checkPermission - * - * @return bool */ public function doesGroupHavePermission(int $groupId, string $checkPermission): bool { @@ -130,10 +120,7 @@ public function doesGroupHavePermission(int $groupId, string $checkPermission): /** * Saves new permissions for a user group. * - * @param int $groupId - * @param array $permissions * - * @return bool * @throws WrongEditionException if this is called from Craft Solo edition */ public function saveGroupPermissions(int $groupId, array $permissions): bool @@ -143,10 +130,6 @@ public function saveGroupPermissions(int $groupId, array $permissions): bool /** * Returns all of a given user’s permissions. - * - * @param int $userId - * - * @return array */ public function getPermissionsByUserId(int $userId): array { @@ -154,9 +137,6 @@ public function getPermissionsByUserId(int $userId): array } /** - * @param string $permission - * - * @return bool * @since 5.8.13.2 */ public function validatePermission(string $permission): bool @@ -166,11 +146,6 @@ public function validatePermission(string $permission): bool /** * Returns whether a given user has a given permission. - * - * @param int $userId - * @param string $checkPermission - * - * @return bool */ public function doesUserHavePermission(int $userId, string $checkPermission): bool { @@ -180,10 +155,7 @@ public function doesUserHavePermission(int $userId, string $checkPermission): bo /** * Saves new permissions for a user. * - * @param int $userId - * @param array $permissions * - * @return bool * @throws WrongEditionException if this is called from Craft Solo edition */ public function saveUserPermissions(int $userId, array $permissions): bool @@ -193,8 +165,6 @@ public function saveUserPermissions(int $userId, array $permissions): bool /** * Handle any changed group permissions. - * - * @param ConfigEvent $event */ public function handleChangedGroupPermissions(ConfigEvent $event): void { @@ -216,25 +186,18 @@ private function service(): UserPermissionsService return app(UserPermissionsService::class); } - public static function registerEvents(): void + /** @internal */ + public static function finalizeRegistrationEvents(): void { - Event::listen(UserPermissionsResolving::class, function(UserPermissionsResolving $event) { - if (Craft::$app->getUserPermissions()->hasEventHandlers(self::EVENT_REGISTER_PERMISSIONS)) { - $yiiEvent = new RegisterUserPermissionsEvent([ - 'permissions' => $event->permissions->toArray(), - ]); + if (!Craft::$app->getUserPermissions()->hasEventHandlers(self::EVENT_REGISTER_PERMISSIONS)) { + return; + } - Craft::$app->getUserPermissions()->trigger(self::EVENT_REGISTER_PERMISSIONS, $yiiEvent); - - $event->permissions = collect($yiiEvent->permissions)->map(function(array $group) { - return new PermissionGroup( - heading: $group['heading'], - permissions: collect(self::keyPermissions($group['permissions'])), - ); - }); - } - }); + app(UserPermissionsService::class)->reset(); + } + public static function registerEvents(): void + { Event::listen(UserGroupPermissionsSaved::class, function(UserGroupPermissionsSaved $event) { if (Craft::$app->getUserPermissions()->hasEventHandlers(self::EVENT_AFTER_SAVE_GROUP_PERMISSIONS)) { Craft::$app->getUserPermissions()->trigger(self::EVENT_AFTER_SAVE_GROUP_PERMISSIONS, new UserGroupPermissionsEvent([ @@ -253,19 +216,4 @@ public static function registerEvents(): void } }); } - - private static function keyPermissions(array $permissions): Collection - { - return collect($permissions)->map(function(array $permission, string $key) { - return new Permission( - key: $key, - label: $permission['label'], - info: $permission['info'] ?? null, - warning: $permission['warning'] ?? null, - nested: isset($permission['nested']) - ? self::keyPermissions($permission['nested']) - : collect(), - ); - }); - } } diff --git a/yii2-adapter/src/User/LegacyUserPermissions.php b/yii2-adapter/src/User/LegacyUserPermissions.php new file mode 100644 index 00000000000..576f3a7a57f --- /dev/null +++ b/yii2-adapter/src/User/LegacyUserPermissions.php @@ -0,0 +1,67 @@ + */ + private Collection $legacyPermissions; + + #[Override] + public function getAllPermissions(): Collection + { + if (isset($this->legacyPermissions)) { + return $this->legacyPermissions; + } + + $groups = parent::getAllPermissions(); + $service = Craft::$app->getUserPermissions(); + + if (!$service->hasEventHandlers(LegacyUserPermissionsService::EVENT_REGISTER_PERMISSIONS)) { + return $groups; + } + + $event = new RegisterUserPermissionsEvent(['permissions' => $groups->values()->toArray()]); + $service->trigger(LegacyUserPermissionsService::EVENT_REGISTER_PERMISSIONS, $event); + + $groups = collect($event->permissions)->map(fn(array $group, int|string $key) => new PermissionGroup( + handle: $group['handle'] ?? "yii2-adapter:legacy:$key", + heading: $group['heading'], + permissions: $this->keyPermissions($group['permissions']), + )); + + return $this->legacyPermissions = $groups->values(); + } + + #[Override] + public function reset(): void + { + unset($this->legacyPermissions); + parent::reset(); + } + + private function keyPermissions(array $permissions): Collection + { + return collect($permissions)->map(fn(array $permission, string $key) => new Permission( + key: $key, + label: $permission['label'], + info: $permission['info'] ?? null, + warning: $permission['warning'] ?? null, + nested: isset($permission['nested']) + ? $this->keyPermissions($permission['nested']) + : collect(), + )); + } +} diff --git a/yii2-adapter/tests-laravel/Legacy/PermissionRegistryCompatibilityTest.php b/yii2-adapter/tests-laravel/Legacy/PermissionRegistryCompatibilityTest.php new file mode 100644 index 00000000000..90d36c3ea45 --- /dev/null +++ b/yii2-adapter/tests-laravel/Legacy/PermissionRegistryCompatibilityTest.php @@ -0,0 +1,182 @@ +instance(Plugins::class, Mockery::mock(Plugins::class, function($mock) { + $mock->shouldReceive('getAllPlugins')->andReturn([])->byDefault(); + })); + app()->instance(Utilities::class, Mockery::mock(Utilities::class, function($mock) { + $mock->shouldReceive('getAllUtilityTypes')->andReturn(Collection::make())->byDefault(); + })); + UserGroups::shouldReceive('getAllGroups')->andReturn(Collection::make())->byDefault(); + Sites::shouldReceive('isMultiSite')->andReturnFalse()->byDefault(); + Sections::shouldReceive('getAllSections')->andReturn(Collection::make())->byDefault(); + Volumes::shouldReceive('getAllVolumes')->andReturn(Collection::make())->byDefault(); +}); + +afterEach(function() { + YiiEvent::off(LegacyUserPermissions::class, LegacyUserPermissions::EVENT_REGISTER_PERMISSIONS); +}); + +it('accepts duplicate legacy headings and associative group keys', function() { + YiiEvent::off(LegacyUserPermissions::class, LegacyUserPermissions::EVENT_REGISTER_PERMISSIONS); + $service = app(UserPermissions::class); + YiiEvent::on( + LegacyUserPermissions::class, + LegacyUserPermissions::EVENT_REGISTER_PERMISSIONS, + function(RegisterUserPermissionsEvent $event) { + $event->permissions[] = [ + 'heading' => 'Shared heading', + 'permissions' => [], + ]; + $event->permissions[] = [ + 'heading' => 'Shared heading', + 'permissions' => [], + ]; + $event->permissions['wheelform'] = [ + 'heading' => 'Wheelform', + 'permissions' => [], + ]; + }, + ); + + LegacyUserPermissions::finalizeRegistrationEvents(); + + $groups = $service->getAllPermissions(); + + expect($groups->where('heading', 'Shared heading'))->toHaveCount(2) + ->and($groups->pluck('handle')->unique())->toHaveCount($groups->count()) + ->and($groups->firstWhere('heading', 'Wheelform')->handle)->toBe('yii2-adapter:legacy:wheelform'); +}); + +it('preserves stable permission group handles when legacy handlers change headings', function() { + YiiEvent::off(LegacyUserPermissions::class, LegacyUserPermissions::EVENT_REGISTER_PERMISSIONS); + $service = app(UserPermissions::class); + $service->registerPermissionGroup('plugin:example', fn() => new PermissionGroup('plugin:example', 'Original heading')); + YiiEvent::on( + LegacyUserPermissions::class, + LegacyUserPermissions::EVENT_REGISTER_PERMISSIONS, + function(RegisterUserPermissionsEvent $event) { + foreach ($event->permissions as &$group) { + if (($group['handle'] ?? null) === 'plugin:example') { + $group['heading'] = 'Translated heading'; + } + } + }, + ); + + LegacyUserPermissions::finalizeRegistrationEvents(); + + $groups = $service->getAllPermissions(); + + expect($groups->firstWhere('handle', 'plugin:example')->heading)->toBe('Translated heading'); +}); + +it('finalizes the legacy permission transformer idempotently', function() { + $calls = 0; + YiiEvent::on( + LegacyUserPermissions::class, + LegacyUserPermissions::EVENT_REGISTER_PERMISSIONS, + function() use (&$calls) { + $calls++; + }, + ); + + LegacyUserPermissions::finalizeRegistrationEvents(); + LegacyUserPermissions::finalizeRegistrationEvents(); + app(UserPermissions::class)->getAllPermissions(); + + expect($calls)->toBe(1); +}); + +it('rebuilds legacy permissions from current resources and locale', function() { + YiiEvent::off(LegacyUserPermissions::class, LegacyUserPermissions::EVENT_REGISTER_PERMISSIONS); + $resource = 'old'; + $originalLocale = app()->getLocale(); + + app()->instance(Plugins::class, Mockery::mock(Plugins::class, function($mock) { + $mock->shouldReceive('getAllPlugins')->andReturn([]); + })); + app()->instance(Utilities::class, Mockery::mock(Utilities::class, function($mock) { + $mock->shouldReceive('getAllUtilityTypes')->andReturn(Collection::make()); + })); + UserGroups::shouldReceive('getAllGroups')->andReturn(Collection::make()); + Sites::shouldReceive('isMultiSite')->andReturnTrue(); + Sites::shouldReceive('getAllSites')->with(true)->andReturnUsing(function() use (&$resource) { + $site = new Site(); + $site->name = ucfirst($resource); + $site->uid = "$resource-site"; + + return collect([$site]); + }); + Sections::shouldReceive('getAllSections')->andReturnUsing(function() use (&$resource) { + return collect([new Section([ + 'name' => ucfirst($resource), + 'type' => SectionType::Channel, + 'uid' => "$resource-section", + ])]); + }); + Volumes::shouldReceive('getAllVolumes')->andReturnUsing(function() use (&$resource) { + return collect([new Volume([ + 'name' => ucfirst($resource), + 'uid' => "$resource-volume", + ])]); + }); + + $eventLocales = []; + YiiEvent::on( + LegacyUserPermissions::class, + LegacyUserPermissions::EVENT_REGISTER_PERMISSIONS, + function() use (&$eventLocales) { + $eventLocales[] = app()->getLocale(); + }, + ); + + try { + LegacyUserPermissions::finalizeRegistrationEvents(); + + $service = app(UserPermissions::class); + $first = $service->getAllPermissions(); + + expect($first->flatMap(fn(PermissionGroup $group) => $group->keys)) + ->toContain('editSite:old-site', 'viewEntries:old-section', 'viewAssets:old-volume'); + + $resource = 'reset'; + $service->reset(); + $afterReset = $service->getAllPermissions(); + + expect($afterReset->flatMap(fn(PermissionGroup $group) => $group->keys)) + ->toContain('editSite:reset-site', 'viewEntries:reset-section', 'viewAssets:reset-volume') + ->not()->toContain('editSite:old-site', 'viewEntries:old-section', 'viewAssets:old-volume'); + + $resource = 'scope'; + app()->setLocale('fr'); + app()->forgetScopedInstances(); + $nextScope = app(UserPermissions::class)->getAllPermissions(); + + expect($nextScope->flatMap(fn(PermissionGroup $group) => $group->keys)) + ->toContain('editSite:scope-site', 'viewEntries:scope-section', 'viewAssets:scope-volume') + ->not()->toContain('editSite:reset-site', 'viewEntries:reset-section', 'viewAssets:reset-volume') + ->and(collect($eventLocales)->last())->toBe('fr'); + } finally { + app()->setLocale($originalLocale); + } +}); From 0ec380b27dcfbfd0f437bc74852257d2a0d228a9 Mon Sep 17 00:00:00 2001 From: Rias Date: Sat, 25 Jul 2026 09:08:54 +0200 Subject: [PATCH 15/21] Register template roots and cache collectors explicitly --- src/Plugin/Concerns/HasViews.php | 32 ++++----- src/View/Events/CpTemplateRootsResolving.php | 17 ----- .../Events/SiteTemplateRootsResolving.php | 17 ----- .../TemplateCacheCollectorsResolving.php | 32 --------- src/View/TemplateCaches.php | 19 ++---- src/View/TemplateMode.php | 24 +------ src/View/TemplateRoots.php | 54 +++++++++++++++ src/View/ViewServiceProvider.php | 3 +- .../Controllers/SiteRouteControllerTest.php | 6 +- tests/Unit/Plugin/Concerns/HasViewsTest.php | 18 ++--- tests/Unit/Twig/TemplateResolverTest.php | 16 +---- .../Unit/View/TemplateCacheCollectorsTest.php | 56 ++++++++++++++++ tests/Unit/View/TemplateCachesTest.php | 31 +++------ tests/Unit/View/TemplateModeTest.php | 19 ------ tests/Unit/View/TemplateRootsTest.php | 42 ++++++++++++ tests/Unit/View/TwigEngineTest.php | 13 +--- .../events/RegisterTemplateRootsEvent.php | 2 +- yii2-adapter/legacy/web/View.php | 49 +++++++------- .../Http/LegacySiteTemplateRoutingTest.php | 2 - .../RegisterTemplateCacheCollectorsTest.php | 9 +-- yii2-adapter/tests/unit/web/ViewTest.php | 66 +++++-------------- 21 files changed, 235 insertions(+), 292 deletions(-) delete mode 100644 src/View/Events/CpTemplateRootsResolving.php delete mode 100644 src/View/Events/SiteTemplateRootsResolving.php delete mode 100644 src/View/Events/TemplateCacheCollectorsResolving.php create mode 100644 src/View/TemplateRoots.php create mode 100644 tests/Unit/View/TemplateCacheCollectorsTest.php create mode 100644 tests/Unit/View/TemplateRootsTest.php diff --git a/src/Plugin/Concerns/HasViews.php b/src/Plugin/Concerns/HasViews.php index 22a5566bb35..4ad35eee917 100644 --- a/src/Plugin/Concerns/HasViews.php +++ b/src/Plugin/Concerns/HasViews.php @@ -5,8 +5,8 @@ namespace CraftCms\Cms\Plugin\Concerns; use CraftCms\Cms\Plugin\Plugin; -use CraftCms\Cms\View\Events\CpTemplateRootsResolving; -use Illuminate\Support\Facades\Event; +use CraftCms\Cms\View\TemplateMode; +use CraftCms\Cms\View\TemplateRoots; /** * @mixin Plugin @@ -17,24 +17,16 @@ trait HasViews { public function bootHasViews(): void { - Event::listen(function (CpTemplateRootsResolving $event) { - $plugin = self::getInstance(); - $basePath = $plugin->getBasePath(); - $resourcesPath = $plugin->getResourcesPath(); - $handle = $plugin->handle; + $baseDirs = array_values(array_filter([ + $this->getResourcesPath().'/views', + $this->getResourcesPath().'/templates', + $this->getBasePath().'/templates', + ], is_dir(...))); - $baseDirs = array_values(array_filter([ - // Laravel Convention - $resourcesPath.'/views', - // Laravel Convention for resources, Twig convention for templates - $resourcesPath.'/templates', - // Craft 5 and earlier - $basePath.'/templates', - ], is_dir(...))); - - if ($baseDirs !== []) { - $event->roots[$handle] = $baseDirs; - } - }); + $this->app->make(TemplateRoots::class)->register( + TemplateMode::Cp, + $this->handle, + ...$baseDirs, + ); } } diff --git a/src/View/Events/CpTemplateRootsResolving.php b/src/View/Events/CpTemplateRootsResolving.php deleted file mode 100644 index 5a5e1725ac2..00000000000 --- a/src/View/Events/CpTemplateRootsResolving.php +++ /dev/null @@ -1,17 +0,0 @@ - The registered template roots. Each key should be a root template path, and values should be the - * corresponding directory path, or an array of directory paths. - */ - public array $roots = []; -} diff --git a/src/View/Events/SiteTemplateRootsResolving.php b/src/View/Events/SiteTemplateRootsResolving.php deleted file mode 100644 index a03e8b43d90..00000000000 --- a/src/View/Events/SiteTemplateRootsResolving.php +++ /dev/null @@ -1,17 +0,0 @@ - The registered template roots. Each key should be a root template path, and values should be the - * corresponding directory path, or an array of directory paths. - */ - public array $roots = []; -} diff --git a/src/View/Events/TemplateCacheCollectorsResolving.php b/src/View/Events/TemplateCacheCollectorsResolving.php deleted file mode 100644 index 988b5e1fecb..00000000000 --- a/src/View/Events/TemplateCacheCollectorsResolving.php +++ /dev/null @@ -1,32 +0,0 @@ -types->add(MyCollector::class); - * }); - * ``` - */ -class TemplateCacheCollectorsResolving -{ - /** - * @param Collection> $types - */ - public function __construct( - public Collection $types, - ) {} -} diff --git a/src/View/TemplateCaches.php b/src/View/TemplateCaches.php index f8d0533e9e3..81e50987454 100644 --- a/src/View/TemplateCaches.php +++ b/src/View/TemplateCaches.php @@ -8,15 +8,12 @@ use CraftCms\Cms\Support\DateTimeHelper; use CraftCms\Cms\Support\Facades\Sites; use CraftCms\Cms\View\CacheCollectors\DependencyCollector; -use CraftCms\Cms\View\CacheCollectors\ResourceCollector; use CraftCms\Cms\View\Contracts\CacheCollectorInterface; use CraftCms\Cms\View\Data\TemplateCacheContext; -use CraftCms\Cms\View\Events\TemplateCacheCollectorsResolving; use CraftCms\DependencyAwareCache\Dependency\TagDependency; use CraftCms\DependencyAwareCache\Facades\DependencyCache; use Illuminate\Container\Attributes\Scoped; use Illuminate\Pagination\AbstractPaginator; -use Illuminate\Support\Collection; use Illuminate\Support\Facades\Date; use InvalidArgumentException; @@ -38,6 +35,7 @@ class TemplateCaches public function __construct( private readonly DependencyCollector $dependencyCollector, + private readonly TemplateCacheCollectors $cacheCollectors, ) {} public function getTemplateCache(string $key, bool $global, bool $registerResources = false): ?string @@ -144,14 +142,8 @@ private function collectors(): array return $this->collectors; } - event($event = new TemplateCacheCollectorsResolving(Collection::make())); - - $this->collectors = collect([ - DependencyCollector::class, - ResourceCollector::class, - ]) - ->concat($event->types) - ->map(function (string $type): CacheCollectorInterface { + $this->collectors = array_map( + function (string $type): CacheCollectorInterface { $collector = app($type); if (! $collector instanceof CacheCollectorInterface) { @@ -159,8 +151,9 @@ private function collectors(): array } return $collector; - }) - ->all(); + }, + $this->cacheCollectors->types()->all(), + ); return $this->collectors; } diff --git a/src/View/TemplateMode.php b/src/View/TemplateMode.php index a91af235147..9d3a97078b8 100644 --- a/src/View/TemplateMode.php +++ b/src/View/TemplateMode.php @@ -6,8 +6,6 @@ use CraftCms\Cms\Cms; use CraftCms\Cms\Support\Facades\Path; -use CraftCms\Cms\View\Events\CpTemplateRootsResolving; -use CraftCms\Cms\View\Events\SiteTemplateRootsResolving; use Illuminate\Support\Facades\Context; use Illuminate\View\FileViewFinder; @@ -115,26 +113,6 @@ public function templatesPath(): string public function templateRoots(): array { - return once(function () { - $templateRoots = []; - - event($event = match ($this) { - self::Cp => new CpTemplateRootsResolving, - self::Site => new SiteTemplateRootsResolving, - }); - - foreach ($event->roots as $templatePath => $dir) { - $templatePath = strtolower(trim($templatePath, '/')); - - $templateRoots[$templatePath] ??= []; - - array_push($templateRoots[$templatePath], ...(array) $dir); - } - - // Longest (most specific) first - krsort($templateRoots, SORT_STRING); - - return $templateRoots; - }); + return app(TemplateRoots::class)->roots($this); } } diff --git a/src/View/TemplateRoots.php b/src/View/TemplateRoots.php new file mode 100644 index 00000000000..e58fca40e31 --- /dev/null +++ b/src/View/TemplateRoots.php @@ -0,0 +1,54 @@ +register( + * TemplateMode::Cp, + * 'my-plugin', + * __DIR__.'/templates', + * ); + * } + * ``` + */ +#[Singleton] +class TemplateRoots +{ + /** @var array>> */ + private array $roots = []; + + public function register(TemplateMode $mode, string $namespace, string ...$paths): void + { + $namespace = strtolower(trim($namespace, '/')); + + foreach ($paths as $path) { + $this->roots[$mode->value][$namespace][$path] = $path; + } + } + + public function remove(TemplateMode $mode, string ...$namespaces): void + { + foreach ($namespaces as $namespace) { + $namespace = strtolower(trim($namespace, '/')); + unset($this->roots[$mode->value][$namespace]); + } + } + + /** @return array> */ + public function roots(TemplateMode $mode): array + { + $roots = array_map(array_values(...), $this->roots[$mode->value] ?? []); + krsort($roots, SORT_STRING); + + return $roots; + } +} diff --git a/src/View/ViewServiceProvider.php b/src/View/ViewServiceProvider.php index 635e197dbde..a8be81aca8d 100644 --- a/src/View/ViewServiceProvider.php +++ b/src/View/ViewServiceProvider.php @@ -15,6 +15,7 @@ use CraftCms\Cms\View\Events\ViewAssetsRendering; use CraftCms\Cms\View\LegacyAssets\InternalAssetRegistry; use Illuminate\Contracts\View\Factory as ViewFactory; +use Illuminate\Foundation\Bootstrap\BootProviders; use Illuminate\Support\Facades\Event; use Illuminate\Support\Facades\View; use Illuminate\Support\ServiceProvider; @@ -68,7 +69,7 @@ private function registerTemplateGlobals(): void private function registerTemplateRoots(): void { - $this->app->booted(function () { + $this->app->afterBootstrapping(BootProviders::class, function () { /** @var Factory $factory */ $factory = $this->app->make(ViewFactory::class); diff --git a/tests/Feature/Http/Controllers/SiteRouteControllerTest.php b/tests/Feature/Http/Controllers/SiteRouteControllerTest.php index 2193640bfa3..a81962834b2 100644 --- a/tests/Feature/Http/Controllers/SiteRouteControllerTest.php +++ b/tests/Feature/Http/Controllers/SiteRouteControllerTest.php @@ -15,8 +15,8 @@ use CraftCms\Cms\RouteToken\RouteTokens; use CraftCms\Cms\Section\Models\Section; use CraftCms\Cms\User\Models\User as UserModel; -use CraftCms\Cms\View\Events\SiteTemplateRootsResolving; use CraftCms\Cms\View\TemplateMode; +use CraftCms\Cms\View\TemplateRoots; use Illuminate\Http\JsonResponse; use Illuminate\Http\Request; use Illuminate\Support\Facades\Context; @@ -32,9 +32,7 @@ TemplateMode::set(TemplateMode::Site); Aliases::set('@templates', dirname(__DIR__, 3).'/Support/templates'); - Event::listen(function (SiteTemplateRootsResolving $event) { - $event->roots[''] = dirname(__DIR__, 3).'/Support/templates'; - }); + app(TemplateRoots::class)->register(TemplateMode::Site, '', dirname(__DIR__, 3).'/Support/templates'); }); diff --git a/tests/Unit/Plugin/Concerns/HasViewsTest.php b/tests/Unit/Plugin/Concerns/HasViewsTest.php index 314a8ceac2b..442d9b93dc3 100644 --- a/tests/Unit/Plugin/Concerns/HasViewsTest.php +++ b/tests/Unit/Plugin/Concerns/HasViewsTest.php @@ -3,11 +3,8 @@ declare(strict_types=1); use CraftCms\Cms\Tests\TestClasses\TestPlugin\src\TestPlugin; -use CraftCms\Cms\View\Events\CpTemplateRootsResolving; use CraftCms\Cms\View\TemplateMode; -use Illuminate\Contracts\Events\Dispatcher; use Illuminate\Support\Facades\File; -use Illuminate\Support\Once; use Illuminate\View\Factory as ViewFactory; beforeEach(function () { @@ -15,9 +12,7 @@ }); afterEach(function () { - app(Dispatcher::class)->forget(CpTemplateRootsResolving::class); app()->forgetInstance(TestPlugin::class); - Once::flush(); }); it('registers the plugin template roots when views or templates exist', function () { @@ -29,12 +24,11 @@ $plugin->useBasePath(dirname(__DIR__, 3).'/TestClasses/TestPlugin/src'); $plugin->bootHasViews(); - $event = new CpTemplateRootsResolving; - event($event); + $roots = TemplateMode::Cp->templateRoots(); - expect($event->roots) + expect($roots) ->toHaveKey('test-plugin') - ->and($event->roots['test-plugin']) + ->and($roots['test-plugin']) ->toBe([ dirname(__DIR__, 3).'/TestClasses/TestPlugin/resources/views', dirname(__DIR__, 3).'/TestClasses/TestPlugin/resources/templates', @@ -53,10 +47,7 @@ $plugin->useBasePath($emptyPluginPath); $plugin->bootHasViews(); - $event = new CpTemplateRootsResolving; - event($event); - - expect($event->roots)->not->toHaveKey('other-plugin'); + expect(TemplateMode::Cp->templateRoots())->not->toHaveKey('other-plugin'); File::deleteDirectory(dirname($emptyPluginPath, 2)); }); @@ -70,7 +61,6 @@ $plugin->useBasePath(dirname(__DIR__, 3).'/TestClasses/TestPlugin/src'); $plugin->bootHasViews(); - Once::flush(); TemplateMode::set(TemplateMode::Cp); /** @var ViewFactory $viewFactory */ diff --git a/tests/Unit/Twig/TemplateResolverTest.php b/tests/Unit/Twig/TemplateResolverTest.php index 20c40df0321..56d10069182 100644 --- a/tests/Unit/Twig/TemplateResolverTest.php +++ b/tests/Unit/Twig/TemplateResolverTest.php @@ -5,12 +5,10 @@ use CraftCms\Aliases\Aliases; use CraftCms\Cms\Cms; use CraftCms\Cms\Support\File as Path; -use CraftCms\Cms\View\Events\CpTemplateRootsResolving; use CraftCms\Cms\View\TemplateMode; use CraftCms\Cms\View\TemplateResolver; -use Illuminate\Support\Facades\Event; +use CraftCms\Cms\View\TemplateRoots; use Illuminate\Support\Facades\File; -use Illuminate\Support\Once; use Twig\Error\LoaderError; beforeEach(function () { @@ -229,11 +227,7 @@ mkdir($rootDir, 0777, true); file_put_contents($rootDir.'/page.twig', 'root content'); - Once::flush(); - - Event::listen(CpTemplateRootsResolving::class, function (CpTemplateRootsResolving $event) use ($rootDir) { - $event->roots['myroot'] = $rootDir; - }); + app(TemplateRoots::class)->register(TemplateMode::Cp, 'myroot', $rootDir); TemplateMode::set(TemplateMode::Cp); @@ -248,11 +242,7 @@ mkdir($rootDir, 0777, true); file_put_contents($rootDir.'/fallback.twig', 'fallback content'); - Once::flush(); - - Event::listen(CpTemplateRootsResolving::class, function (CpTemplateRootsResolving $event) use ($rootDir) { - $event->roots[''] = $rootDir; - }); + app(TemplateRoots::class)->register(TemplateMode::Cp, '', $rootDir); TemplateMode::set(TemplateMode::Cp); diff --git a/tests/Unit/View/TemplateCacheCollectorsTest.php b/tests/Unit/View/TemplateCacheCollectorsTest.php new file mode 100644 index 00000000000..ca52131c1c9 --- /dev/null +++ b/tests/Unit/View/TemplateCacheCollectorsTest.php @@ -0,0 +1,56 @@ +types()) + ->toContain(DependencyCollector::class, ResourceCollector::class); +}); + +it('rejects collectors that do not implement the contract', function () { + expect(fn () => app(TemplateCacheCollectors::class)->register(stdClass::class)) + ->toThrow(InvalidArgumentException::class); +}); + +it('rejects duplicate collector keys without partially registering the batch', function () { + $registry = app(TemplateCacheCollectors::class); + + expect(fn () => $registry->register(AnotherTemplateCacheCollector::class, DuplicateKeyTemplateCacheCollector::class)) + ->toThrow(InvalidArgumentException::class) + ->and($registry->types()) + ->not()->toContain(AnotherTemplateCacheCollector::class, DuplicateKeyTemplateCacheCollector::class); +}); + +class LazyTemplateCacheCollector implements CacheCollectorInterface +{ + public static function key(): string + { + return 'lazy'; + } + + public function begin(TemplateCacheContext $context): void {} + + public function end(TemplateCacheContext $context): mixed + { + return null; + } + + public function apply(mixed $payload, TemplateCacheContext $context): void {} +} + +class AnotherTemplateCacheCollector extends LazyTemplateCacheCollector +{ + #[Override] + public static function key(): string + { + return 'another'; + } +} + +class DuplicateKeyTemplateCacheCollector extends AnotherTemplateCacheCollector {} diff --git a/tests/Unit/View/TemplateCachesTest.php b/tests/Unit/View/TemplateCachesTest.php index 209fbf4d34e..ab133ae1f9a 100644 --- a/tests/Unit/View/TemplateCachesTest.php +++ b/tests/Unit/View/TemplateCachesTest.php @@ -8,13 +8,12 @@ use CraftCms\Cms\View\CacheCollectors\DependencyCollector; use CraftCms\Cms\View\Contracts\CacheCollectorInterface; use CraftCms\Cms\View\Data\TemplateCacheContext; -use CraftCms\Cms\View\Events\TemplateCacheCollectorsResolving; +use CraftCms\Cms\View\TemplateCacheCollectors; use CraftCms\Cms\View\TemplateCaches; use Illuminate\Http\Request; use Illuminate\Pagination\Paginator; use Illuminate\Support\Facades\Cache; use Illuminate\Support\Facades\Context; -use Illuminate\Support\Facades\Event; class TestTemplateCacheCollector implements CacheCollectorInterface { @@ -27,19 +26,19 @@ public static function key(): string public function begin(TemplateCacheContext $context): void { - self::$calls[] = ['begin', $context->cacheKey]; + static::$calls[] = ['begin', $context->cacheKey]; } public function end(TemplateCacheContext $context): array { - self::$calls[] = ['end', $context->cacheKey]; + static::$calls[] = ['end', $context->cacheKey]; return ['applied' => true]; } public function apply(mixed $payload, TemplateCacheContext $context): void { - self::$calls[] = ['apply', $context->cacheKey, $payload]; + static::$calls[] = ['apply', $context->cacheKey, $payload]; } } @@ -54,20 +53,9 @@ public function apply(mixed $payload, TemplateCacheContext $context): void TestTemplateCacheCollector::$calls = []; }); -it('dispatches the collector registration event', function () { - Event::fake(TemplateCacheCollectorsResolving::class); - - app(TemplateCaches::class)->startTemplateCache(global: true); - - Event::assertDispatched(TemplateCacheCollectorsResolving::class); -}); - -it('runs event-registered collectors', function () { - Event::listen(TemplateCacheCollectorsResolving::class, function (TemplateCacheCollectorsResolving $event) { - if ($event->types->doesntContain(TestTemplateCacheCollector::class)) { - $event->types->add(TestTemplateCacheCollector::class); - } - }); +it('runs registered collectors', function () { + $registry = app(TemplateCacheCollectors::class); + $registry->register(TestTemplateCacheCollector::class); $service = app(TemplateCaches::class); @@ -75,12 +63,9 @@ public function apply(mixed $payload, TemplateCacheContext $context): void $service->endTemplateCache('collector-cache', true, null, null, 'cached body'); $service->getTemplateCache('collector-cache', true); - expect(collect(TestTemplateCacheCollector::$calls)->contains( - fn (array $call) => $call === ['end', 'template::collector-cache::1'], - ))->toBeTrue() + expect(collect(TestTemplateCacheCollector::$calls)->pluck(0))->toContain('end', 'apply') ->and(collect(TestTemplateCacheCollector::$calls)->contains( fn (array $call) => $call[0] === 'apply' && - $call[1] === 'template::collector-cache::1' && ($call[2] ?? null) === ['applied' => true], ))->toBeTrue(); }); diff --git a/tests/Unit/View/TemplateModeTest.php b/tests/Unit/View/TemplateModeTest.php index a4c18116df3..8a5a197e373 100644 --- a/tests/Unit/View/TemplateModeTest.php +++ b/tests/Unit/View/TemplateModeTest.php @@ -3,12 +3,8 @@ declare(strict_types=1); use CraftCms\Cms\Cms; -use CraftCms\Cms\View\Events\CpTemplateRootsResolving; -use CraftCms\Cms\View\Events\SiteTemplateRootsResolving; use CraftCms\Cms\View\TemplateMode; use Illuminate\Support\Facades\Context; -use Illuminate\Support\Facades\Event; -use Illuminate\Support\Once; it('defaults to site mode for non-cp requests', function () { Context::forgetHidden(TemplateMode::class); @@ -117,18 +113,3 @@ expect(TemplateMode::Cp->value)->toBe('cp'); expect(TemplateMode::Site->value)->toBe('site'); }); - -it('dispatches the correct event for template roots', function () { - Once::flush(); - Event::fake([CpTemplateRootsResolving::class, SiteTemplateRootsResolving::class]); - - TemplateMode::Cp->templateRoots(); - - Event::assertDispatched(CpTemplateRootsResolving::class); - Event::assertNotDispatched(SiteTemplateRootsResolving::class); - - TemplateMode::Site->templateRoots(); - - Event::assertDispatchedOnce(CpTemplateRootsResolving::class); - Event::assertDispatchedOnce(SiteTemplateRootsResolving::class); -}); diff --git a/tests/Unit/View/TemplateRootsTest.php b/tests/Unit/View/TemplateRootsTest.php new file mode 100644 index 00000000000..44ea3c3e4aa --- /dev/null +++ b/tests/Unit/View/TemplateRootsTest.php @@ -0,0 +1,42 @@ +register(TemplateMode::Cp, 'plugin', '/first', '/second', '/first'); + $registry->register(TemplateMode::Site, 'plugin', '/site'); + + expect($registry->roots(TemplateMode::Cp))->toBe(['plugin' => ['/first', '/second']]) + ->and($registry->roots(TemplateMode::Site))->toBe(['plugin' => ['/site']]); +}); + +it('removes namespaces within one template mode', function () { + $registry = app(TemplateRoots::class); + $registry->register(TemplateMode::Cp, 'plugin', '/first'); + $registry->register(TemplateMode::Cp, 'removed', '/removed'); + $registry->register(TemplateMode::Site, 'plugin', '/site'); + + $registry->remove(TemplateMode::Cp, 'removed', 'missing'); + + expect($registry->roots(TemplateMode::Cp))->toBe(['plugin' => ['/first']]) + ->and($registry->roots(TemplateMode::Site))->toBe(['plugin' => ['/site']]); +}); + +it('exposes current roots through template modes', function () { + $registry = app(TemplateRoots::class); + + expect(TemplateMode::Cp->templateRoots())->not()->toHaveKey('plugin'); + + $registry->register(TemplateMode::Cp, 'plugin', '/templates'); + + expect(TemplateMode::Cp->templateRoots())->toBe(['plugin' => ['/templates']]); + + $registry->remove(TemplateMode::Cp, 'plugin'); + + expect(TemplateMode::Cp->templateRoots())->not()->toHaveKey('plugin'); +}); diff --git a/tests/Unit/View/TwigEngineTest.php b/tests/Unit/View/TwigEngineTest.php index 94c6d075558..ff6739b674d 100644 --- a/tests/Unit/View/TwigEngineTest.php +++ b/tests/Unit/View/TwigEngineTest.php @@ -3,18 +3,13 @@ declare(strict_types=1); use CraftCms\Cms\Support\Facades\Template; -use CraftCms\Cms\View\Events\CpTemplateRootsResolving; use CraftCms\Cms\View\TemplateMode; +use CraftCms\Cms\View\TemplateRoots; use CraftCms\Cms\View\TwigEngine; -use Illuminate\Contracts\Events\Dispatcher; -use Illuminate\Support\Facades\Event; use Illuminate\Support\Facades\File; -use Illuminate\Support\Once; afterEach(function () { Template::clearResolvedInstance(); - app(Dispatcher::class)->forget(CpTemplateRootsResolving::class); - Once::flush(); }); it('maps plugin view paths to Craft template root names', function () { @@ -22,11 +17,7 @@ File::ensureDirectoryExists("{$root}/tokens"); File::put("{$root}/tokens/index.twig", 'Plugin {{ value }}'); - Once::flush(); - - Event::listen(CpTemplateRootsResolving::class, function (CpTemplateRootsResolving $event) use ($root) { - $event->roots['mcp'] = $root; - }); + app(TemplateRoots::class)->register(TemplateMode::Cp, 'mcp', $root); TemplateMode::set(TemplateMode::Cp); diff --git a/yii2-adapter/legacy/events/RegisterTemplateRootsEvent.php b/yii2-adapter/legacy/events/RegisterTemplateRootsEvent.php index c421590427c..02deb3f5da8 100644 --- a/yii2-adapter/legacy/events/RegisterTemplateRootsEvent.php +++ b/yii2-adapter/legacy/events/RegisterTemplateRootsEvent.php @@ -14,7 +14,7 @@ * * @author Pixel & Tonic, Inc. * @since 3.0.0 - * @deprecated 6.0.0 use {@see \CraftCms\Cms\View\Events\CpTemplateRootsResolving} or {@see \CraftCms\Cms\View\Events\SiteTemplateRootsResolving} instead. + * @deprecated 6.0.0 use {@see \CraftCms\Cms\View\TemplateRoots::register()} instead. */ class RegisterTemplateRootsEvent extends Event { diff --git a/yii2-adapter/legacy/web/View.php b/yii2-adapter/legacy/web/View.php index 71b99252c4b..24255e4c0fc 100644 --- a/yii2-adapter/legacy/web/View.php +++ b/yii2-adapter/legacy/web/View.php @@ -30,10 +30,8 @@ use CraftCms\Cms\Twig\Events\TwigCreated; use CraftCms\Cms\Twig\Twig; use CraftCms\Cms\View\Enums\Position; -use CraftCms\Cms\View\Events\CpTemplateRootsResolving; use CraftCms\Cms\View\Events\PageTemplateRendered; use CraftCms\Cms\View\Events\PageTemplateRendering; -use CraftCms\Cms\View\Events\SiteTemplateRootsResolving; use CraftCms\Cms\View\Events\TemplateRendered; use CraftCms\Cms\View\Events\TemplateRendering; use CraftCms\Cms\View\Events\ViewAssetsRendering; @@ -44,6 +42,7 @@ use CraftCms\Cms\View\TemplateManager; use CraftCms\Cms\View\TemplateMode; use CraftCms\Cms\View\TemplateResolver; +use CraftCms\Cms\View\TemplateRoots; use Illuminate\Support\Facades\Event; use Throwable; use Twig\Error\LoaderError as TwigLoaderError; @@ -90,14 +89,14 @@ class View extends \yii\web\View /** * @event RegisterTemplateRootsEvent The event that is triggered when registering control panel template roots * - * @deprecated 6.0.0 use {@see \CraftCms\Cms\View\Events\CpTemplateRootsResolving} instead. + * @deprecated 6.0.0 use {@see TemplateRoots::register()} instead. */ public const EVENT_REGISTER_CP_TEMPLATE_ROOTS = 'registerCpTemplateRoots'; /** * @event RegisterTemplateRootsEvent The event that is triggered when registering site template roots * - * @deprecated 6.0.0 use {@see \CraftCms\Cms\View\Events\SiteTemplateRootsResolving} instead. + * @deprecated 6.0.0 use {@see TemplateRoots::register()} instead. */ public const EVENT_REGISTER_SITE_TEMPLATE_ROOTS = 'registerSiteTemplateRoots'; @@ -2224,26 +2223,6 @@ public function elementChipHtml(array $context): ?string public static function registerEvents(): void { - Event::listen(CpTemplateRootsResolving::class, function(CpTemplateRootsResolving $event) { - if (!Craft::$app->getView()->hasEventHandlers(self::EVENT_REGISTER_CP_TEMPLATE_ROOTS)) { - return; - } - - $yiiEvent = new RegisterTemplateRootsEvent(); - Craft::$app->getView()->trigger(self::EVENT_REGISTER_CP_TEMPLATE_ROOTS, $yiiEvent); - $event->roots = array_merge($event->roots, $yiiEvent->roots); - }); - - Event::listen(SiteTemplateRootsResolving::class, function(SiteTemplateRootsResolving $event) { - if (!Craft::$app->getView()->hasEventHandlers(self::EVENT_REGISTER_SITE_TEMPLATE_ROOTS)) { - return; - } - - $yiiEvent = new RegisterTemplateRootsEvent(); - Craft::$app->getView()->trigger(self::EVENT_REGISTER_SITE_TEMPLATE_ROOTS, $yiiEvent); - $event->roots = array_merge($event->roots, $yiiEvent->roots); - }); - Event::listen(function(TwigCreated $event) { if (!Craft::$app->getView()->hasEventHandlers(self::EVENT_AFTER_CREATE_TWIG)) { return; @@ -2344,4 +2323,26 @@ public static function registerEvents(): void Craft::$app->getView()->flushPendingAssets(); }); } + + /** @internal */ + public static function finalizeRegistrationEvents(): void + { + self::registerTemplateRoots(TemplateMode::Cp, self::EVENT_REGISTER_CP_TEMPLATE_ROOTS); + self::registerTemplateRoots(TemplateMode::Site, self::EVENT_REGISTER_SITE_TEMPLATE_ROOTS); + } + + private static function registerTemplateRoots(TemplateMode $mode, string $legacyEvent): void + { + if (!Craft::$app->getView()->hasEventHandlers($legacyEvent)) { + return; + } + + $registry = app(TemplateRoots::class); + $yiiEvent = new RegisterTemplateRootsEvent(); + Craft::$app->getView()->trigger($legacyEvent, $yiiEvent); + + foreach ($yiiEvent->roots as $namespace => $paths) { + $registry->register($mode, $namespace, ...(array)$paths); + } + } } diff --git a/yii2-adapter/tests-laravel/Http/LegacySiteTemplateRoutingTest.php b/yii2-adapter/tests-laravel/Http/LegacySiteTemplateRoutingTest.php index 9a317b5097d..3f773a8546b 100644 --- a/yii2-adapter/tests-laravel/Http/LegacySiteTemplateRoutingTest.php +++ b/yii2-adapter/tests-laravel/Http/LegacySiteTemplateRoutingTest.php @@ -6,7 +6,6 @@ use CraftCms\Cms\View\TemplateMode; use CraftCms\Cms\View\TemplateResolver; use Illuminate\Support\Facades\File; -use Illuminate\Support\Once; it('resolves legacy site templates from the base templates directory after resource views', function() { $templatesPath = base_path('templates'); @@ -18,7 +17,6 @@ File::put("$templatesPath/shared.twig", 'legacy-shared-route'); File::put("$resourcesPath/shared.twig", 'resource-shared-route'); Cms::setIsInstalled(false); - Once::flush(); try { $resolver = app(TemplateResolver::class); diff --git a/yii2-adapter/tests-laravel/View/RegisterTemplateCacheCollectorsTest.php b/yii2-adapter/tests-laravel/View/RegisterTemplateCacheCollectorsTest.php index 16fd1260ee7..d2ff06b0254 100644 --- a/yii2-adapter/tests-laravel/View/RegisterTemplateCacheCollectorsTest.php +++ b/yii2-adapter/tests-laravel/View/RegisterTemplateCacheCollectorsTest.php @@ -2,14 +2,9 @@ declare(strict_types=1); -use CraftCms\Cms\View\Events\TemplateCacheCollectorsResolving; +use CraftCms\Cms\View\TemplateCacheCollectors; use CraftCms\Yii2Adapter\View\LegacyAssetBundleCollector; -use Illuminate\Support\Collection; it('registers the legacy asset bundle collector', function() { - $event = new TemplateCacheCollectorsResolving(Collection::make()); - - event($event); - - expect($event->types)->toContain(LegacyAssetBundleCollector::class); + expect(app(TemplateCacheCollectors::class)->types())->toContain(LegacyAssetBundleCollector::class); }); diff --git a/yii2-adapter/tests/unit/web/ViewTest.php b/yii2-adapter/tests/unit/web/ViewTest.php index 51125bb94d2..b40ba669056 100644 --- a/yii2-adapter/tests/unit/web/ViewTest.php +++ b/yii2-adapter/tests/unit/web/ViewTest.php @@ -16,13 +16,11 @@ use CraftCms\Aliases\Aliases; use CraftCms\Cms\Cms; use CraftCms\Cms\Support\Facades\Sites; -use CraftCms\Cms\View\Events\SiteTemplateRootsResolving; use CraftCms\Cms\View\HtmlStack; use CraftCms\Cms\View\TemplateMode; use CraftCms\Cms\View\TemplateResolver; +use CraftCms\Cms\View\TemplateRoots; use crafttests\fixtures\SitesFixture; -use Illuminate\Support\Facades\Event as LaravelEvent; -use Illuminate\Support\Once; use ReflectionException; use Throwable; use Twig\Error\LoaderError; @@ -290,45 +288,23 @@ public function testNamespaceInputId(string $expected, string $string, ?string $ /** * @dataProvider getTemplateRootsDataProvider * @param array $expected - * @param string $which * @param array $roots - * @throws ReflectionException */ - public function testGetTemplateRoots(array $expected, string $which, array $roots): void + public function testGetTemplateRoots(array $expected, array $roots): void { - Once::flush(); - - LaravelEvent::listen(SiteTemplateRootsResolving::class, function(SiteTemplateRootsResolving $event) use ($roots) { - $event->roots = $roots; - }); - - self::assertSame($expected, TemplateMode::Site->templateRoots()); - } - - /** - * Testing these events is quite important as they are quite integral to this function working. - */ - public function testGetTemplateRootsEvents(): void - { - Once::flush(); - - $cpEventTriggered = false; - Event::on(View::class, View::EVENT_REGISTER_CP_TEMPLATE_ROOTS, function() use (&$cpEventTriggered) { - $cpEventTriggered = true; - }); - - TemplateMode::Cp->templateRoots(); - self::assertTrue($cpEventTriggered, 'Asserting that the CP template roots Yii event is triggered.'); - - Once::flush(); + $originalRegistry = app(TemplateRoots::class); + $registry = new TemplateRoots(); + app()->instance(TemplateRoots::class, $registry); - $siteEventTriggered = false; - Event::on(View::class, View::EVENT_REGISTER_SITE_TEMPLATE_ROOTS, function() use (&$siteEventTriggered) { - $siteEventTriggered = true; - }); + try { + foreach ($roots as $handle => $path) { + $registry->register(TemplateMode::Site, $handle, ...(array)$path); + } - TemplateMode::Site->templateRoots(); - self::assertTrue($siteEventTriggered, 'Asserting that the site template roots Yii event is triggered.'); + self::assertSame($expected, TemplateMode::Site->templateRoots()); + } finally { + app()->instance(TemplateRoots::class, $originalRegistry); + } } /** @@ -647,9 +623,8 @@ public static function namespaceInputIdDataProvider(): array public static function getTemplateRootsDataProvider(): array { return [ - [['random-roots' => [null]], 'random-roots', ['random-roots' => [null]]], - [['random-roots' => ['/linux/box/craft/templates']], 'random-roots', ['random-roots' => '/linux/box/craft/templates']], - [['random-roots' => ['windows/box/craft/templates', '/linux/box/craft/templates']], 'random-roots', ['random-roots' => ['windows/box/craft/templates', '/linux/box/craft/templates']]], + [['random-roots' => ['/linux/box/craft/templates']], ['random-roots' => '/linux/box/craft/templates']], + [['random-roots' => ['windows/box/craft/templates', '/linux/box/craft/templates']], ['random-roots' => ['windows/box/craft/templates', '/linux/box/craft/templates']]], ]; } @@ -669,17 +644,6 @@ protected function _before(): void $this->view->setTemplateMode(TemplateMode::Site->value); } - /** - /** - * @param string $which - * @return array - * @throws ReflectionException - */ - private function _getTemplateRoots(string $which): array - { - return $this->invokeMethod($this->view, '_getTemplateRoots', [$which]); - } - /** * @param string $basePath * @param string $name From 6b1fc670e40e7b3d579d884e72cfe57ec359f462 Mon Sep 17 00:00:00 2001 From: Rias Date: Sat, 25 Jul 2026 09:09:19 +0200 Subject: [PATCH 16/21] Register migration tracks explicitly --- src/Database/Commands/MigrateCommand.php | 38 +++++++++++++++--- src/Database/Events/MigratorsResolving.php | 15 ------- .../Database/Commands/MigrateCommandTest.php | 39 +++++++++++++++++++ 3 files changed, 72 insertions(+), 20 deletions(-) delete mode 100644 src/Database/Events/MigratorsResolving.php diff --git a/src/Database/Commands/MigrateCommand.php b/src/Database/Commands/MigrateCommand.php index c85f1ac05ed..bca79f27a01 100644 --- a/src/Database/Commands/MigrateCommand.php +++ b/src/Database/Commands/MigrateCommand.php @@ -4,10 +4,10 @@ namespace CraftCms\Cms\Database\Commands; +use Closure; use CraftCms\Cms\Console\CraftCommand; use CraftCms\Cms\Console\PromptTask; use CraftCms\Cms\Database\Commands\Concerns\BackupTrait; -use CraftCms\Cms\Database\Events\MigratorsResolving; use CraftCms\Cms\Database\LaravelMigrations; use CraftCms\Cms\Database\Migrator; use CraftCms\Cms\Plugin\Plugins; @@ -26,6 +26,18 @@ use function Laravel\Prompts\confirm; +/** + * Runs Craft, plugin, content, and explicitly registered migration tracks. + * + * ```php + * public function boot(): void + * { + * MigrateCommand::registerMigrator(fn (Migrator $migrator) => $migrator + * ->track('my-plugin') + * ->setPaths([__DIR__.'/migrations'])); + * } + * ``` + */ class MigrateCommand extends Command implements Isolatable { use BackupTrait; @@ -61,8 +73,24 @@ class MigrateCommand extends Command implements Isolatable */ private array $migrators = []; - public function handle(Updates $updates, Plugins $plugins): int + /** @var list */ + private static array $migratorResolvers = []; + + /** @param Closure(): Migrator $resolver */ + public static function registerMigrator(Closure $resolver): void + { + self::$migratorResolvers[] = $resolver; + } + + public static function flushState(): void { + self::$migratorResolvers = []; + } + + public function handle( + Updates $updates, + Plugins $plugins, + ): int { if (! $this->confirmToProceed()) { return self::SUCCESS; } @@ -206,11 +234,11 @@ private function gatherMigrationsByTrack(array &$migrationsByTrack, array &$plug } } - event($event = new MigratorsResolving); + foreach (self::$migratorResolvers as $resolver) { + $migrator = app()->call($resolver); - foreach ($event->migrators as $migrator) { if (! $migrator instanceof Migrator) { - $this->components->warn($migrator::class.' is not an instance of '.Migrator::class); + $this->components->warn(get_debug_type($migrator).' is not an instance of '.Migrator::class); continue; } diff --git a/src/Database/Events/MigratorsResolving.php b/src/Database/Events/MigratorsResolving.php deleted file mode 100644 index 281b3a1e03b..00000000000 --- a/src/Database/Events/MigratorsResolving.php +++ /dev/null @@ -1,15 +0,0 @@ -delete(); @@ -44,3 +50,36 @@ expect(Schema::hasColumn(Table::MIGRATIONS, 'track'))->toBeTrue(); }); + +it('runs additional migrators', function () { + $migrator = Mockery::mock(Migrator::class); + $migrator->expects('getTrack')->andReturn('custom'); + $migrator->expects('getPendingMigrations')->andReturn(['2026_01_01_000000_custom']); + $migrator->allows('setOutput')->andReturnSelf(); + $migrator->allows('getMigrationName')->andReturn('Custom migration'); + $migrator->expects('run')->once()->andReturn([]); + + MigrateCommand::registerMigrator(fn () => $migrator); + + artisan('craft:migrate:all', [ + '--force' => true, + '--no-backup' => true, + '--no-interaction' => true, + '--track' => 'custom', + ])->assertSuccessful(); +}); + +it('skips additional migrators without a track', function () { + $migrator = Mockery::mock(Migrator::class); + $migrator->expects('getTrack')->andReturnNull(); + + MigrateCommand::registerMigrator(fn () => $migrator); + + artisan('craft:migrate:all', [ + '--force' => true, + '--no-backup' => true, + '--track' => 'custom', + ]) + ->expectsOutputToContain('A migrator was registered without a track.') + ->assertSuccessful(); +}); From 315736f86b00c5bed8bbab837440d333bbaea5f0 Mon Sep 17 00:00:00 2001 From: Rias Date: Sat, 25 Jul 2026 09:09:41 +0200 Subject: [PATCH 17/21] Discover resave commands through Artisan --- .../Commands/Resave/ResaveAllCommand.php | 9 ++-- .../Events/ElementResaveCommandsResolving.php | 26 --------- .../Element/Commands/ResaveCommandsTest.php | 17 +++--- .../console/controllers/ResaveController.php | 38 ------------- .../events/DefineConsoleActionsEvent.php | 2 +- .../src/Console/LegacyCraftCommand.php | 31 ++++++++--- .../Console/LegacyResaveCompatibilityTest.php | 53 ++++++------------- 7 files changed, 54 insertions(+), 122 deletions(-) delete mode 100644 src/Element/Events/ElementResaveCommandsResolving.php diff --git a/src/Element/Commands/Resave/ResaveAllCommand.php b/src/Element/Commands/Resave/ResaveAllCommand.php index 16fec9112c6..df015530057 100644 --- a/src/Element/Commands/Resave/ResaveAllCommand.php +++ b/src/Element/Commands/Resave/ResaveAllCommand.php @@ -4,7 +4,6 @@ namespace CraftCms\Cms\Element\Commands\Resave; -use CraftCms\Cms\Element\Events\ElementResaveCommandsResolving; use CraftCms\Cms\Support\Str; use Override; @@ -28,14 +27,12 @@ public function handle(): int 'craft:resave:users', ]; - event($event = new ElementResaveCommandsResolving); - - foreach ($event->commands as $commandName => $command) { - if (! $this->getApplication()->has($commandName)) { + foreach ($this->getApplication()->all('craft:resave') as $command) { + if ($command instanceof self) { continue; } - $commands[] = $commandName; + $commands[] = $command->getName(); } $commands = array_values(array_unique($commands)); diff --git a/src/Element/Events/ElementResaveCommandsResolving.php b/src/Element/Events/ElementResaveCommandsResolving.php deleted file mode 100644 index 277d620f8bd..00000000000 --- a/src/Element/Events/ElementResaveCommandsResolving.php +++ /dev/null @@ -1,26 +0,0 @@ -commands['craft:resave:products'] = [ - * 'description' => 'Resave products', - * ]; - * }); - * ``` - */ -class ElementResaveCommandsResolving -{ - /** - * @var array Command signatures mapped to metadata. - */ - public array $commands = []; -} diff --git a/tests/Feature/Element/Commands/ResaveCommandsTest.php b/tests/Feature/Element/Commands/ResaveCommandsTest.php index 9c54fc270b4..3d162eeb8d1 100644 --- a/tests/Feature/Element/Commands/ResaveCommandsTest.php +++ b/tests/Feature/Element/Commands/ResaveCommandsTest.php @@ -9,7 +9,6 @@ use CraftCms\Cms\Asset\Models\VolumeFolder; use CraftCms\Cms\Asset\Volumes as VolumesService; use CraftCms\Cms\Database\Table; -use CraftCms\Cms\Element\Events\ElementResaveCommandsResolving; use CraftCms\Cms\Element\Jobs\ResaveElements; use CraftCms\Cms\Entry\Elements\Entry as EntryElement; use CraftCms\Cms\Entry\Models\Entry; @@ -24,8 +23,8 @@ use CraftCms\Cms\User\Models\User; use CraftCms\Cms\User\Models\UserGroup; use CraftCms\Cms\User\Users; +use Illuminate\Support\Facades\Artisan; use Illuminate\Support\Facades\DB; -use Illuminate\Support\Facades\Event; use Illuminate\Support\Facades\Queue; it('runs the built-in resave commands from resave all', function () { @@ -271,12 +270,14 @@ ->and(DB::table(Table::ELEMENTS_SITES)->where('elementId', $entry->id)->where('siteId', $site->id)->exists())->toBeTrue(); }); -it('ignores event-discovered commands that are not actually registered', function () { - Event::listen(ElementResaveCommandsResolving::class, function (ElementResaveCommandsResolving $event) { - $event->commands['craft:resave:missing-plugin-command'] = [ - 'description' => 'Missing command', - ]; - }); +it('runs registered resave commands', function () { + $handled = false; + Artisan::setArtisan(null); + Artisan::command('craft:resave:custom', function () use (&$handled) { + $handled = true; + })->purpose('Custom resave command'); $this->artisan('craft:resave:all --no-interaction')->assertSuccessful(); + + expect($handled)->toBeTrue(); }); diff --git a/yii2-adapter/legacy/console/controllers/ResaveController.php b/yii2-adapter/legacy/console/controllers/ResaveController.php index 8a2a7471f19..d2999046592 100644 --- a/yii2-adapter/legacy/console/controllers/ResaveController.php +++ b/yii2-adapter/legacy/console/controllers/ResaveController.php @@ -9,12 +9,10 @@ use Craft; use craft\base\DefaultableFieldInterface; -use craft\base\Event as YiiEvent; use craft\base\FieldInterface; use craft\console\Controller; use craft\elements\Category; use craft\elements\Tag; -use craft\events\DefineConsoleActionsEvent; use craft\events\MultiElementActionEvent; use craft\helpers\Console; use craft\models\CategoryGroup; @@ -26,7 +24,6 @@ use CraftCms\Cms\Element\Contracts\ElementInterface; use CraftCms\Cms\Element\Element; use CraftCms\Cms\Element\ElementHelper; -use CraftCms\Cms\Element\Events\ElementResaveCommandsResolving; use CraftCms\Cms\Element\Exceptions\InvalidElementException; use CraftCms\Cms\Element\Jobs\ResaveElements; use CraftCms\Cms\Element\Queries\Contracts\ElementQueryInterface; @@ -39,7 +36,6 @@ use CraftCms\Cms\Support\Typecast; use CraftCms\Yii2Adapter\DeprecatedConcepts; use Illuminate\Support\Collection; -use Illuminate\Support\Facades\Event; use Throwable; use yii\console\ExitCode; @@ -532,40 +528,6 @@ public function saveElements(ElementQueryInterface $query): int return $this->_resaveElements($query); } - public static function registerEvents(): void - { - Event::listen(ElementResaveCommandsResolving::class, function(ElementResaveCommandsResolving $event) { - if (DeprecatedConcepts::supportsCategories()) { - $event->commands['craft:resave:categories'] = [ - 'description' => 'Re-saves categories.', - ]; - } - - if (DeprecatedConcepts::supportsTags()) { - $event->commands['craft:resave:tags'] = [ - 'description' => 'Re-saves tags.', - ]; - } - - if (!YiiEvent::hasHandlers(self::class, Controller::EVENT_DEFINE_ACTIONS)) { - return; - } - - $yiiEvent = new DefineConsoleActionsEvent(); - YiiEvent::trigger(self::class, Controller::EVENT_DEFINE_ACTIONS, $yiiEvent); - - foreach ($yiiEvent->actions as $id => $action) { - if (!isset($action['action'])) { - continue; - } - - $event->commands["craft:resave:$id"] = [ - 'description' => $action['helpSummary'] ?? '', - ]; - } - }); - } - /** * @return array */ diff --git a/yii2-adapter/legacy/events/DefineConsoleActionsEvent.php b/yii2-adapter/legacy/events/DefineConsoleActionsEvent.php index 7da18e98e5f..0fb667f45d8 100644 --- a/yii2-adapter/legacy/events/DefineConsoleActionsEvent.php +++ b/yii2-adapter/legacy/events/DefineConsoleActionsEvent.php @@ -14,7 +14,7 @@ * * @author Pixel & Tonic, Inc. * @since 3.2.0 - * @deprecated 6.0.0 use {@see \CraftCms\Cms\Element\Events\ElementResaveCommandsResolving} instead. + * @deprecated 6.0.0 register a Laravel command in the `craft:resave` namespace instead. */ class DefineConsoleActionsEvent extends Event { diff --git a/yii2-adapter/src/Console/LegacyCraftCommand.php b/yii2-adapter/src/Console/LegacyCraftCommand.php index 17653ca6483..ffab28a389e 100644 --- a/yii2-adapter/src/Console/LegacyCraftCommand.php +++ b/yii2-adapter/src/Console/LegacyCraftCommand.php @@ -1,5 +1,7 @@ input instanceof ArgvInput); - - $tokens = $this->input->getRawTokens(); + $tokens = $this->tokens($this->input); $tokens[0] = str_replace(':', '/', Str::after($tokens[0], 'craft:')); @@ -42,10 +44,27 @@ public function handle(Deprecator $deprecator): never $deprecator->log(__METHOD__, $this->deprecationMessage); } + $argv = $_SERVER['argv'] ?? null; $_SERVER['argv'] = array_merge(['craft'], $tokens); - $exitCode = $this->app->run(); + try { + return $this->app->run(); + } finally { + if ($argv === null) { + unset($_SERVER['argv']); + } else { + $_SERVER['argv'] = $argv; + } + } + } + + /** @return list */ + private function tokens(InputInterface $input): array + { + if ($input instanceof ArgvInput) { + return $input->getRawTokens(); + } - exit($exitCode); + return new StringInput($this->getName() . ' ' . (string) $input)->getRawTokens(); } } diff --git a/yii2-adapter/tests-laravel/Console/LegacyResaveCompatibilityTest.php b/yii2-adapter/tests-laravel/Console/LegacyResaveCompatibilityTest.php index 36be52ab79e..f3e93d5f761 100644 --- a/yii2-adapter/tests-laravel/Console/LegacyResaveCompatibilityTest.php +++ b/yii2-adapter/tests-laravel/Console/LegacyResaveCompatibilityTest.php @@ -2,27 +2,22 @@ declare(strict_types=1); +use craft\console\Application; use craft\console\Controller; use craft\console\controllers\ResaveController; use craft\events\DefineConsoleActionsEvent; -use CraftCms\Cms\Element\Events\ElementResaveCommandsResolving; +use CraftCms\Yii2Adapter\Console\LegacyCraftCommand; use CraftCms\Yii2Adapter\Tests\TestCase; -use Illuminate\Support\Facades\Event; +use Symfony\Component\Console\Tester\CommandTester; use yii\base\Event as YiiEvent; uses(TestCase::class); -beforeEach(function() { - Event::forget(ElementResaveCommandsResolving::class); - ResaveController::registerEvents(); -}); - afterEach(function() { - Event::forget(ElementResaveCommandsResolving::class); YiiEvent::off(ResaveController::class, Controller::EVENT_DEFINE_ACTIONS); }); -it('bridges legacy define actions handlers into define resave commands', function() { +it('keeps legacy define actions available to command discovery', function() { YiiEvent::on(ResaveController::class, Controller::EVENT_DEFINE_ACTIONS, function(DefineConsoleActionsEvent $event) { $event->actions['products'] = [ 'helpSummary' => 'Re-saves products.', @@ -30,39 +25,23 @@ ]; }); - $event = new ElementResaveCommandsResolving(); - event($event); - - expect($event->commands) - ->toHaveKey('craft:resave:products') - ->and($event->commands['craft:resave:products']['description'])->toBe('Re-saves products.'); + expect(new ResaveController('resave', app('Craft'))->actions())->toHaveKey('products'); }); -it('bridges legacy resave actions into command metadata', function() { - YiiEvent::on(ResaveController::class, Controller::EVENT_DEFINE_ACTIONS, function(DefineConsoleActionsEvent $event) { - $event->actions['products'] = [ - 'helpSummary' => 'Re-saves products.', - 'action' => static fn() => 0, - ]; - }); - - $event = new ElementResaveCommandsResolving(); - event($event); +it('runs legacy commands from nested artisan calls', function() { + $argv = $_SERVER['argv']; + $app = Mockery::mock(Application::class); + $app->shouldReceive('run')->once()->andReturnUsing(function() { + expect($_SERVER['argv'])->toBe(['craft', 'resave/products', '--limit=10']); - expect($event->commands)->toHaveKey('craft:resave:products'); -}); - -it('bridges built-in legacy category and tag actions into command metadata', function() { - $this->artisan('craft:add-categories-support --force --no-interaction')->assertSuccessful(); - $this->artisan('craft:add-tags-support --force --no-interaction')->assertSuccessful(); - CraftCms\Yii2Adapter\DeprecatedConcepts::resetSupport(); + return 0; + }); - $event = new ElementResaveCommandsResolving(); - event($event); + $command = new LegacyCraftCommand($app, 'craft:resave:products {--limit=}'); + $command->setLaravel(app()); - expect($event->commands) - ->toHaveKey('craft:resave:categories') - ->toHaveKey('craft:resave:tags'); + expect(new CommandTester($command)->execute(['--limit' => 10]))->toBe(0) + ->and($_SERVER['argv'])->toBe($argv); }); it('keeps the legacy controller resave api available', function() { From bd90724ea0db150faa425cf92feadee6d8db2cea Mon Sep 17 00:00:00 2001 From: Rias Date: Sat, 25 Jul 2026 09:10:48 +0200 Subject: [PATCH 18/21] Wire legacy registration compatibility --- .../events/RegisterComponentTypesEvent.php | 2 +- yii2-adapter/src/Event/EventCompatibility.php | 33 +- yii2-adapter/src/LegacyApp.php | 12 +- yii2-adapter/src/Yii2ServiceProvider.php | 37 +- .../Http/LegacyActionRoutingTest.php | 10 +- ...edConceptRegistrationCompatibilityTest.php | 87 +++++ .../PluginLifecycleCompatibilityTest.php | 72 ++++ .../RegistrationRegistryCompatibilityTest.php | 320 ++++++++++++++++++ 8 files changed, 539 insertions(+), 34 deletions(-) create mode 100644 yii2-adapter/tests-laravel/Legacy/DeprecatedConceptRegistrationCompatibilityTest.php create mode 100644 yii2-adapter/tests-laravel/Legacy/RegistrationRegistryCompatibilityTest.php diff --git a/yii2-adapter/legacy/events/RegisterComponentTypesEvent.php b/yii2-adapter/legacy/events/RegisterComponentTypesEvent.php index 0fc4ba84151..ebcffc7f310 100644 --- a/yii2-adapter/legacy/events/RegisterComponentTypesEvent.php +++ b/yii2-adapter/legacy/events/RegisterComponentTypesEvent.php @@ -14,7 +14,7 @@ * * @author Pixel & Tonic, Inc. * @since 3.0.0 - * @deprecated 6.0.0 use the relevant Laravel registration event, such as {@see \CraftCms\Cms\Element\Events\ElementTypesResolving}, {@see \CraftCms\Cms\Field\Events\FieldTypesResolving}, {@see \CraftCms\Cms\Auth\Events\AuthMethodsResolving}, or {@see \CraftCms\Cms\Dashboard\Events\WidgetTypesResolving}, instead. + * @deprecated 6.0.0 use the relevant type catalog, such as {@see \CraftCms\Cms\Element\ElementTypes}, {@see \CraftCms\Cms\Field\FieldTypes}, or {@see \CraftCms\Cms\Dashboard\WidgetTypes}, instead. */ class RegisterComponentTypesEvent extends Event { diff --git a/yii2-adapter/src/Event/EventCompatibility.php b/yii2-adapter/src/Event/EventCompatibility.php index a5da5eed4f9..64967e7ab7a 100644 --- a/yii2-adapter/src/Event/EventCompatibility.php +++ b/yii2-adapter/src/Event/EventCompatibility.php @@ -7,12 +7,10 @@ use Craft; use craft\base\Element; use craft\base\Field as LegacyField; -use craft\console\controllers\ResaveController; use craft\controllers\AssetsController; use craft\controllers\ElementsController; use craft\controllers\UsersController; use craft\db\Connection; -use craft\elements\Address; use craft\elements\Asset; use craft\elements\Entry; use craft\elements\NestedElementManager; @@ -39,7 +37,6 @@ use craft\services\Search as LegacySearch; use craft\services\Sites; use craft\services\Structures; -use craft\services\SystemMessages; use craft\services\UserGroups; use craft\services\UserPermissions; use craft\services\Users; @@ -51,10 +48,9 @@ use craft\web\twig\variables\Cp; use craft\web\View; use CraftCms\Cms\Edition\Events\EditionChanged; -use CraftCms\Cms\Element\Events\ElementTypesResolving; use CraftCms\Cms\Shared\Concerns\LegacyEventConstants; use CraftCms\Cms\User\Elements\User; -use CraftCms\Cms\View\Events\TemplateCacheCollectorsResolving; +use CraftCms\Cms\View\TemplateCacheCollectors; use CraftCms\DependencyAwareCache\Events\TagsInvalidated; use CraftCms\Yii2Adapter\IdentityWrapper; use CraftCms\Yii2Adapter\View\LegacyAssetBundleCollector; @@ -79,13 +75,6 @@ public function boot(): void NestedElementManager::registerEvents(); \craft\elements\User::registerEvents(); - Event::listen(function(ElementTypesResolving $event) { - $event->types[] = Address::class; - $event->types[] = Asset::class; - $event->types[] = Entry::class; - $event->types[] = \craft\elements\User::class; - }); - /** * FieldLayouts */ @@ -117,7 +106,6 @@ public function boot(): void Gc::registerEvents(); LegacyGql::registerEvents(); LegacySearch::registerEvents(); - Utilities::registerEvents(); Dashboard::registerEvents(); LegacyPlugins::registerEvents(); LegacyProjectConfig::registerEvents(); @@ -125,7 +113,6 @@ public function boot(): void Routes::registerEvents(); Sites::registerEvents(); Structures::registerEvents(); - SystemMessages::registerEvents(); UserGroups::registerEvents(); UserPermissions::registerEvents(); Users::registerEvents(); @@ -138,7 +125,6 @@ public function boot(): void * Controllers */ AssetsController::registerEvents(); - ResaveController::registerEvents(); UsersController::registerEvents(); ElementsController::registerEvents(); @@ -188,10 +174,21 @@ public function boot(): void YiiTagDependency::invalidate(Craft::$app->getCache(), $event->tags); }); - Event::listen(function(TemplateCacheCollectorsResolving $event) { - $event->types->add(LegacyAssetBundleCollector::class); - }); + app(TemplateCacheCollectors::class)->register(LegacyAssetBundleCollector::class); LegacyGqlEvents::register(); } + + /** @internal */ + public function finalizeRegistrationEvents(): void + { + LegacyField::finalizeRegistrationEvents(); + Elements::finalizeRegistrationEvents(); + Fields::finalizeRegistrationEvents(); + Fs::finalizeRegistrationEvents(); + Dashboard::finalizeRegistrationEvents(); + UserPermissions::finalizeRegistrationEvents(); + View::finalizeRegistrationEvents(); + ImageTransforms::finalizeRegistrationEvents(); + } } diff --git a/yii2-adapter/src/LegacyApp.php b/yii2-adapter/src/LegacyApp.php index fcc3600e47e..84b29b93528 100644 --- a/yii2-adapter/src/LegacyApp.php +++ b/yii2-adapter/src/LegacyApp.php @@ -62,7 +62,8 @@ public function register(Application $app): void * Every legacy class that fires Yii events should listen to * the relevant Laravel event and trigger the Yii event. */ - new EventCompatibility()->boot(); + $eventCompatibility = new EventCompatibility(); + $eventCompatibility->boot(); foreach ($laravelApp->make(Plugins::class)->getAllPlugins() as $plugin) { if ($plugin instanceof Module) { @@ -73,10 +74,19 @@ public function register(Application $app): void /** * Globals, Categories, Tags */ + DeprecatedConcepts::resetSupport(); new DeprecatedConcepts()->boot(); DeprecatedConcepts::bootYiiEvents(); + $laravelApp->booted(function() use ($eventCompatibility) { + if (!Cms::isInstalled(strict: true)) { + return; + } + + $eventCompatibility->finalizeRegistrationEvents(); + }); + return $craftApp; }); } diff --git a/yii2-adapter/src/Yii2ServiceProvider.php b/yii2-adapter/src/Yii2ServiceProvider.php index b0570ccc298..03e2d67fbc6 100644 --- a/yii2-adapter/src/Yii2ServiceProvider.php +++ b/yii2-adapter/src/Yii2ServiceProvider.php @@ -1,5 +1,7 @@ register(); @@ -74,7 +95,14 @@ public function register(): void new LegacyApp()->register($this->app); new CompatibilityMixins()->register(); new FilesystemCompatibility()->register($this->app); - + $this->app->singleton(Settings::class, LegacySettings::class); + $this->app->singleton(GqlArguments::class, LegacyGqlArguments::class); + $this->app->singleton(GqlDirectives::class, LegacyGqlDirectives::class); + $this->app->singleton(GqlTypes::class, LegacyGqlTypes::class); + $this->app->scoped(Gql::class, LegacyGql::class); + $this->app->scoped(SystemMessages::class, LegacySystemMessages::class); + $this->app->scoped(UserPermissions::class, LegacyUserPermissions::class); + $this->app->singleton(UtilityTypes::class, LegacyUtilityTypes::class); /** * Load the legacy fallback route from booted() so it registers after * the CMS package's own Route::fallback(), ensuring that unmatched @@ -114,10 +142,7 @@ protected function registerConstants(): void private function registerLegacySiteTemplateRoot(): void { - Event::listen(SiteTemplateRootsResolving::class, function(SiteTemplateRootsResolving $event): void { - $event->roots[''] ??= []; - $event->roots[''] = array_merge((array)$event->roots[''], [base_path('templates')]); - }); + $this->app->make(TemplateRoots::class)->register(TemplateMode::Site, '', base_path('templates')); } /** diff --git a/yii2-adapter/tests-laravel/Http/LegacyActionRoutingTest.php b/yii2-adapter/tests-laravel/Http/LegacyActionRoutingTest.php index 4d3af83bf7c..383c021a671 100644 --- a/yii2-adapter/tests-laravel/Http/LegacyActionRoutingTest.php +++ b/yii2-adapter/tests-laravel/Http/LegacyActionRoutingTest.php @@ -2,13 +2,11 @@ use CraftCms\Cms\Cms; use CraftCms\Cms\Http\Middleware\HandleActionRequest; -use CraftCms\Cms\View\Events\CpTemplateRootsResolving; use CraftCms\Cms\View\TemplateMode; +use CraftCms\Cms\View\TemplateRoots; use CraftCms\Yii2Adapter\Http\CaptureOriginalActionRequestUri; use Illuminate\Http\Request as IlluminateRequest; -use Illuminate\Support\Facades\Event; use Illuminate\Support\Facades\File; -use Illuminate\Support\Once; use yii\base\Module; use yii\web\Controller; @@ -79,11 +77,7 @@ File::ensureDirectoryExists($templateRoot); File::put($templateRoot . '/_index.twig', 'Example template route'); - Once::flush(); - - Event::listen(CpTemplateRootsResolving::class, function(CpTemplateRootsResolving $event) use ($templateRoot) { - $event->roots['example-plugin'] = $templateRoot; - }); + app(TemplateRoots::class)->register(TemplateMode::Cp, 'example-plugin', $templateRoot); TemplateMode::set(TemplateMode::Cp); app()->instance('request', IlluminateRequest::create('/' . Cms::config()->cpTrigger . '/example-plugin')); diff --git a/yii2-adapter/tests-laravel/Legacy/DeprecatedConceptRegistrationCompatibilityTest.php b/yii2-adapter/tests-laravel/Legacy/DeprecatedConceptRegistrationCompatibilityTest.php new file mode 100644 index 00000000000..2e94a324952 --- /dev/null +++ b/yii2-adapter/tests-laravel/Legacy/DeprecatedConceptRegistrationCompatibilityTest.php @@ -0,0 +1,87 @@ +setValue(null, true); + new ReflectionProperty(DeprecatedConcepts::class, 'supportsGlobalSets')->setValue(null, true); + new ReflectionProperty(DeprecatedConcepts::class, 'supportsTags')->setValue(null, true); + YiiEvent::off(LegacyElements::class, LegacyElements::EVENT_REGISTER_ELEMENT_TYPES); + YiiEvent::off(LegacyArgumentManager::class, LegacyArgumentManager::EVENT_DEFINE_GQL_ARGUMENT_HANDLERS); + YiiEvent::off(LegacyUserPermissions::class, LegacyUserPermissions::EVENT_REGISTER_PERMISSIONS); + DeprecatedConcepts::bootYiiEvents(); + + $categories = Craft::$app->getCategories(); + $globals = Craft::$app->getGlobals(); + + Craft::$app->set('categories', Mockery::mock(LegacyCategories::class, function($mock) { + $mock->shouldReceive('getAllGroups')->andReturn([(object) [ + 'name' => 'News', + 'uid' => 'news', + ]]); + })); + Craft::$app->set('globals', Mockery::mock(LegacyGlobals::class, function($mock) { + $mock->shouldReceive('getAllSets')->andReturn([(object) [ + 'name' => 'Company', + 'uid' => 'company', + ]]); + })); + + app()->instance(Plugins::class, Mockery::mock(Plugins::class, function($mock) { + $mock->shouldReceive('getAllPlugins')->andReturn([]); + })); + app()->instance(Utilities::class, Mockery::mock(Utilities::class, function($mock) { + $mock->shouldReceive('getAllUtilityTypes')->andReturn(Collection::make()); + })); + UserGroups::shouldReceive('getAllGroups')->andReturn(Collection::make()); + Sites::shouldReceive('isMultiSite')->andReturnFalse(); + Sections::shouldReceive('getAllSections')->andReturn(Collection::make()); + Volumes::shouldReceive('getAllVolumes')->andReturn(Collection::make()); + + try { + LegacyElements::finalizeRegistrationEvents(); + LegacyUserPermissions::finalizeRegistrationEvents(); + + $permissionKeys = app(UserPermissions::class)->getAllPermissions() + ->flatMap->permissions + ->pluck('key'); + + expect(app(ElementTypes::class)->types()) + ->toContain(Category::class, GlobalSet::class, Tag::class) + ->and(app(GqlArguments::class)->handlers()) + ->toHaveKeys(['relatedToCategories', 'relatedToTags']) + ->and($permissionKeys) + ->toContain('viewCategories:news', 'editGlobalSet:company'); + } finally { + Craft::$app->set('categories', $categories); + Craft::$app->set('globals', $globals); + } +}); diff --git a/yii2-adapter/tests-laravel/Legacy/PluginLifecycleCompatibilityTest.php b/yii2-adapter/tests-laravel/Legacy/PluginLifecycleCompatibilityTest.php index 247ff5ead7a..84239c4e7b8 100644 --- a/yii2-adapter/tests-laravel/Legacy/PluginLifecycleCompatibilityTest.php +++ b/yii2-adapter/tests-laravel/Legacy/PluginLifecycleCompatibilityTest.php @@ -2,15 +2,26 @@ declare(strict_types=1); +use craft\base\Event as YiiEvent; use craft\base\Plugin; +use craft\events\RegisterComponentTypesEvent; +use craft\services\Fields as LegacyFields; +use CraftCms\Cms\Field\Field; +use CraftCms\Cms\Field\FieldTypes; use CraftCms\Cms\Plugin\Contracts\PluginInterface; +use CraftCms\Cms\Plugin\Plugin as ModernPlugin; use CraftCms\Cms\Plugin\Plugins; +use CraftCms\Yii2Adapter\Event\EventCompatibility; use CraftCms\Yii2Adapter\Yii2ServiceProvider; beforeEach(function() { $this->app->register(Yii2ServiceProvider::class); }); +afterEach(function() { + YiiEvent::off(LegacyFields::class, LegacyFields::EVENT_REGISTER_FIELD_TYPES); +}); + it('creates and runs adapter plugins through the shared plugin interface', function() { $plugins = app(Plugins::class); @@ -38,6 +49,67 @@ })->not()->toThrow(Throwable::class); }); +it('reconciles legacy registrations after legacy and modern plugins register types', function() { + $plugins = app(Plugins::class); + AdapterRegistrationTestPlugin::$modernTypeWasVisible = false; + + new ReflectionProperty($plugins, 'composerPluginInfo')->setValue($plugins, [ + 'legacy-registration' => [ + 'class' => AdapterRegistrationTestPlugin::class, + 'handle' => 'legacy-registration', + 'name' => 'Legacy registration', + 'packageName' => 'craftcms/legacy-registration', + 'version' => '1.0.0', + 'basePath' => __DIR__, + ], + ]); + + $compatibility = new EventCompatibility(); + $plugins->createPlugin('legacy-registration'); + + $modernPlugin = AdapterModernRegistrationTestPlugin::create([ + 'handle' => 'modern-registration', + 'name' => 'Modern registration', + 'packageName' => 'craftcms/modern-registration', + 'version' => '1.0.0', + ]); + $modernPlugin->bootPlugin($plugins); + + $compatibility->finalizeRegistrationEvents(); + + expect(app(FieldTypes::class)->types()) + ->toContain(AdapterLegacyRegistrationField::class, AdapterModernRegistrationField::class) + ->and(AdapterRegistrationTestPlugin::$modernTypeWasVisible)->toBeTrue(); +}); + class AdapterLifecycleTestPlugin extends Plugin { } + +class AdapterRegistrationTestPlugin extends Plugin +{ + public static bool $modernTypeWasVisible = false; + + public function init(): void + { + parent::init(); + + YiiEvent::on(LegacyFields::class, LegacyFields::EVENT_REGISTER_FIELD_TYPES, function(RegisterComponentTypesEvent $event) { + self::$modernTypeWasVisible = in_array(AdapterModernRegistrationField::class, $event->types, true); + $event->types[] = AdapterLegacyRegistrationField::class; + }); + } +} + +class AdapterModernRegistrationTestPlugin extends ModernPlugin +{ + protected array $fieldTypes = [AdapterModernRegistrationField::class]; +} + +abstract class AdapterLegacyRegistrationField extends Field +{ +} + +abstract class AdapterModernRegistrationField extends Field +{ +} diff --git a/yii2-adapter/tests-laravel/Legacy/RegistrationRegistryCompatibilityTest.php b/yii2-adapter/tests-laravel/Legacy/RegistrationRegistryCompatibilityTest.php new file mode 100644 index 00000000000..6fb13fe7d2e --- /dev/null +++ b/yii2-adapter/tests-laravel/Legacy/RegistrationRegistryCompatibilityTest.php @@ -0,0 +1,320 @@ +remove('Modules', 'modern'); + ClearCaches::flushState(); +}); + +it('exposes native fields after repeated legacy event registration', function() { + $registry = new NativeFields(app()); + app()->instance(NativeFields::class, $registry); + + YiiEvent::on( + craft\models\FieldLayout::class, + craft\models\FieldLayout::EVENT_DEFINE_NATIVE_FIELDS, + function(DefineFieldLayoutFieldsEvent $event) { + $event->fields = [EntryTitleField::class]; + }, + ); + + craft\models\FieldLayout::registerEvents(); + craft\models\FieldLayout::registerEvents(); + + expect($registry->apply(new FieldLayout()))->toBe([EntryTitleField::class]); +}); + +it('exposes keyed cache options to legacy reducers', function() { + ClearCaches::add('modern', [ + 'label' => 'Modern', + 'action' => static fn() => null, + ]); + LegacyClearCaches::registerEvents(); + $modernOptionWasVisible = false; + + YiiEvent::on( + LegacyClearCaches::class, + LegacyClearCaches::EVENT_REGISTER_CACHE_OPTIONS, + function(RegisterCacheOptionsEvent $event) use (&$modernOptionWasVisible) { + $modernOptionWasVisible = collect($event->options)->contains('key', 'modern'); + $event->options = [[ + 'key' => 'legacy', + 'label' => 'Legacy', + 'action' => static fn() => null, + ]]; + }, + ); + + $options = ClearCaches::cacheOptions(); + + expect($options)->toHaveCount(1) + ->and($options[0]['key'])->toBe('legacy') + ->and($modernOptionWasVisible)->toBeTrue(); +}); + +it('exposes cache tags to legacy reducers', function() { + ClearCaches::addTag('modern', 'Modern'); + LegacyClearCaches::registerEvents(); + + YiiEvent::on( + LegacyClearCaches::class, + LegacyClearCaches::EVENT_REGISTER_TAG_OPTIONS, + function(RegisterCacheOptionsEvent $event) { + expect($event->options)->toContain(['tag' => 'modern', 'label' => 'Modern']); + $event->options = [['tag' => 'legacy', 'label' => 'Legacy']]; + }, + ); + + expect(ClearCaches::tagOptions())->toBe([['tag' => 'legacy', 'label' => 'Legacy']]); +}); + +it('exposes keyed settings to legacy reducers', function() { + app(Settings::class)->registerSetting('Modules', 'modern', fn() => ['label' => 'Modern']); + $modernSettingWasVisible = false; + + YiiEvent::on( + LegacyCp::class, + LegacyCp::EVENT_REGISTER_CP_SETTINGS, + function(RegisterCpSettingsEvent $event) use (&$modernSettingWasVisible) { + $modernSettingWasVisible = $event->settings['Modules']['modern'] === ['label' => 'Modern']; + $event->settings = ['Legacy' => ['replacement' => ['label' => 'Replacement']]]; + }, + ); + + expect(app(Settings::class)->all())->toBe([ + 'Legacy' => ['replacement' => ['label' => 'Replacement']], + ])->and($modernSettingWasVisible)->toBeTrue(); +}); + +it('uses the legacy read-only settings event', function() { + $config = app(GeneralConfig::class); + $allowAdminChanges = $config->allowAdminChanges; + $config->allowAdminChanges = false; + app(Settings::class)->registerReadOnlySetting('Modules', 'readonly', fn() => ['label' => 'Readonly']); + + YiiEvent::on( + LegacyCp::class, + LegacyCp::EVENT_REGISTER_READ_ONLY_CP_SETTINGS, + function(RegisterCpSettingsEvent $event) { + $event->settings = ['Legacy' => ['readonly' => ['label' => 'Readonly replacement']]]; + }, + ); + + try { + expect(app(Settings::class)->all())->toBe([ + 'Legacy' => ['readonly' => ['label' => 'Readonly replacement']], + ]); + } finally { + $config->allowAdminChanges = $allowAdminChanges; + app(Settings::class)->remove('Modules', 'readonly'); + } +}); + +it('does not resolve system messages while finalizing startup registrations', function() { + YiiEvent::off(LegacyUserPermissions::class, LegacyUserPermissions::EVENT_REGISTER_PERMISSIONS); + $factoryCalls = 0; + app(SystemMessages::class)->register('modern', function() use (&$factoryCalls) { + $factoryCalls++; + + return new SystemMessage([ + 'key' => 'modern', + 'heading' => 'Modern message', + 'subject' => 'Modern message', + 'body' => 'Modern message', + ]); + }); + + YiiEvent::on( + LegacySystemMessages::class, + LegacySystemMessages::EVENT_REGISTER_MESSAGES, + fn() => null, + ); + + new EventCompatibility()->finalizeRegistrationEvents(); + + expect($factoryCalls)->toBe(0); +}); + +it('does not resolve permissions without legacy event handlers', function() { + YiiEvent::off(LegacyUserPermissions::class, LegacyUserPermissions::EVENT_REGISTER_PERMISSIONS); + app()->instance(UserPermissions::class, Mockery::mock(UserPermissions::class, function($mock) { + $mock->shouldNotReceive('getAllPermissions'); + })); + + LegacyUserPermissions::finalizeRegistrationEvents(); + + expect(app(UserPermissions::class))->toBeInstanceOf(UserPermissions::class); +}); + +it('exposes registered system messages to the legacy replacement event', function() { + I18N::shouldReceive('getSiteLocaleIds')->andReturn(collect([app()->getLocale()])); + I18N::shouldReceive('translate')->andReturnUsing(fn(string $message) => $message); + + $registry = app(SystemMessages::class); + $registry->register('modern', fn() => new SystemMessage([ + 'key' => 'modern', + 'heading' => 'Modern message', + 'subject' => 'Modern message', + 'body' => 'Modern message', + ])); + + $modernMessageWasVisible = false; + + YiiEvent::on( + LegacySystemMessages::class, + LegacySystemMessages::EVENT_REGISTER_MESSAGES, + function(RegisterEmailMessagesEvent $event) use (&$modernMessageWasVisible) { + $modernMessageWasVisible = collect($event->messages)->contains( + fn(array $message) => $message['key'] === 'modern', + ); + $event->messages = [[ + 'key' => 'legacy', + 'heading' => 'Legacy message', + 'subject' => 'Legacy message', + 'body' => 'Legacy message', + ]]; + }, + ); + + $messages = $registry->messages(); + + expect($messages->keys()->all())->toBe(['legacy']) + ->and($messages['legacy']->subject)->toBe('Legacy message') + ->and($modernMessageWasVisible)->toBeTrue(); +}); + +it('preserves the missing primary site failure for direct system message reads', function() { + I18N::shouldReceive('getSiteLocaleIds')->andReturn(Collection::make()); + Sites::shouldReceive('getPrimarySite')->andThrow(new SiteNotFoundException('No primary site exists')); + + expect(fn() => app(SystemMessages::class)->messages()) + ->toThrow(SiteNotFoundException::class, 'No primary site exists'); +}); + +it('exposes registered permission groups to the legacy resolving event', function() { + YiiEvent::off(LegacyUserPermissions::class, LegacyUserPermissions::EVENT_REGISTER_PERMISSIONS); + + app()->instance(Plugins::class, Mockery::mock(Plugins::class, function($mock) { + $mock->shouldReceive('getAllPlugins')->andReturn([]); + })); + app()->instance(Utilities::class, Mockery::mock(Utilities::class, function($mock) { + $mock->shouldReceive('getAllUtilityTypes')->andReturn(Collection::make()); + })); + UserGroups::shouldReceive('getAllGroups')->andReturn(Collection::make()); + Sites::shouldReceive('isMultiSite')->andReturnFalse(); + Sections::shouldReceive('getAllSections')->andReturn(Collection::make()); + Volumes::shouldReceive('getAllVolumes')->andReturn(Collection::make()); + + $service = app(UserPermissions::class); + $service->registerPermissionGroup( + 'plugin:modern', + fn() => new PermissionGroup( + handle: 'plugin:modern', + heading: 'Modern plugin', + permissions: collect([new Permission('manageModernPlugin', 'Manage modern plugin')]), + ), + ); + + $modernGroupWasVisible = false; + $eventCalls = 0; + + YiiEvent::on( + LegacyUserPermissions::class, + LegacyUserPermissions::EVENT_REGISTER_PERMISSIONS, + function(RegisterUserPermissionsEvent $event) use (&$eventCalls, &$modernGroupWasVisible) { + $eventCalls++; + $modernGroupWasVisible = collect($event->permissions)->contains( + fn(array $group) => $group['heading'] === 'Modern plugin', + ); + $event->permissions = [[ + 'heading' => 'Legacy replacement', + 'permissions' => [], + ]]; + }, + ); + + LegacyUserPermissions::finalizeRegistrationEvents(); + + expect($service->getAllPermissions()->pluck('heading')->all())->toBe(['Legacy replacement']) + ->and($modernGroupWasVisible)->toBeTrue() + ->and($eventCalls)->toBe(1); + + YiiEvent::off(LegacyUserPermissions::class, LegacyUserPermissions::EVENT_REGISTER_PERMISSIONS); + $service->reset(); + + expect($service->getAllPermissions()->pluck('heading')) + ->toContain('Modern plugin') + ->not()->toContain('Legacy replacement'); +}); + +it('adds legacy template roots without replacing modern roots', function( + TemplateMode $mode, + string $legacyEvent, +) { + YiiEvent::off(LegacyView::class, $legacyEvent); + + app(TemplateRoots::class)->register($mode, 'modern', '/modern'); + + YiiEvent::on(LegacyView::class, $legacyEvent, function(RegisterTemplateRootsEvent $event) { + $event->roots = ['legacy' => '/legacy']; + }); + + LegacyView::finalizeRegistrationEvents(); + + $resolved = $mode->templateRoots(); + + expect($resolved)->toHaveKeys(['legacy', 'modern']); + + YiiEvent::off(LegacyView::class, $legacyEvent); + + expect($mode->templateRoots())->toHaveKeys(['legacy', 'modern']); +})->with([ + 'control panel' => [TemplateMode::Cp, LegacyView::EVENT_REGISTER_CP_TEMPLATE_ROOTS], + 'site' => [TemplateMode::Site, LegacyView::EVENT_REGISTER_SITE_TEMPLATE_ROOTS], +]); From e8e89f12b087d207b86de2b8a7c29c113371907c Mon Sep 17 00:00:00 2001 From: Rias Date: Sat, 25 Jul 2026 09:11:00 +0200 Subject: [PATCH 19/21] Update registry migration references --- CHANGELOG-WIP.md | 13 ++++++------- CHANGELOG.md | 13 ++++++------- 2 files changed, 12 insertions(+), 14 deletions(-) diff --git a/CHANGELOG-WIP.md b/CHANGELOG-WIP.md index e3f8a32015e..e90113e43f8 100644 --- a/CHANGELOG-WIP.md +++ b/CHANGELOG-WIP.md @@ -182,7 +182,7 @@ Craft's Mutex classes have been deprecated. [Laravel's atomic locking](https://l ##### Events -- Deprecated `craft\services\Dashboard::EVENT_REGISTER_WIDGET_TYPES`. `CraftCms\Cms\Dashboard\Events\WidgetTypesResolving` should be used instead. +- Deprecated `craft\services\Dashboard::EVENT_REGISTER_WIDGET_TYPES`. `CraftCms\Cms\Dashboard\WidgetTypes::register()` should be used instead. - Deprecated `craft\events\WidgetEvent` in favor of the following new events: - `craft\services\Dashboard::EVENT_BEFORE_SAVE_WIDGET` => `CraftCms\Cms\Dashboard\Events\WidgetSaving` - `craft\services\Dashboard::EVENT_AFTER_SAVE_WIDGET` => `CraftCms\Cms\Dashboard\Events\WidgetSaved` @@ -601,7 +601,7 @@ Craft 6 introduces a new validation system that uses Laravel's Validator instead - Deprecated `craft\events\CreateFieldLayoutFormEvent`. `CraftCms\Cms\FieldLayout\Events\FieldLayoutFormCreating` should be used instead. - Deprecated `craft\events\DefineFieldLayoutCustomFieldsEvent`. `CraftCms\Cms\FieldLayout\Events\FieldLayoutCustomFieldsResolving` should be used instead. - Deprecated `craft\events\DefineFieldLayoutElementsEvent`. `CraftCms\Cms\FieldLayout\Events\FieldLayoutUIElementsResolving` should be used instead. -- Deprecated `craft\events\DefineFieldLayoutFieldsEvent`. `CraftCms\Cms\FieldLayout\Events\NativeFieldsResolving` should be used instead. +- Deprecated `craft\events\DefineFieldLayoutFieldsEvent`. `CraftCms\Cms\FieldLayout\NativeFields` should be used instead. - Deprecated `craft\events\DefineShowFieldLayoutComponentInFormEvent`. `CraftCms\Cms\FieldLayout\Events\FieldLayoutComponentShowInFormResolving` should be used instead. - Deprecated `craft\events\DefineFieldActionsEvent`. `CraftCms\Cms\FieldLayout\Events\FieldLayoutActionMenuItemsResolving` should be used instead. @@ -1048,14 +1048,13 @@ Moved the following controllers: - Added `CraftCms\Cms\View\DeltaRegistry`. - Added `CraftCms\Cms\Support\Facades\DeltaRegistry`. - Added `CraftCms\Cms\View\TemplateMode` enum. -- Added `CraftCms\Cms\View\Events\CpTemplateRootsResolving`. -- Added `CraftCms\Cms\View\Events\SiteTemplateRootsResolving`. +- Added `CraftCms\Cms\View\TemplateRoots`. - Added `CraftCms\Cms\View\TemplateCaches`. - Added `CraftCms\Cms\View\CacheCollectors\DependencyCollector`. - Added `CraftCms\Cms\View\CacheCollectors\ResourceCollector`. - Added `CraftCms\Cms\View\Contracts\CacheCollectorInterface`. - Added `CraftCms\Cms\View\Data\TemplateCacheContext`. -- Added `CraftCms\Cms\View\Events\TemplateCacheCollectorsResolving`. +- Added `CraftCms\Cms\View\TemplateCacheCollectors`. - Deprecated `craft\services\TemplateCaches`. `CraftCms\Cms\View\TemplateCaches` should be used instead. - Deprecated `craft\web\View::registerJs()`. `CraftCms\Cms\View\HtmlStack::js()` should be used instead. - Deprecated `craft\web\View::registerJsWithVars()`. `CraftCms\Cms\View\HtmlStack::jsWithVars()` should be used instead. @@ -1098,8 +1097,8 @@ Moved the following controllers: - Deprecated `craft\web\View::getTemplatesPath()`. `CraftCms\Cms\View\TemplateMode::templatesPath()` should be used instead. - Deprecated `craft\web\View::getCpTemplateRoots()`. `CraftCms\Cms\View\TemplateMode::templateRoots()` should be used instead. - Deprecated `craft\web\View::getSiteTemplateRoots()`. `CraftCms\Cms\View\TemplateMode::templateRoots()` should be used instead. -- Deprecated `craft\web\View::EVENT_REGISTER_CP_TEMPLATE_ROOTS`. `CraftCms\Cms\View\Events\CpTemplateRootsResolving` should be used instead. -- Deprecated `craft\web\View::EVENT_REGISTER_SITE_TEMPLATE_ROOTS`. `CraftCms\Cms\View\Events\SiteTemplateRootsResolving` should be used instead. +- Deprecated `craft\web\View::EVENT_REGISTER_CP_TEMPLATE_ROOTS`. `CraftCms\Cms\View\TemplateRoots::register()` should be used instead. +- Deprecated `craft\web\View::EVENT_REGISTER_SITE_TEMPLATE_ROOTS`. `CraftCms\Cms\View\TemplateRoots::register()` should be used instead. - Deprecated `craft\web\View::registerDeltaName()`. `CraftCms\Cms\View\DeltaRegistry::registerName()` should be used instead. - Deprecated `craft\web\View::getDeltaNames()`. `CraftCms\Cms\View\DeltaRegistry::getNames()` should be used instead. - Deprecated `craft\web\View::getModifiedDeltaNames()`. `CraftCms\Cms\View\DeltaRegistry::getModifiedNames()` should be used instead. diff --git a/CHANGELOG.md b/CHANGELOG.md index c0375789cd1..b2b57f0bdcf 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -514,7 +514,7 @@ Craft’s Mutex classes have been deprecated. [Laravel’s atomic locking](https ##### Events -- Deprecated `craft\services\Dashboard::EVENT_REGISTER_WIDGET_TYPES`. `CraftCms\Cms\Dashboard\Events\WidgetTypesResolving` should be used instead. +- Deprecated `craft\services\Dashboard::EVENT_REGISTER_WIDGET_TYPES`. `CraftCms\Cms\Dashboard\WidgetTypes::register()` should be used instead. - Deprecated `craft\events\WidgetEvent` in favor of the following new events: - `craft\services\Dashboard::EVENT_BEFORE_SAVE_WIDGET` => `CraftCms\Cms\Dashboard\Events\WidgetSaving` - `craft\services\Dashboard::EVENT_AFTER_SAVE_WIDGET` => `CraftCms\Cms\Dashboard\Events\WidgetSaved` @@ -933,7 +933,7 @@ Craft 6 introduces a new validation system that uses Laravel’s Validator inste - Deprecated `craft\events\CreateFieldLayoutFormEvent`. `CraftCms\Cms\FieldLayout\Events\FieldLayoutFormCreating` should be used instead. - Deprecated `craft\events\DefineFieldLayoutCustomFieldsEvent`. `CraftCms\Cms\FieldLayout\Events\FieldLayoutCustomFieldsResolving` should be used instead. - Deprecated `craft\events\DefineFieldLayoutElementsEvent`. `CraftCms\Cms\FieldLayout\Events\FieldLayoutUIElementsResolving` should be used instead. -- Deprecated `craft\events\DefineFieldLayoutFieldsEvent`. `CraftCms\Cms\FieldLayout\Events\NativeFieldsResolving` should be used instead. +- Deprecated `craft\events\DefineFieldLayoutFieldsEvent`. `CraftCms\Cms\FieldLayout\NativeFields` should be used instead. - Deprecated `craft\events\DefineShowFieldLayoutComponentInFormEvent`. `CraftCms\Cms\FieldLayout\Events\FieldLayoutComponentShowInFormResolving` should be used instead. - Deprecated `craft\events\DefineFieldActionsEvent`. `CraftCms\Cms\FieldLayout\Events\FieldLayoutActionMenuItemsResolving` should be used instead. @@ -1378,14 +1378,13 @@ Moved the following controllers: - Added `CraftCms\Cms\View\DeltaRegistry`. - Added `CraftCms\Cms\Support\Facades\DeltaRegistry`. - Added `CraftCms\Cms\View\TemplateMode` enum. -- Added `CraftCms\Cms\View\Events\CpTemplateRootsResolving`. -- Added `CraftCms\Cms\View\Events\SiteTemplateRootsResolving`. +- Added `CraftCms\Cms\View\TemplateRoots`. - Added `CraftCms\Cms\View\TemplateCaches`. - Added `CraftCms\Cms\View\CacheCollectors\DependencyCollector`. - Added `CraftCms\Cms\View\CacheCollectors\ResourceCollector`. - Added `CraftCms\Cms\View\Contracts\CacheCollectorInterface`. - Added `CraftCms\Cms\View\Data\TemplateCacheContext`. -- Added `CraftCms\Cms\View\Events\TemplateCacheCollectorsResolving`. +- Added `CraftCms\Cms\View\TemplateCacheCollectors`. - Deprecated `craft\services\TemplateCaches`. `CraftCms\Cms\View\TemplateCaches` should be used instead. - Deprecated `craft\web\View::registerJs()`. `CraftCms\Cms\View\HtmlStack::js()` should be used instead. - Deprecated `craft\web\View::registerJsWithVars()`. `CraftCms\Cms\View\HtmlStack::jsWithVars()` should be used instead. @@ -1428,8 +1427,8 @@ Moved the following controllers: - Deprecated `craft\web\View::getTemplatesPath()`. `CraftCms\Cms\View\TemplateMode::templatesPath()` should be used instead. - Deprecated `craft\web\View::getCpTemplateRoots()`. `CraftCms\Cms\View\TemplateMode::templateRoots()` should be used instead. - Deprecated `craft\web\View::getSiteTemplateRoots()`. `CraftCms\Cms\View\TemplateMode::templateRoots()` should be used instead. -- Deprecated `craft\web\View::EVENT_REGISTER_CP_TEMPLATE_ROOTS`. `CraftCms\Cms\View\Events\CpTemplateRootsResolving` should be used instead. -- Deprecated `craft\web\View::EVENT_REGISTER_SITE_TEMPLATE_ROOTS`. `CraftCms\Cms\View\Events\SiteTemplateRootsResolving` should be used instead. +- Deprecated `craft\web\View::EVENT_REGISTER_CP_TEMPLATE_ROOTS`. `CraftCms\Cms\View\TemplateRoots::register()` should be used instead. +- Deprecated `craft\web\View::EVENT_REGISTER_SITE_TEMPLATE_ROOTS`. `CraftCms\Cms\View\TemplateRoots::register()` should be used instead. - Deprecated `craft\web\View::registerDeltaName()`. `CraftCms\Cms\View\DeltaRegistry::registerName()` should be used instead. - Deprecated `craft\web\View::getDeltaNames()`. `CraftCms\Cms\View\DeltaRegistry::getNames()` should be used instead. - Deprecated `craft\web\View::getModifiedDeltaNames()`. `CraftCms\Cms\View\DeltaRegistry::getModifiedNames()` should be used instead. From b431c9c7c60108b681d88e840cac240b98528698 Mon Sep 17 00:00:00 2001 From: Rias Date: Sat, 25 Jul 2026 09:44:25 +0200 Subject: [PATCH 20/21] Clarify test suite commands --- AGENTS.md | 8 +++++--- 1 file changed, 5 insertions(+), 3 deletions(-) diff --git a/AGENTS.md b/AGENTS.md index 9adbee3dc06..e8039ee86e1 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -15,10 +15,11 @@ This is a large codebase with some large files. Search narrowly before reading f ### PHP ```bash -composer tests # Run all Pest tests +composer tests # Run main tests only (tests/) composer tests-adapter # Run yii2-adapter tests only -./vendor/bin/pest path/to/TestFile.php # Run a single test file -./vendor/bin/pest --filter "test description" # Run tests matching a name +./vendor/bin/pest tests/path/to/TestFile.php # Run a main test file +composer tests-adapter -- yii2-adapter/tests-laravel/path/to/TestFile.php +composer tests-adapter -- --filter "test description" composer fix-cs # Run Rector + Pint + ECS (auto-fixes code style) composer phpstan # Run PHPStan static analysis (level 5) composer ci # Full CI pipeline: pint, rector, phpstan, tests, tests-adapter @@ -44,6 +45,7 @@ npm run test:ui # Vitest tests for the @craftcms/ui package ## Testing +- Main and adapter tests are separate Pest suites. Never mix `tests/...` and `yii2-adapter/tests-laravel/...` in one Pest invocation. Run adapter tests through `composer tests-adapter`; passing the adapter PHPUnit configuration alone is insufficient because Pest also needs the adapter test directory to load `yii2-adapter/tests-laravel/Pest.php`. - Pest tests using `tests/TestCase.php` or `yii2-adapter/tests-laravel/TestCase.php` share a database lock. If another process has the lock, the next process will wait and print `Another Pest process is already using the shared test database. Waiting for the lock...`. - `tests/Unit/` tests using `UnitTestCase` do not take that lock and can still run concurrently. - When writing tests, prefer real code paths, or use Laravel facades to set up service mocks. From 8739a8d2e8c8f5f984b7116a7125364479fe8b81 Mon Sep 17 00:00:00 2001 From: Rias Date: Sat, 25 Jul 2026 10:18:16 +0200 Subject: [PATCH 21/21] Fix registry compatibility checks --- src/FieldLayout/NativeFields.php | 2 +- .../events/RegisterAssetFileKindsEvent.php | 2 +- yii2-adapter/legacy/helpers/Assets.php | 10 -- yii2-adapter/legacy/services/Auth.php | 18 ++-- yii2-adapter/legacy/services/Fields.php | 11 +-- .../src/Asset/LegacyAssetFileKinds.php | 30 ++++++ yii2-adapter/src/Event/EventCompatibility.php | 1 + yii2-adapter/src/Gql/LegacyGqlArguments.php | 24 ++++- yii2-adapter/src/Gql/LegacyGqlTypes.php | 16 ++- .../src/Utility/LegacyUtilityTypes.php | 13 ++- yii2-adapter/src/Yii2ServiceProvider.php | 3 + .../Legacy/TypeRegistryCompatibilityTest.php | 97 +++++++++++++++++-- 12 files changed, 185 insertions(+), 42 deletions(-) create mode 100644 yii2-adapter/src/Asset/LegacyAssetFileKinds.php diff --git a/src/FieldLayout/NativeFields.php b/src/FieldLayout/NativeFields.php index 4642b14b2a8..b7f7623fbaf 100644 --- a/src/FieldLayout/NativeFields.php +++ b/src/FieldLayout/NativeFields.php @@ -62,7 +62,7 @@ public function apply(FieldLayout $fieldLayout, array $fields = []): array { foreach ($this->providers as $handle => $provider) { $fields = $this->container->call($provider, [ - 'fieldLayout' => $fieldLayout, + FieldLayout::class => $fieldLayout, 'fields' => $fields, ]); diff --git a/yii2-adapter/legacy/events/RegisterAssetFileKindsEvent.php b/yii2-adapter/legacy/events/RegisterAssetFileKindsEvent.php index 665ebfbf42e..4db91a44b2a 100644 --- a/yii2-adapter/legacy/events/RegisterAssetFileKindsEvent.php +++ b/yii2-adapter/legacy/events/RegisterAssetFileKindsEvent.php @@ -14,7 +14,7 @@ * * @author Pixel & Tonic, Inc. * @since 3.0.0 - * @deprecated 6.0.0 use {@see \CraftCms\Cms\Asset\Events\AssetFileKindsResolving} instead. + * @deprecated 6.0.0 use {@see \CraftCms\Cms\Asset\AssetFileKinds::register()} instead. */ class RegisterAssetFileKindsEvent extends Event { diff --git a/yii2-adapter/legacy/helpers/Assets.php b/yii2-adapter/legacy/helpers/Assets.php index f689a05a011..1161067faef 100644 --- a/yii2-adapter/legacy/helpers/Assets.php +++ b/yii2-adapter/legacy/helpers/Assets.php @@ -10,13 +10,11 @@ namespace craft\helpers; use craft\base\Event as YiiEvent; -use craft\events\RegisterAssetFileKindsEvent; use craft\events\SetAssetFilenameEvent; use craft\helpers\ImageTransforms as TransformHelper; use CraftCms\Cms\Asset\AssetsHelper; use CraftCms\Cms\Asset\Data\VolumeFolder; use CraftCms\Cms\Asset\Elements\Asset; -use CraftCms\Cms\Asset\Events\AssetFileKindsResolving; use CraftCms\Cms\Asset\Events\SetAssetFilename; use CraftCms\Cms\Cms; use CraftCms\Cms\Shared\Enums\TimePeriod; @@ -231,13 +229,5 @@ public static function registerEvents(): void $event->extension = $yiiEvent->extension; } }); - - Event::listen(function(AssetFileKindsResolving $event) { - if (YiiEvent::hasHandlers(self::class, self::EVENT_REGISTER_FILE_KINDS)) { - $yiiEvent = new RegisterAssetFileKindsEvent(['fileKinds' => $event->fileKinds]); - YiiEvent::trigger(self::class, self::EVENT_REGISTER_FILE_KINDS, $yiiEvent); - $event->fileKinds = $yiiEvent->fileKinds; - } - }); } } diff --git a/yii2-adapter/legacy/services/Auth.php b/yii2-adapter/legacy/services/Auth.php index c1adfe113b4..f91476eabf8 100644 --- a/yii2-adapter/legacy/services/Auth.php +++ b/yii2-adapter/legacy/services/Auth.php @@ -9,7 +9,7 @@ use Craft; use craft\events\RegisterComponentTypesEvent; -use CraftCms\Cms\Auth\Events\AuthMethodsResolving; +use CraftCms\Cms\Auth\AuthMethodCatalog; use CraftCms\Cms\Auth\Events\SettingPassword; use CraftCms\Cms\Auth\Methods\AuthMethodInterface; use CraftCms\Cms\Auth\Passkeys\Passkeys; @@ -19,8 +19,8 @@ use CraftCms\Cms\User\Elements\User; use CraftCms\Cms\User\Validation\UserRules; use CraftCms\Cms\View\TemplateMode; +use CraftCms\Yii2Adapter\Event\TypeRegistryCompatibility; use DateTime; -use Illuminate\Support\Collection; use Illuminate\Support\Facades\Event; use Illuminate\Support\Facades\Password; use InvalidArgumentException; @@ -324,14 +324,6 @@ public function webauthnServer(): WebauthnServer public static function registerEvents(): void { - Event::listen(AuthMethodsResolving::class, function(AuthMethodsResolving $event) { - if (Craft::$app->getAuth()->hasEventHandlers(self::EVENT_REGISTER_METHODS)) { - $yiiEvent = new RegisterComponentTypesEvent(['types' => $event->methods->all()]); - Craft::$app->getAuth()->trigger(self::EVENT_REGISTER_METHODS, $yiiEvent); - $event->methods = new Collection($yiiEvent->types); - } - }); - Event::listen(SettingPassword::class, function(SettingPassword $event) { if ($event->status === Password::PASSWORD_RESET) { return; @@ -353,4 +345,10 @@ public static function registerEvents(): void $event->status = Password::PASSWORD_RESET; }); } + + /** @internal */ + public static function finalizeRegistrationEvents(): void + { + TypeRegistryCompatibility::reconcile(app(AuthMethodCatalog::class), Craft::$app->getAuth(), self::EVENT_REGISTER_METHODS); + } } diff --git a/yii2-adapter/legacy/services/Fields.php b/yii2-adapter/legacy/services/Fields.php index 28a41474705..0ceeda7e419 100644 --- a/yii2-adapter/legacy/services/Fields.php +++ b/yii2-adapter/legacy/services/Fields.php @@ -29,8 +29,8 @@ use CraftCms\Cms\Field\Events\FieldSaveApplying; use CraftCms\Cms\Field\Events\FieldSaved; use CraftCms\Cms\Field\Events\FieldSaving; -use CraftCms\Cms\Field\Events\NestedEntryFieldTypesResolving; use CraftCms\Cms\Field\FieldTypes; +use CraftCms\Cms\Field\NestedEntryFieldTypes; use CraftCms\Cms\FieldLayout\FieldLayout; use CraftCms\Cms\FieldLayout\FieldLayoutElement; use CraftCms\Cms\ProjectConfig\Events\ConfigEvent; @@ -718,6 +718,7 @@ public function getTableData( public static function finalizeRegistrationEvents(): void { TypeRegistryCompatibility::reconcile(app(FieldTypes::class), Craft::$app->getFields(), self::EVENT_REGISTER_FIELD_TYPES); + TypeRegistryCompatibility::reconcile(app(NestedEntryFieldTypes::class), Craft::$app->getFields(), self::EVENT_REGISTER_NESTED_ENTRY_FIELD_TYPES); } public static function registerEvents(): void @@ -730,14 +731,6 @@ public static function registerEvents(): void } }); - Event::listen(function(NestedEntryFieldTypesResolving $event) { - if (Craft::$app->getFields()->hasEventHandlers(self::EVENT_REGISTER_NESTED_ENTRY_FIELD_TYPES)) { - $yiiEvent = new RegisterComponentTypesEvent(['types' => $event->types->all()]); - Craft::$app->getFields()->trigger(self::EVENT_REGISTER_NESTED_ENTRY_FIELD_TYPES, $yiiEvent); - $event->types = new Collection($yiiEvent->types); - } - }); - Event::listen(function(FieldSaving $event) { if (Craft::$app->getFields()->hasEventHandlers(self::EVENT_BEFORE_SAVE_FIELD)) { $yiiEvent = new FieldEvent(['field' => $event->field, 'isNew' => $event->isNew]); diff --git a/yii2-adapter/src/Asset/LegacyAssetFileKinds.php b/yii2-adapter/src/Asset/LegacyAssetFileKinds.php new file mode 100644 index 00000000000..f785f8f2179 --- /dev/null +++ b/yii2-adapter/src/Asset/LegacyAssetFileKinds.php @@ -0,0 +1,30 @@ + $fileKinds]); + YiiEvent::trigger(Assets::class, Assets::EVENT_REGISTER_FILE_KINDS, $event); + + return $event->fileKinds; + } +} diff --git a/yii2-adapter/src/Event/EventCompatibility.php b/yii2-adapter/src/Event/EventCompatibility.php index 64967e7ab7a..3f0fa6417e9 100644 --- a/yii2-adapter/src/Event/EventCompatibility.php +++ b/yii2-adapter/src/Event/EventCompatibility.php @@ -183,6 +183,7 @@ public function boot(): void public function finalizeRegistrationEvents(): void { LegacyField::finalizeRegistrationEvents(); + Auth::finalizeRegistrationEvents(); Elements::finalizeRegistrationEvents(); Fields::finalizeRegistrationEvents(); Fs::finalizeRegistrationEvents(); diff --git a/yii2-adapter/src/Gql/LegacyGqlArguments.php b/yii2-adapter/src/Gql/LegacyGqlArguments.php index 1138d4dfdc5..b8bdbca0776 100644 --- a/yii2-adapter/src/Gql/LegacyGqlArguments.php +++ b/yii2-adapter/src/Gql/LegacyGqlArguments.php @@ -4,11 +4,14 @@ namespace CraftCms\Yii2Adapter\Gql; +use Closure; use craft\base\Event as YiiEvent; use craft\events\RegisterGqlArgumentHandlersEvent; use craft\gql\ArgumentManager as LegacyArgumentManager; +use CraftCms\Cms\Gql\Contracts\ArgumentHandlerInterface; use CraftCms\Cms\Gql\GqlArguments; use Illuminate\Support\Collection; +use InvalidArgumentException; use Override; /** @internal */ @@ -26,6 +29,25 @@ public function handlers(): Collection $event = new RegisterGqlArgumentHandlersEvent(['handlers' => $handlers->all()]); YiiEvent::trigger(LegacyArgumentManager::class, LegacyArgumentManager::EVENT_DEFINE_GQL_ARGUMENT_HANDLERS, $event); - return collect($event->handlers); + return collect($event->handlers) + ->map(fn(mixed $handler) => $this->normalizeHandler($handler)); + } + + /** @return class-string|Closure */ + private function normalizeHandler(mixed $handler): string|Closure + { + if ($handler instanceof Closure) { + return $handler; + } + + if ($handler instanceof ArgumentHandlerInterface) { + return fn() => $handler; + } + + if (is_string($handler) && is_a($handler, ArgumentHandlerInterface::class, true)) { + return $handler; + } + + throw new InvalidArgumentException('Legacy GraphQL argument handlers must implement ' . ArgumentHandlerInterface::class . '.'); } } diff --git a/yii2-adapter/src/Gql/LegacyGqlTypes.php b/yii2-adapter/src/Gql/LegacyGqlTypes.php index 61aee1438c9..9e160f37a3e 100644 --- a/yii2-adapter/src/Gql/LegacyGqlTypes.php +++ b/yii2-adapter/src/Gql/LegacyGqlTypes.php @@ -7,8 +7,10 @@ use Craft; use craft\events\RegisterGqlTypesEvent; use craft\services\Gql as LegacyGqlService; +use CraftCms\Cms\Gql\Contracts\SingularTypeInterface; use CraftCms\Cms\Gql\GqlTypes; use Illuminate\Support\Collection; +use InvalidArgumentException; use Override; /** @internal */ @@ -27,6 +29,18 @@ public function types(): Collection $event = new RegisterGqlTypesEvent(['types' => $types->all()]); $service->trigger(LegacyGqlService::EVENT_REGISTER_GQL_TYPES, $event); - return new Collection($event->types); + return collect($event->types) + ->map(fn(mixed $type) => $this->normalizeType($type)) + ->values(); + } + + /** @return class-string */ + private function normalizeType(mixed $type): string + { + if (is_string($type) && is_a($type, SingularTypeInterface::class, true)) { + return $type; + } + + throw new InvalidArgumentException('Legacy GraphQL types must implement ' . SingularTypeInterface::class . '.'); } } diff --git a/yii2-adapter/src/Utility/LegacyUtilityTypes.php b/yii2-adapter/src/Utility/LegacyUtilityTypes.php index 417e0b20b6c..c01baf1b29f 100644 --- a/yii2-adapter/src/Utility/LegacyUtilityTypes.php +++ b/yii2-adapter/src/Utility/LegacyUtilityTypes.php @@ -11,6 +11,7 @@ use CraftCms\Cms\Utility\Utility; use CraftCms\Cms\Utility\UtilityTypes; use Illuminate\Support\Collection; +use InvalidArgumentException; use Override; /** @internal */ @@ -31,8 +32,18 @@ public function types(): Collection $disabledUtilities = array_flip(Cms::config()->disabledUtilities); return collect($event->types) - /** @var class-string $type */ + ->map(fn(mixed $type) => $this->normalizeType($type)) ->filter(fn(string $type) => !isset($disabledUtilities[$type::id()]) && $type::isSelectable()) ->values(); } + + /** @return class-string */ + private function normalizeType(mixed $type): string + { + if (is_string($type) && is_a($type, Utility::class, true)) { + return $type; + } + + throw new InvalidArgumentException('Legacy utility types must extend ' . Utility::class . '.'); + } } diff --git a/yii2-adapter/src/Yii2ServiceProvider.php b/yii2-adapter/src/Yii2ServiceProvider.php index 03e2d67fbc6..60c8d7de37c 100644 --- a/yii2-adapter/src/Yii2ServiceProvider.php +++ b/yii2-adapter/src/Yii2ServiceProvider.php @@ -9,6 +9,7 @@ use craft\web\Application as WebApplication; use craft\web\ErrorHandler; use craft\web\twig\variables\CraftVariable as LegacyCraftVariable; +use CraftCms\Cms\Asset\AssetFileKinds; use CraftCms\Cms\Cms; use CraftCms\Cms\Cp\Settings; use CraftCms\Cms\Database\LaravelMigrations; @@ -27,6 +28,7 @@ use CraftCms\Cms\Utility\UtilityTypes; use CraftCms\Cms\View\TemplateMode; use CraftCms\Cms\View\TemplateRoots; +use CraftCms\Yii2Adapter\Asset\LegacyAssetFileKinds; use CraftCms\Yii2Adapter\Config\GeneralConfigCompatibility; use CraftCms\Yii2Adapter\Config\MultiEnvironmentConfigCompatibility; use CraftCms\Yii2Adapter\Console\AddCategoriesSupportCommand; @@ -95,6 +97,7 @@ public function register(): void new LegacyApp()->register($this->app); new CompatibilityMixins()->register(); new FilesystemCompatibility()->register($this->app); + $this->app->singleton(AssetFileKinds::class, LegacyAssetFileKinds::class); $this->app->singleton(Settings::class, LegacySettings::class); $this->app->singleton(GqlArguments::class, LegacyGqlArguments::class); $this->app->singleton(GqlDirectives::class, LegacyGqlDirectives::class); diff --git a/yii2-adapter/tests-laravel/Legacy/TypeRegistryCompatibilityTest.php b/yii2-adapter/tests-laravel/Legacy/TypeRegistryCompatibilityTest.php index d69e7dd7872..80ccd02be0b 100644 --- a/yii2-adapter/tests-laravel/Legacy/TypeRegistryCompatibilityTest.php +++ b/yii2-adapter/tests-laravel/Legacy/TypeRegistryCompatibilityTest.php @@ -3,8 +3,11 @@ declare(strict_types=1); use craft\base\Event as YiiEvent; +use craft\events\RegisterAssetFileKindsEvent; use craft\events\RegisterComponentTypesEvent; use craft\fields\Link as LegacyLink; +use craft\helpers\Assets as LegacyAssets; +use craft\services\Auth as LegacyAuth; use craft\services\Dashboard as LegacyDashboard; use craft\services\Elements as LegacyElements; use craft\services\Fields as LegacyFields; @@ -12,6 +15,10 @@ use craft\services\ImageTransforms as LegacyImageTransforms; use craft\services\Utilities as LegacyUtilities; use CraftCms\Cms\Address\Elements\Address; +use CraftCms\Cms\Asset\AssetFileKinds; +use CraftCms\Cms\Auth\AuthMethodCatalog; +use CraftCms\Cms\Auth\AuthMethods; +use CraftCms\Cms\Auth\Methods\TOTP; use CraftCms\Cms\Cms; use CraftCms\Cms\Dashboard\Widgets\CraftSupport; use CraftCms\Cms\Dashboard\Widgets\Feed; @@ -28,6 +35,8 @@ use CraftCms\Cms\Field\LinkTypes; use CraftCms\Cms\Field\LinkTypes\Asset as LinkAsset; use CraftCms\Cms\Field\LinkTypes\Url; +use CraftCms\Cms\Field\Matrix; +use CraftCms\Cms\Field\NestedEntryFieldTypes; use CraftCms\Cms\Field\PlainText; use CraftCms\Cms\Filesystem\Filesystems; use CraftCms\Cms\Filesystem\Filesystems\Local; @@ -49,18 +58,46 @@ afterEach(function() { foreach ([ - LegacyElements::class => LegacyElements::EVENT_REGISTER_ELEMENT_TYPES, - LegacyFields::class => LegacyFields::EVENT_REGISTER_FIELD_TYPES, - LegacyDashboard::class => LegacyDashboard::EVENT_REGISTER_WIDGET_TYPES, - LegacyUtilities::class => LegacyUtilities::EVENT_REGISTER_UTILITIES, - LegacyFilesystems::class => LegacyFilesystems::EVENT_REGISTER_FILESYSTEM_TYPES, - LegacyImageTransforms::class => LegacyImageTransforms::EVENT_REGISTER_IMAGE_TRANSFORMERS, - LegacyLink::class => LegacyLink::EVENT_REGISTER_LINK_TYPES, - ] as $class => $event) { + [LegacyAssets::class, LegacyAssets::EVENT_REGISTER_FILE_KINDS], + [LegacyAuth::class, LegacyAuth::EVENT_REGISTER_METHODS], + [LegacyElements::class, LegacyElements::EVENT_REGISTER_ELEMENT_TYPES], + [LegacyFields::class, LegacyFields::EVENT_REGISTER_FIELD_TYPES], + [LegacyFields::class, LegacyFields::EVENT_REGISTER_NESTED_ENTRY_FIELD_TYPES], + [LegacyDashboard::class, LegacyDashboard::EVENT_REGISTER_WIDGET_TYPES], + [LegacyUtilities::class, LegacyUtilities::EVENT_REGISTER_UTILITIES], + [LegacyFilesystems::class, LegacyFilesystems::EVENT_REGISTER_FILESYSTEM_TYPES], + [LegacyImageTransforms::class, LegacyImageTransforms::EVENT_REGISTER_IMAGE_TRANSFORMERS], + [LegacyLink::class, LegacyLink::EVENT_REGISTER_LINK_TYPES], + ] as [$class, $event]) { YiiEvent::off($class, $event); } }); +it('applies legacy asset file kind listeners to the current definitions', function() { + $fileKinds = app(AssetFileKinds::class); + $fileKinds->register('modern', [ + 'label' => 'Modern', + 'extensions' => ['modern'], + ]); + $calls = 0; + + YiiEvent::on(LegacyAssets::class, LegacyAssets::EVENT_REGISTER_FILE_KINDS, function(RegisterAssetFileKindsEvent $event) use (&$calls) { + $calls++; + expect($event->fileKinds)->toHaveKey('modern'); + $event->fileKinds = [ + 'legacy' => [ + 'label' => 'Legacy', + 'extensions' => ['legacy'], + ], + ]; + }); + + expect($fileKinds->fileKinds())->toHaveKey('legacy') + ->not()->toHaveKey('modern') + ->and($fileKinds->fileKinds())->toHaveKey('legacy') + ->and($calls)->toBe(2); +}); + it('exposes Craft 5 element aliases through the legacy service and event', function() { $eventTypes = []; YiiEvent::on(LegacyElements::class, LegacyElements::EVENT_REGISTER_ELEMENT_TYPES, function(RegisterComponentTypesEvent $event) use (&$eventTypes) { @@ -246,6 +283,26 @@ public static function contentHtml(): string PlainText::class, Color::class, ], + 'authentication methods' => [ + AuthMethodCatalog::class, + AdapterRegistryAuthMethod::class, + LegacyAuth::class, + LegacyAuth::EVENT_REGISTER_METHODS, + AuthMethods::class, + 'types', + AdapterRetainedAuthMethod::class, + TOTP::class, + ], + 'nested entry fields' => [ + NestedEntryFieldTypes::class, + AdapterRegistryNestedEntryField::class, + LegacyFields::class, + LegacyFields::EVENT_REGISTER_NESTED_ENTRY_FIELD_TYPES, + Fields::class, + 'getNestedEntryFieldTypes', + AdapterRetainedNestedEntryField::class, + Matrix::class, + ], 'widgets' => [ WidgetTypes::class, AdapterRegistryWidget::class, @@ -286,6 +343,30 @@ abstract class AdapterRegistryField extends Field { } +class AdapterRegistryAuthMethod extends TOTP +{ + public static function handle(): string + { + return 'adapter-registry'; + } +} + +class AdapterRetainedAuthMethod extends TOTP +{ + public static function handle(): string + { + return 'adapter-retained'; + } +} + +abstract class AdapterRegistryNestedEntryField extends Matrix +{ +} + +abstract class AdapterRetainedNestedEntryField extends Matrix +{ +} + abstract class AdapterRegistryWidget extends Widget { }