diff --git a/docs/components.md b/docs/components.md index 9d30ae21e83809b1769e70b677e4b726b42d6452..c672f5c2b10c326bc09f10e173115fee98560bcc 100644 --- a/docs/components.md +++ b/docs/components.md @@ -111,7 +111,7 @@ For a `Block` to be compatible/eligible for use in XB it: - MUST have fully validatable block plugin settings config schema via the `FullyValidatable` constraint - MUST NOT have any required context (⚠️ handling contexts is still TBD in [#3485502](https://www.drupal.org/project/experience_builder/issues/3485502)) -These checks are implemented in `experience_builder_block_alter()`. +These checks are implemented in `\Drupal\experience_builder\Plugin\ExperienceBuilder\ComponentSource\BlockComponent::checkRequirements()`. _Note:_ this list of criteria is not final, it will keep evolving _at least_ until a `1.0` release of XB. diff --git a/experience_builder.module b/experience_builder.module index d72a204458f8f89fce4c5566e8434d7b8e96ff0b..d749147f5a556ee62fda977fa07d7991d0b6ee8f 100644 --- a/experience_builder.module +++ b/experience_builder.module @@ -12,9 +12,7 @@ ini_set('assert.active', 1); use Drupal\Component\Serialization\Json; use Drupal\Core\Block\BlockManagerInterface; -use Drupal\Core\Block\BlockPluginInterface; use Drupal\Core\Entity\Display\EntityFormDisplayInterface; -use Drupal\Core\Block\MainContentBlockPluginInterface; use Drupal\Core\Entity\EntityPublishedInterface; use Drupal\Core\Entity\EntityTypeInterface; use Drupal\Core\Entity\TypedData\EntityDataDefinition; @@ -25,14 +23,11 @@ use Drupal\Core\Form\FormStateInterface; use Drupal\Core\Plugin\Discovery\YamlDiscovery; use Drupal\datetime\Plugin\Field\FieldType\DateTimeItem; use Drupal\experience_builder\Entity\AssetLibrary; -use Drupal\experience_builder\Entity\Component; use Drupal\experience_builder\Entity\JavaScriptComponent; use Drupal\experience_builder\Form\FormIdPreRender; use Drupal\experience_builder\Entity\PageRegion; use Drupal\experience_builder\Plugin\ComponentPluginManager; -use Drupal\Core\Validation\Plugin\Validation\Constraint\FullyValidatableConstraint; use Drupal\experience_builder\Plugin\DisplayVariant\XbPageVariant; -use Drupal\experience_builder\Plugin\ExperienceBuilder\ComponentSource\BlockComponent; use Drupal\experience_builder\Plugin\Field\FieldType\ComponentTreeItem; use Drupal\experience_builder\PropExpressions\StructuredData\FieldPropExpression; use Drupal\experience_builder\PropExpressions\StructuredData\FieldTypeObjectPropsExpression; @@ -209,88 +204,6 @@ function experience_builder_rebuild(): void { \Drupal::service(ComponentPluginManager::class)->getDefinitions(); } -/** - * Implements hook_block_alter(). - */ -function experience_builder_block_alter(array &$definitions): void { - static $in_recursion = FALSE; - // Ensure we're not in infinite recursion. - if ($in_recursion) { - return; - } - - // @todo Remove when minimum version is Drupal 11.1 following https://www.drupal.org/project/drupal/issues/3379725 - $additional = version_compare(\Drupal::VERSION, '11.1', '>=') ? [] : [ - 'info' => '', - 'status' => TRUE, - 'view_mode' => '', - 'context_mapping' => [], - ]; - - // @todo This only handles new & existing Component entities in best case scenario, but does not handle cases where BlockComponent based entities exist, but Block Plugin definition is missing, see https://www.drupal.org/project/experience_builder/issues/3484682 - foreach ($definitions as $id => $definition) { - if ($id === 'broken') { - continue; - } - - $component_id = 'block.' . str_replace(':', '.', $id); - $component = Component::load($component_id); - if ($component instanceof Component) { - // @todo Update Component entities with BlockComponent source plugin: https://www.drupal.org/project/experience_builder/issues/3484682 - continue; - } - else { - $in_recursion = TRUE; - // @todo This is super ugly, decorate the block plugin manager so we can create instances directly? - // @todo is this a not going to become performance bottle neck on BlockPlugin heavy sites? - $block = \Drupal::service('plugin.manager.block')->createInstance($id); - assert($block instanceof BlockPluginInterface); - // The main content is rendered in a fixed position. - // @see \Drupal\experience_builder\Plugin\DisplayVariant\XbPageVariant::build() - if ($block instanceof MainContentBlockPluginInterface) { - continue; - } - $settings = $block->defaultConfiguration(); - $data_definition = \Drupal::service('config.typed')->createFromNameAndData('block.settings.' . $id, $settings); - // We currently support only block plugins with no settings, or if they do - // have settings, they must be fully validatable. - $fullyValidatable = FALSE; - foreach ($data_definition->getConstraints() as $constraint) { - if ($constraint instanceof FullyValidatableConstraint) { - $fullyValidatable = TRUE; - break; - } - } - // @todo Remove the PageTitleBlock and SystemMessagesBlock special cases: make it fully validatable upstream in Drupal core. They are exempted here because of its crucial role in \Drupal\experience_builder\Entity\PageTemplate. Alternatively, this can be removed once XB requires Drupal 11. - // @todo Remove the LocalActionsBlock special case: it is necessary to be able to test BlockPluginInterface::access() support *and* it has the exact same trivial settings as the two crucial blocks above. Alternatively, this can be removed once XB requires Drupal 11. - if (!empty($settings) && !$fullyValidatable && $id !== 'page_title_block' && $id !== 'system_messages_block' && $id != 'local_actions_block') { - continue; - } - $component = Component::create([ - 'id' => $component_id, - 'label' => (string) $definition['admin_label'], - 'category' => (string) $definition['category'], - 'source' => BlockComponent::SOURCE_PLUGIN_ID, - 'provider' => $definition['provider'], - 'settings' => [ - 'plugin_id' => $id, - // We are using strict config schema validation, so we need to provide valid default settings for each block. - 'default_settings' => [ - // @todo if we need ID here can we merge settings with the parent and drop plugin_id? - 'id' => $id, - 'label' => (string) $definition['admin_label'], - 'label_display' => FALSE, - 'provider' => $definition['provider'], - ] + $additional + $settings, - ], - 'status' => TRUE, - ]); - $component->save(); - $in_recursion = FALSE; - } - } -} - /** * Implements hook_config_schema_info_alter(). */ diff --git a/experience_builder.services.yml b/experience_builder.services.yml index 4c67d87f7644b5d8aea0259d5cfe729a22ea4097..77f7cfd22e388beaf2346992113f847d123b926c 100644 --- a/experience_builder.services.yml +++ b/experience_builder.services.yml @@ -31,6 +31,10 @@ services: decorates: Drupal\Core\Theme\ComponentPluginManager parent: Drupal\Core\Theme\ComponentPluginManager arguments: ['@entity_type.manager', '@Drupal\experience_builder\ComponentIncompatibilityReasonRepository'] + Drupal\experience_builder\Plugin\BlockManager: + decorates: Drupal\Core\Block\BlockManagerInterface + parent: Drupal\Core\Block\BlockManagerInterface + arguments: ['@config.typed', '@Drupal\experience_builder\ComponentIncompatibilityReasonRepository'] Drupal\experience_builder\ComponentSource\ComponentSourceManager: parent: default_plugin_manager public: true diff --git a/phpstan.neon b/phpstan.neon index 0e2e9ff7c02f2334d7cbb8f988ccbacc85d705ff..fda757ed67938cb96a5682fc3bd2db7f04cb350f 100644 --- a/phpstan.neon +++ b/phpstan.neon @@ -34,9 +34,11 @@ parameters: count: 1 path: src/PropSource/StaticPropSource.php - - message: "#^Missing cache backend declaration for performance\\.$#" - count: 1 - path: src/Plugin/ComponentPluginManager.php + messages: + - "#^Missing cache backend declaration for performance\\.$#" + paths: + - src/Plugin/BlockManager.php + - src/Plugin/ComponentPluginManager.php - message: "#^Cannot assign offset 'id' to string\\.$#" count: 1 diff --git a/src/ComponentDoesNotMeetRequirementsException.php b/src/ComponentDoesNotMeetRequirementsException.php index 5d0887368e409dd3c7ea2c37bc6ee3dafcafc0f1..b1dda871de538834e5a82d116877a651326a748a 100644 --- a/src/ComponentDoesNotMeetRequirementsException.php +++ b/src/ComponentDoesNotMeetRequirementsException.php @@ -9,4 +9,16 @@ namespace Drupal\experience_builder; */ final class ComponentDoesNotMeetRequirementsException extends \Exception { + public function __construct( + protected readonly array $messages, + int $code = 0, + ?\Throwable $previous = NULL, + ) { + parent::__construct(\implode("\n", $this->messages), $code, $previous); + } + + public function getMessages(): array { + return $this->messages; + } + } diff --git a/src/ComponentIncompatibilityReasonRepository.php b/src/ComponentIncompatibilityReasonRepository.php index 270557de721375a3dc97f387753abb8fdcbbd87f..9264e74da5a00e80dcf45d0e72a642a2e7fdeaae 100644 --- a/src/ComponentIncompatibilityReasonRepository.php +++ b/src/ComponentIncompatibilityReasonRepository.php @@ -23,9 +23,16 @@ final class ComponentIncompatibilityReasonRepository { $this->keyValue = $keyValueFactory->get('experience_builder:component:reasons'); } - public function storeReason(string $source_plugin_id, string $identifier, string $reason): void { + /** + * @param string $source_plugin_id + * @param string $identifier + * @param array<int, string> $reasons + * + * @return void + */ + public function storeReasons(string $source_plugin_id, string $identifier, array $reasons): void { $key = $this->generateKey($source_plugin_id, $identifier); - $this->keyValue->set($key, $reason); + $this->keyValue->set($key, $reasons); } public function removeReason(string $source_plugin_id, string $identifier): void { diff --git a/src/ComponentMetadataRequirementsChecker.php b/src/ComponentMetadataRequirementsChecker.php index 01abc5d31f4779f1149e951fd77facf1dcc9ed61..4a3837ab33cef8f94729bccdb86a47e6733f2753 100644 --- a/src/ComponentMetadataRequirementsChecker.php +++ b/src/ComponentMetadataRequirementsChecker.php @@ -28,15 +28,16 @@ final class ComponentMetadataRequirementsChecker { * When the component does not meet requirements. */ public static function check(string $component_id, ComponentMetadata $metadata, array $required_props): void { + $messages = []; // XB always requires schema, even for theme components. // @see \Drupal\Core\Theme\ComponentPluginManager::shouldEnforceSchemas() // @see \Drupal\Core\Theme\Component\ComponentMetadata::parseSchemaInfo() if ($metadata->schema === NULL) { - throw new ComponentDoesNotMeetRequirementsException('Component has no props schema'); + throw new ComponentDoesNotMeetRequirementsException(['Component has no props schema']); } if ($metadata->group == 'Elements') { - throw new ComponentDoesNotMeetRequirementsException('Component uses the reserved "Elements" category'); + $messages[] = 'Component uses the reserved "Elements" category'; } $missing_examples = \array_filter( @@ -44,9 +45,7 @@ final class ComponentMetadataRequirementsChecker { static fn (array $property) => empty($property['examples']) ); if (\count($missing_examples) > 0) { - throw new ComponentDoesNotMeetRequirementsException( - \implode("\n", \array_map(static fn(string $prop) => \sprintf('Prop "%s" is required, but does not have example value', $prop), \array_keys($missing_examples))) - ); + $messages += \array_map(static fn(string $prop) => \sprintf('Prop "%s" is required, but does not have example value', $prop), \array_keys($missing_examples)); } $props_for_metadata = PropShape::getComponentPropsForMetadata($component_id, $metadata); @@ -56,7 +55,7 @@ final class ComponentMetadataRequirementsChecker { } // Every prop must have a title. if (!isset($prop['title'])) { - throw new ComponentDoesNotMeetRequirementsException(\sprintf('Prop "%s" must have title', $prop_name)); + $messages[] = \sprintf('Prop "%s" must have title', $prop_name); } // Every prop must have a StorablePropShape. $component_prop_expression = new ComponentPropExpression($component_id, $prop_name); @@ -65,7 +64,10 @@ final class ComponentMetadataRequirementsChecker { if ($storable_prop_shape instanceof StorablePropShape) { continue; } - throw new ComponentDoesNotMeetRequirementsException(\sprintf('Experience Builder does not know of a field type/widget to allow populating the <code>%s</code> prop, with the shape <code>%s</code>.', $prop_name, json_encode($prop_shape->schema, JSON_UNESCAPED_SLASHES))); + $messages[] = \sprintf('Experience Builder does not know of a field type/widget to allow populating the <code>%s</code> prop, with the shape <code>%s</code>.', $prop_name, json_encode($prop_shape->schema, JSON_UNESCAPED_SLASHES)); + } + if (!empty($messages)) { + throw new ComponentDoesNotMeetRequirementsException($messages); } } diff --git a/src/Controller/ComponentStatusController.php b/src/Controller/ComponentStatusController.php index ad40cc7c0da70fd30d427b459690e6fb319dc91b..9abc857755ae16163264eb24b97a5389b4e89e02 100644 --- a/src/Controller/ComponentStatusController.php +++ b/src/Controller/ComponentStatusController.php @@ -41,28 +41,27 @@ final class ComponentStatusController { $reasons = $this->reasonRepository->getReasons(); $rows = []; $header = [ - [ - 'data' => $this->t('Component'), - ], - [ - 'data' => $this->t('Status'), - ], - [ - 'data' => $this->t('Reason'), - ], + 'id' => $this->t('Component'), + 'status' => $this->t('Status'), + 'reason' => $this->t('Reason'), ]; foreach ($reasons as $source_reasons) { - foreach ($source_reasons as $component_id => $reason) { + foreach ($source_reasons as $component_id => $component_reasons) { $component_entity = Component::load($component_id); $status = $component_entity instanceof Component && !$component_entity->status() ? $this->t('Disabled') : $this->t('Incompatible'); - - $rows[] = [ - 'data' => [ - $component_id, - $status, - Markup::create($reason), - ], + $items = []; + $component_reasons = is_string($component_reasons) ? [$component_reasons] : $component_reasons; + foreach ($component_reasons as $item) { + $items[] = Markup::create($item); + } + $row = []; + $row['id']['data'] = $component_id; + $row['status']['data'] = $status; + $row['reason']['data'] = [ + '#theme' => 'item_list', + '#items' => $items, ]; + $rows[] = $row; } } @@ -93,7 +92,7 @@ final class ComponentStatusController { $source_plugin_id = $source->getPluginId(); if ($op === 'disable') { $component->disable()->save(); - $this->reasonRepository->storeReason($source_plugin_id, $component_id, 'Manually disabled'); + $this->reasonRepository->storeReasons($source_plugin_id, $component_id, ['Manually disabled']); } elseif ($op === 'enable') { try { @@ -106,7 +105,7 @@ final class ComponentStatusController { "%component" => $component_id, "%reason" => $e->getMessage(), ])); - $this->reasonRepository->storeReason($source_plugin_id, $component_id, $e->getMessage()); + $this->reasonRepository->storeReasons($source_plugin_id, $component_id, $e->getMessages()); return new RedirectResponse(Url::fromRoute('entity.component.collection')->toString()); } } diff --git a/src/Entity/PageRegion.php b/src/Entity/PageRegion.php index 48ad84924b88eea850a664e0bb766f006760556d..bc0578fd5ac6809dadb4148a9ca19f76b0377600 100644 --- a/src/Entity/PageRegion.php +++ b/src/Entity/PageRegion.php @@ -235,7 +235,7 @@ final class PageRegion extends ConfigEntityBase { $component_id = BlockComponent::componentIdFromBlockPluginId($block->getPluginId()); if (!Component::load($component_id)) { // This block isn't supported by XB. - // @see \experience_builder_block_alter(). + // @see \Drupal\experience_builder\Plugin\ExperienceBuilder\ComponentSource\BlockComponent::checkRequirements() continue; } $region_name = match ($block->getRegion()) { diff --git a/src/EntityHandlers/JavascriptComponentStorage.php b/src/EntityHandlers/JavascriptComponentStorage.php index 81c5b7d8d989ada47bf3c1aa283db03e7626d3e1..15fb15e76cdfedf02bc32866efec5ca30bdb736d 100644 --- a/src/EntityHandlers/JavascriptComponentStorage.php +++ b/src/EntityHandlers/JavascriptComponentStorage.php @@ -132,7 +132,7 @@ final class JavascriptComponentStorage extends XbAssetStorage { } private function handleComponentDoesNotMeetRequirementsException(string $component_id, ComponentDoesNotMeetRequirementsException $e): void { - $this->componentIncompatibilityReasonRepository->storeReason(JsComponent::SOURCE_PLUGIN_ID, $component_id, $e->getMessage()); + $this->componentIncompatibilityReasonRepository->storeReasons(JsComponent::SOURCE_PLUGIN_ID, $component_id, $e->getMessages()); } } diff --git a/src/Plugin/BlockManager.php b/src/Plugin/BlockManager.php new file mode 100644 index 0000000000000000000000000000000000000000..35ca65163d1151912cc741abf1616f3ce962e3b6 --- /dev/null +++ b/src/Plugin/BlockManager.php @@ -0,0 +1,115 @@ +<?php + +declare(strict_types=1); + +namespace Drupal\experience_builder\Plugin; + +use Drupal\Core\Block\BlockManager as CoreBlockManager; +use Drupal\Core\Block\BlockPluginInterface; +use Drupal\Core\Block\MainContentBlockPluginInterface; +use Drupal\Core\Cache\CacheBackendInterface; +use Drupal\Core\Config\TypedConfigManagerInterface; +use Drupal\Core\Extension\ModuleHandlerInterface; +use Drupal\experience_builder\ComponentDoesNotMeetRequirementsException; +use Drupal\experience_builder\ComponentIncompatibilityReasonRepository; +use Drupal\experience_builder\Entity\Component; +use Drupal\experience_builder\Plugin\ExperienceBuilder\ComponentSource\BlockComponent; +use Psr\Log\LoggerInterface; + +/** + * Decorator that auto-creates/updates an Experience Builder Component entity per Block plugin. + * + * @see \Drupal\experience_builder\Entity\Component + * @see docs/components.md#3.2 + */ +final class BlockManager extends CoreBlockManager { + + /** + * {@inheritdoc} + */ + public function __construct( + \Traversable $namespaces, + CacheBackendInterface $cache_backend, + ModuleHandlerInterface $module_handler, + LoggerInterface $logger, + protected readonly TypedConfigManagerInterface $configTyped, + private readonly ComponentIncompatibilityReasonRepository $reasonRepository, + ) { + parent::__construct($namespaces, $cache_backend, $module_handler, $logger); + } + + /** + * {@inheritdoc} + */ + protected function setCachedDefinitions($definitions): array { + parent::setCachedDefinitions($definitions); + + // Do not auto-create/update XB configuration when syncing config/deploying. + // @todo Introduce a "XB development mode" similar to Twig's: https://www.drupal.org/node/3359728 + // @phpstan-ignore-next-line + if (\Drupal::isConfigSyncing()) { + return $definitions; + } + + // @todo Remove this in Drupal 11 following https://www.drupal.org/project/drupal/issues/3379725 + [$version] = explode('.', \Drupal::VERSION); + $additional = $version > 10 ? [] : [ + 'info' => '', + 'status' => TRUE, + 'view_mode' => '', + 'context_mapping' => [], + ]; + + foreach ($definitions as $id => $definition) { + if ($id === 'broken') { + continue; + } + + $component_id = 'block.' . str_replace(':', '.', $id); + $component = Component::load($component_id); + if ($component instanceof Component) { + // @todo Update Component entities with BlockComponent source plugin: https://www.drupal.org/project/experience_builder/issues/3484682 + continue; + } + + // @todo is this a not going to become performance bottle neck on BlockPlugin heavy sites? + $block = $this->createInstance($id); + assert($block instanceof BlockPluginInterface); + // The main content is rendered in a fixed position. + // @see \Drupal\experience_builder\Plugin\DisplayVariant\XbPageVariant::build() + if ($block instanceof MainContentBlockPluginInterface) { + continue; + } + $settings = $block->defaultConfiguration(); + $component = Component::create([ + 'id' => $component_id, + 'label' => (string) $definition['admin_label'], + 'category' => (string) $definition['category'], + 'source' => BlockComponent::SOURCE_PLUGIN_ID, + 'provider' => $definition['provider'], + 'settings' => [ + 'plugin_id' => $id, + // We are using strict config schema validation, so we need to provide valid default settings for each block. + 'default_settings' => [ + // @todo if we need ID here can we merge settings with the parent and drop plugin_id? + 'id' => $id, + 'label' => (string) $definition['admin_label'], + 'label_display' => FALSE, + 'provider' => $definition['provider'], + ] + $additional + $settings, + ], + 'status' => TRUE, + ]); + try { + $component->getComponentSource()->checkRequirements(); + $component->save(); + } + catch (ComponentDoesNotMeetRequirementsException $e) { + $this->reasonRepository->storeReasons($block->getPluginId(), $component_id, $e->getMessages()); + } + } + + return $definitions; + } + +} diff --git a/src/Plugin/ComponentPluginManager.php b/src/Plugin/ComponentPluginManager.php index 37c21b82b9ed25021363689b048aaa6683be6b0d..ee8d1cefa8c8fe080c4249cb33f271ce520a309d 100644 --- a/src/Plugin/ComponentPluginManager.php +++ b/src/Plugin/ComponentPluginManager.php @@ -84,7 +84,7 @@ class ComponentPluginManager extends CoreComponentPluginManager implements Categ $component_plugin = $this->createInstance($machine_name); $component = SingleDirectoryComponent::updateConfigEntity($component_plugin); if (isset($component_plugin->metadata->status) && $component_plugin->metadata->status === 'obsolete') { - $reasons[$component_id] = 'Component has "obsolete" status'; + $reasons[$component_id][] = 'Component has "obsolete" status'; $component->disable(); } } @@ -95,7 +95,7 @@ class ComponentPluginManager extends CoreComponentPluginManager implements Categ $component = SingleDirectoryComponent::createConfigEntity($component_plugin); } catch (ComponentDoesNotMeetRequirementsException $e) { - $reasons[$component_id] = $e->getMessage(); + $reasons[$component_id] = $e->getMessages(); continue; } } diff --git a/src/Plugin/DisplayVariant/XbPageVariant.php b/src/Plugin/DisplayVariant/XbPageVariant.php index 5805f5ae9ce1567d08aab7902e593a3682c29d0a..89e2a4f9df3803f1c1fe4967ac0717ddde434ccb 100644 --- a/src/Plugin/DisplayVariant/XbPageVariant.php +++ b/src/Plugin/DisplayVariant/XbPageVariant.php @@ -40,7 +40,7 @@ use Drupal\experience_builder\Plugin\Field\FieldType\ComponentTreeItem; * Finally, MainContentBlockPluginInterface implementations are prevented from * being made available as XB Components. * - * @see experience_builder_block_alter() + * @see \Drupal\experience_builder\Plugin\ExperienceBuilder\ComponentSource\BlockComponent::checkRequirements() * * @see docs/components.md * @see \Drupal\Core\Render\Element\Page diff --git a/src/Plugin/ExperienceBuilder/ComponentSource/BlockComponent.php b/src/Plugin/ExperienceBuilder/ComponentSource/BlockComponent.php index 84caddb8c6d0a9ec8766d21f593d080e8ceb0d9f..4fbac989cf6c152bef5d8f9b995f8d9dd26f5e48 100644 --- a/src/Plugin/ExperienceBuilder/ComponentSource/BlockComponent.php +++ b/src/Plugin/ExperienceBuilder/ComponentSource/BlockComponent.php @@ -7,6 +7,7 @@ namespace Drupal\experience_builder\Plugin\ExperienceBuilder\ComponentSource; use Drupal\Core\Access\AccessResultInterface; use Drupal\Core\Block\BlockManagerInterface; use Drupal\Core\Block\BlockPluginInterface; +use Drupal\Core\Block\MainContentBlockPluginInterface; use Drupal\Core\Block\MessagesBlockPluginInterface; use Drupal\Core\Block\TitleBlockPluginInterface; use Drupal\Core\Config\TypedConfigManagerInterface; @@ -21,7 +22,9 @@ use Drupal\Core\StringTranslation\TranslatableMarkup; use Drupal\Core\TypedData\ComplexDataInterface; use Drupal\Core\TypedData\Plugin\DataType\BooleanData; use Drupal\Core\TypedData\TypedDataInterface; +use Drupal\Core\Validation\Plugin\Validation\Constraint\FullyValidatableConstraint; use Drupal\experience_builder\Attribute\ComponentSource; +use Drupal\experience_builder\ComponentDoesNotMeetRequirementsException; use Drupal\experience_builder\ComponentSource\ComponentSourceBase; use Drupal\experience_builder\Entity\Component; use Drupal\experience_builder\Entity\Component as ComponentEntity; @@ -388,7 +391,29 @@ final class BlockComponent extends ComponentSourceBase implements ContainerFacto * {@inheritdoc} */ public function checkRequirements(): void { - // @todo Move logic from experience_builder_block_alter here in https://www.drupal.org/project/experience_builder/issues/3491032 + $block = $this->getBlockPlugin(); + // The main content is rendered in a fixed position. + // @see \Drupal\experience_builder\Plugin\DisplayVariant\XbPageVariant::build() + if ($block instanceof MainContentBlockPluginInterface) { + return; + } + $settings = $block->defaultConfiguration(); + $data_definition = $this->typedConfigManager->createFromNameAndData('block.settings.' . $block->getPluginId(), $settings); + // We currently support only block plugins with no settings, or if they do + // have settings, they must be fully validatable. + $fullyValidatable = FALSE; + foreach ($data_definition->getConstraints() as $constraint) { + if ($constraint instanceof FullyValidatableConstraint) { + $fullyValidatable = TRUE; + break; + } + } + + // @todo Remove the PageTitleBlock and SystemMessagesBlock special cases: make it fully validatable upstream in Drupal core. They are exempted here because of its crucial role in \Drupal\experience_builder\Entity\PageTemplate. Alternatively, this can be removed once XB requires Drupal 11. + // @todo Remove the LocalActionsBlock special case: it is necessary to be able to test BlockPluginInterface::access() support *and* it has the exact same trivial settings as the two crucial blocks above. Alternatively, this can be removed once XB requires Drupal 11. + if (!empty($settings) && !$fullyValidatable && !in_array($block->getPluginId(), ['page_title_block', 'system_messages_block', 'local_actions_block'])) { + throw new ComponentDoesNotMeetRequirementsException(['Block plugin settings must be fully validatable']); + } } } diff --git a/src/Plugin/ExperienceBuilder/ComponentSource/JsComponent.php b/src/Plugin/ExperienceBuilder/ComponentSource/JsComponent.php index 7e3c386fbaa2b6e0b1f56e94b36315f9bb524574..aec6006cc3f908da681ca873c22116c07aff74b2 100644 --- a/src/Plugin/ExperienceBuilder/ComponentSource/JsComponent.php +++ b/src/Plugin/ExperienceBuilder/ComponentSource/JsComponent.php @@ -233,7 +233,7 @@ final class JsComponent extends GeneratedFieldExplicitInputUxComponentSourceBase $ephemeral_sdc_component = self::buildEphemeralSdcPluginInstance($js_component); } catch (InvalidComponentException $e) { - throw new ComponentDoesNotMeetRequirementsException($e->getMessage()); + throw new ComponentDoesNotMeetRequirementsException([$e->getMessage()]); } ComponentMetadataRequirementsChecker::check((string) $js_component->id(), $ephemeral_sdc_component->metadata, $js_component->getRequiredProps()); $props = self::getPropsForComponentPlugin($ephemeral_sdc_component); @@ -274,7 +274,7 @@ final class JsComponent extends GeneratedFieldExplicitInputUxComponentSourceBase $ephemeral_sdc_component = self::buildEphemeralSdcPluginInstance($js_component); } catch (InvalidComponentException $e) { - throw new ComponentDoesNotMeetRequirementsException($e->getMessage()); + throw new ComponentDoesNotMeetRequirementsException([$e->getMessage()]); } ComponentMetadataRequirementsChecker::check((string) $js_component->id(), $ephemeral_sdc_component->metadata, $js_component->getRequiredProps()); $settings['prop_field_definitions'] = self::getPropsForComponentPlugin($ephemeral_sdc_component); @@ -304,7 +304,7 @@ final class JsComponent extends GeneratedFieldExplicitInputUxComponentSourceBase $ephemeral_sdc_component = self::buildEphemeralSdcPluginInstance($js_component); } catch (InvalidComponentException $e) { - throw new ComponentDoesNotMeetRequirementsException($e->getMessage()); + throw new ComponentDoesNotMeetRequirementsException([$e->getMessage()]); } ComponentMetadataRequirementsChecker::check((string) $js_component->id(), $ephemeral_sdc_component->metadata, $js_component->getRequiredProps()); } diff --git a/src/Plugin/ExperienceBuilder/ComponentSource/SingleDirectoryComponent.php b/src/Plugin/ExperienceBuilder/ComponentSource/SingleDirectoryComponent.php index 7155c1bc7e1400c1f920fcde3f78f01be5bc6e75..2780ee5097c45c528762cb8a663ce96301cc3f4d 100644 --- a/src/Plugin/ExperienceBuilder/ComponentSource/SingleDirectoryComponent.php +++ b/src/Plugin/ExperienceBuilder/ComponentSource/SingleDirectoryComponent.php @@ -280,7 +280,7 @@ final class SingleDirectoryComponent extends GeneratedFieldExplicitInputUxCompon \assert(\is_array($definition)); if (isset($definition['status']) && $definition['status'] === 'obsolete') { - throw new ComponentDoesNotMeetRequirementsException('Component has "obsolete" status'); + throw new ComponentDoesNotMeetRequirementsException(['Component has "obsolete" status']); } // Special case exception for 'all-props' SDC. // (This is used to develop support for more prop shapes.) diff --git a/src/Plugin/Validation/Constraint/JsComponentHasValidAndSupportedSdcMetadataConstraintValidator.php b/src/Plugin/Validation/Constraint/JsComponentHasValidAndSupportedSdcMetadataConstraintValidator.php index 8e0a715d2f4fe81c40ba5f1efb175ccd84fb0689..cc29399c062ef34df8dcc825c7cb537c640a062b 100644 --- a/src/Plugin/Validation/Constraint/JsComponentHasValidAndSupportedSdcMetadataConstraintValidator.php +++ b/src/Plugin/Validation/Constraint/JsComponentHasValidAndSupportedSdcMetadataConstraintValidator.php @@ -73,7 +73,9 @@ final class JsComponentHasValidAndSupportedSdcMetadataConstraintValidator extend JsComponent::createConfigEntity($data); } catch (ComponentDoesNotMeetRequirementsException $e) { - $this->context->addViolation($e->getMessage()); + foreach ($e->getMessages() as $message) { + $this->context->addViolation($message); + } } } diff --git a/tests/src/Functional/XbConfigEntityHttpApiTest.php b/tests/src/Functional/XbConfigEntityHttpApiTest.php index 79fa474331247c6964fbc504f187592897d966ab..34a691a91380a82e17c291143b0899c06f6bad52 100644 --- a/tests/src/Functional/XbConfigEntityHttpApiTest.php +++ b/tests/src/Functional/XbConfigEntityHttpApiTest.php @@ -689,6 +689,10 @@ class XbConfigEntityHttpApiTest extends HttpApiTestBase { 'detail' => 'Prop "integer" is required, but does not have example value', 'source' => ['pointer' => ''], ], + [ + 'detail' => 'Prop "string" must have title', + 'source' => ['pointer' => ''], + ], [ 'detail' => "'title' is a required key.", 'source' => ['pointer' => 'props.string'], diff --git a/tests/src/Kernel/ComponentIncompatibilityReasonRepositoryTest.php b/tests/src/Kernel/ComponentIncompatibilityReasonRepositoryTest.php index cc6c85d8a248bfe25a5ec6b4374676dc79f8334f..e9c8dfe280c2c1be8c02823a809d82d675e07052 100644 --- a/tests/src/Kernel/ComponentIncompatibilityReasonRepositoryTest.php +++ b/tests/src/Kernel/ComponentIncompatibilityReasonRepositoryTest.php @@ -27,34 +27,40 @@ final class ComponentIncompatibilityReasonRepositoryTest extends KernelTestBase public function testRepository(): void { $repository = $this->container->get(ComponentIncompatibilityReasonRepository::class); \assert($repository instanceof ComponentIncompatibilityReasonRepository); - $repository->storeReason('sketches', 'house', 'Missing door'); - $repository->storeReason('sketches', 'dog', 'Missing tail'); - $repository->storeReason('petra', 'dragon', 'Climate apocalypse'); + $repository->storeReasons('sketches', 'house', ['Missing door']); + $repository->storeReasons('sketches', 'dog', ['Missing tail']); + $repository->storeReasons('petra', 'dragon', ['Climate apocalypse', 'Large and scaly']); self::assertEquals([ 'sketches' => [ - 'house' => 'Missing door', - 'dog' => 'Missing tail', + 'house' => ['Missing door'], + 'dog' => ['Missing tail'], ], 'petra' => [ - 'dragon' => 'Climate apocalypse', + 'dragon' => [ + 'Climate apocalypse', + 'Large and scaly', + ], ], ], $repository->getReasons()); $repository->removeReason('sketches', 'house'); self::assertEquals([ 'sketches' => [ - 'dog' => 'Missing tail', + 'dog' => ['Missing tail'], ], 'petra' => [ - 'dragon' => 'Climate apocalypse', + 'dragon' => [ + 'Climate apocalypse', + 'Large and scaly', + ], ], ], $repository->getReasons()); - $repository->updateReasons('petra', ['converge' => 'Gray snakes slither across country']); + $repository->updateReasons('petra', ['converge' => ['Gray snakes slither across country']]); self::assertEquals([ 'sketches' => [ - 'dog' => 'Missing tail', + 'dog' => ['Missing tail'], ], 'petra' => [ - 'converge' => 'Gray snakes slither across country', + 'converge' => ['Gray snakes slither across country'], ], ], $repository->getReasons()); } diff --git a/tests/src/Kernel/Config/ComponentTest.php b/tests/src/Kernel/Config/ComponentTest.php index 06ec7607176bbd0a8257e7a3cea96e5c5efe28b8..4401714d8ed5ec184637a92c3948ac6e5242f990 100644 --- a/tests/src/Kernel/Config/ComponentTest.php +++ b/tests/src/Kernel/Config/ComponentTest.php @@ -267,7 +267,7 @@ class ComponentTest extends KernelTestBase { $plugin_id = str_replace('.', ':', $plugin_id); $expected_plugins[$type][] = $plugin_id; $this->assertSame($component_entity['compatible'], Component::load($component_id) instanceof Component, $plugin_id . ' and modules: ' . implode(', ', $modules)); - $this->assertSame($component_entity['reason'] ?? NULL, isset($reasons[$component_id]) ? (string) $reasons[$component_id] : NULL, $plugin_id); + $this->assertSame($component_entity['reasons'] ?? NULL, isset($reasons[$component_id]) && !empty($reasons[$component_id]) ? $reasons[$component_id] : NULL, $plugin_id); } $this->assertEqualsCanonicalizing($expected_plugins['sdc'], array_keys($this->componentPluginManager->getDefinitions())); @@ -277,7 +277,7 @@ class ComponentTest extends KernelTestBase { $expected_plugins['block'][] = 'system_clear_cache_block'; } $this->assertEqualsCanonicalizing($expected_plugins['block'], array_diff($all_installed_block_plugin_ids, [ - // @see \experience_builder_block_alter() + // @see \Drupal\experience_builder\Plugin\ExperienceBuilder\ComponentSource\BlockComponent::checkRequirements() 'system_main_block', ])); } @@ -289,7 +289,7 @@ class ComponentTest extends KernelTestBase { $defaults = [ 'sdc.experience_builder.obsolete' => [ 'compatible' => FALSE, - 'reason' => 'Component has "obsolete" status', + 'reasons' => ['Component has "obsolete" status'], ], 'sdc.experience_builder.druplicon' => [ 'compatible' => TRUE, @@ -314,7 +314,7 @@ class ComponentTest extends KernelTestBase { ], 'sdc.experience_builder.video' => [ 'compatible' => FALSE, - 'reason' => 'Experience Builder does not know of a field type/widget to allow populating the <code>src</code> prop, with the shape <code>{"type":"string","format":"uri","pattern":"\\\.(mp4|webm)(\\\?.*)?(#.*)?$"}</code>.', + 'reasons' => ['Experience Builder does not know of a field type/widget to allow populating the <code>src</code> prop, with the shape <code>{"type":"string","format":"uri","pattern":"\\\.(mp4|webm)(\\\?.*)?(#.*)?$"}</code>.'], ], 'sdc.experience_builder.shoe_tab_panel' => [ 'compatible' => TRUE, @@ -324,7 +324,7 @@ class ComponentTest extends KernelTestBase { ], 'sdc.experience_builder.shoe_button' => [ 'compatible' => FALSE, - 'reason' => 'Experience Builder does not know of a field type/widget to allow populating the <code>icon</code> prop, with the shape <code>{"type":"object","$ref":"json-schema-definitions://experience_builder.module/shoe-icon"}</code>.', + 'reasons' => ['Experience Builder does not know of a field type/widget to allow populating the <code>icon</code> prop, with the shape <code>{"type":"object","$ref":"json-schema-definitions://experience_builder.module/shoe-icon"}</code>.'], ], 'sdc.experience_builder.shoe_icon' => [ 'compatible' => TRUE, @@ -337,7 +337,10 @@ class ComponentTest extends KernelTestBase { ], 'sdc.experience_builder.shoe_details' => [ 'compatible' => FALSE, - 'reason' => 'Experience Builder does not know of a field type/widget to allow populating the <code>expand_icon</code> prop, with the shape <code>{"type":"object","$ref":"json-schema-definitions://experience_builder.module/shoe-icon"}</code>.', + 'reasons' => [ + 'Experience Builder does not know of a field type/widget to allow populating the <code>expand_icon</code> prop, with the shape <code>{"type":"object","$ref":"json-schema-definitions://experience_builder.module/shoe-icon"}</code>.', + 'Experience Builder does not know of a field type/widget to allow populating the <code>collapse_icon</code> prop, with the shape <code>{"type":"object","$ref":"json-schema-definitions://experience_builder.module/shoe-icon"}</code>.', + ], ], 'sdc.experience_builder.my-hero' => [ 'compatible' => TRUE, @@ -347,7 +350,7 @@ class ComponentTest extends KernelTestBase { ], 'sdc.sdc_test.array-to-object' => [ 'compatible' => FALSE, - 'reason' => 'Experience Builder does not know of a field type/widget to allow populating the <code>testProp</code> prop, with the shape <code>{"type":"object"}</code>.', + 'reasons' => ['Experience Builder does not know of a field type/widget to allow populating the <code>testProp</code> prop, with the shape <code>{"type":"object"}</code>.'], ], 'sdc.sdc_test.my-button' => [ 'compatible' => TRUE, @@ -385,7 +388,7 @@ class ComponentTest extends KernelTestBase { ], 'sdc.xb_test_sdc.image-required-without-example' => [ 'compatible' => FALSE, - 'reason' => 'Prop "image" is required, but does not have example value', + 'reasons' => ['Prop "image" is required, but does not have example value'], ], 'sdc.xb_test_sdc.props-no-slots' => [ 'compatible' => TRUE, @@ -395,11 +398,11 @@ class ComponentTest extends KernelTestBase { ], 'sdc.xb_test_sdc.props-no-title' => [ 'compatible' => FALSE, - 'reason' => 'Prop "heading" must have title', + 'reasons' => ['Prop "heading" must have title'], ], 'sdc.xb_test_sdc.props-no-examples' => [ 'compatible' => FALSE, - 'reason' => 'Prop "heading" is required, but does not have example value', + 'reasons' => ['Prop "heading" is required, but does not have example value'], ], ], 'classes' => [ @@ -424,7 +427,7 @@ class ComponentTest extends KernelTestBase { ], 'sdc.xb_test_sdc.image-required-without-example' => [ 'compatible' => FALSE, - 'reason' => 'Prop "image" is required, but does not have example value', + 'reasons' => ['Prop "image" is required, but does not have example value'], ], 'sdc.xb_test_sdc.props-no-slots' => [ 'compatible' => TRUE, @@ -434,11 +437,11 @@ class ComponentTest extends KernelTestBase { ], 'sdc.xb_test_sdc.props-no-title' => [ 'compatible' => FALSE, - 'reason' => 'Prop "heading" must have title', + 'reasons' => ['Prop "heading" must have title'], ], 'sdc.xb_test_sdc.props-no-examples' => [ 'compatible' => FALSE, - 'reason' => 'Prop "heading" is required, but does not have example value', + 'reasons' => ['Prop "heading" is required, but does not have example value'], ], ], 'classes' => [