diff --git a/config/schema/experience_builder.schema.yml b/config/schema/experience_builder.schema.yml index f25ec15bf15fc4d4e2389c18cb75f75f7cfdffa7..19722e2bc5917aadd9fef2d217b1a15cb2f051f2 100644 --- a/config/schema/experience_builder.schema.yml +++ b/config/schema/experience_builder.schema.yml @@ -524,6 +524,9 @@ experience_builder.content_template.*.*.*: constraints: # @todo Use `ConfigExists` once https://www.drupal.org/project/drupal/issues/3518273 is # fixed in core. + # @todo Core entity types ship with a `full` view mode, but it's not guaranteed to + # exist for every content entity type. Ensure XB creates it when opting a content + # entity bundle into XB rendering in https://www.drupal.org/i/3518248. BetterConfigExists: prefix: 'core.entity_view_mode.[%parent.content_entity_type_id].' component_tree: @@ -547,3 +550,51 @@ experience_builder.content_template.*.*.*: - Drupal\Core\Block\TitleBlockPluginInterface - Drupal\Core\Block\MessagesBlockPluginInterface presence: ~ + exposed_slots: + type: sequence + # This is ordered by key because the keys are the machine names assigned to the + # exposed slots. These machine names are, essentially, "aliases" for a particular + # slot in a particular component in the component tree. + orderby: key + # If this sequence is empty, the site builder that crafted this template has not + # exposed any slots to content creators, which means there's nothing for content + # creators to do other than enter values for the content entity form (under the + # "Page Data" tab in the designs). + sequence: + type: mapping + constraints: + # Exposed slots are only supported by the "canonical" view mode, which is generally + # assumed to be `full` (see \Drupal\Core\Entity\Controller\EntityViewController::view() + # for where core makes this assumption). Example: + # + # exposed_slots: + # profile_bio: + # label: 'Profile Bio!' + # component_uuid: 28bcab26-e434-4ad4-9eaf-0520bdb32fcc + # slot_name: column_two + # intro: + # label: 'Intro' + # component_uuid: 98bcab26-e434-4ad4-9eaf-0520bdb32fcc + # slot_name: body + ValidExposedSlot: full + mapping: + # The UUID of the component that contains the slot being exposed. This CANNOT + # be the same as \Drupal\experience_builder\Plugin\DataType\ComponentTreeStructure::ROOT_UUID, + # because that would mean the entire template could be overridden by an individual + # content entity, which is not an appropriate use of a content template (XB has + # the `Page` content entity type for that). + component_uuid: + type: uuid + label: 'UUID of the component instance that contains the exposed slot' + slot_name: + # @todo Add constraint in https://www.drupal.org/project/experience_builder/issues/3519891 + type: string + label: 'The machine name of the component slot being exposed' + label: + type: required_label + label: 'A human-readable label for the exposed slot' + constraints: + SequenceKeysMatchRegex: + # Note: this is a subset of what slot names are allowed by SDCs: the machine names of XB's exposed content template slots must be simpler. + pattern: '/^[a-z0-9_]+$/' + message: "The %value key is not a valid machine name." diff --git a/src/ComponentSource/ComponentSourceInterface.php b/src/ComponentSource/ComponentSourceInterface.php index 15b9a0982e343c5555419fb3bc9078ee54169f5d..b2fb824699866ad53c5a9f42ead4259d0b9dae33 100644 --- a/src/ComponentSource/ComponentSourceInterface.php +++ b/src/ComponentSource/ComponentSourceInterface.php @@ -31,11 +31,15 @@ use Symfony\Component\Validator\ConstraintViolationListInterface; * - a block plugin component source — which renders the a block and needs * settings for the block plugin * + * Not all component sources support slots. A source that supports slots should + * implement \Drupal\experience_builder\ComponentSource\ComponentSourceWithSlotsInterface. + * * @phpstan-import-type PropSourceArray from \Drupal\experience_builder\PropSource\PropSourceBase * * @see \Drupal\experience_builder\Attribute\ComponentSource * @see \Drupal\experience_builder\ComponentSource\ComponentSourceBase * @see \Drupal\experience_builder\ComponentSource\ComponentSourceManager + * @see \Drupal\experience_builder\ComponentSource\ComponentSourceWithSlotsInterface */ interface ComponentSourceInterface extends PluginInspectionInterface, DerivativeInspectionInterface, ConfigurableInterface, PluginFormInterface, DependentPluginInterface, ContextAwarePluginInterface { diff --git a/src/Entity/ContentTemplate.php b/src/Entity/ContentTemplate.php index 2672b4beb7934d65388b2bf4e66000dae9fcbc4e..f3b6bf85ef8c70c4bab5a2128e0ff2036ed2ff9b 100644 --- a/src/Entity/ContentTemplate.php +++ b/src/Entity/ContentTemplate.php @@ -53,6 +53,7 @@ use Drupal\experience_builder\Plugin\Field\FieldType\ComponentTreeItemInstantiat 'content_entity_type_bundle', 'content_entity_type_view_mode', 'component_tree', + 'exposed_slots', ], )] final class ContentTemplate extends ConfigEntityBase implements ComponentTreeEntityInterface, EntityViewDisplayInterface { @@ -68,7 +69,7 @@ final class ContentTemplate extends ConfigEntityBase implements ComponentTreeEnt * * @see \Drupal\experience_builder\Plugin\Validation\Constraint\StringPartsConstraint */ - protected string $id; + protected ?string $id; /** * Entity type to be displayed. @@ -98,6 +99,13 @@ final class ContentTemplate extends ConfigEntityBase implements ComponentTreeEnt */ protected ?array $component_tree; + /** + * The exposed slots. + * + * @var ?array<string, array{'component_uuid': string, 'slot_name': string, 'label': string}> + */ + protected ?array $exposed_slots = []; + /** * Tries to load a template for a particular entity, in a specific view mode. * diff --git a/src/Plugin/Validation/Constraint/SequenceKeysMatchRegexConstraint.php b/src/Plugin/Validation/Constraint/SequenceKeysMatchRegexConstraint.php new file mode 100644 index 0000000000000000000000000000000000000000..8004db2ee041d1a7b0bd7208da429e38e4bf80de --- /dev/null +++ b/src/Plugin/Validation/Constraint/SequenceKeysMatchRegexConstraint.php @@ -0,0 +1,19 @@ +<?php + +declare(strict_types=1); + +namespace Drupal\experience_builder\Plugin\Validation\Constraint; + +use Drupal\Core\StringTranslation\TranslatableMarkup; +use Drupal\Core\Validation\Attribute\Constraint; +use Symfony\Component\Validator\Constraints\Regex; + +#[Constraint( + id: self::PLUGIN_ID, + label: new TranslatableMarkup('Sequence keys match regex', options: ['context' => 'Validation']), +)] +final class SequenceKeysMatchRegexConstraint extends Regex { + + public const string PLUGIN_ID = 'SequenceKeysMatchRegex'; + +} diff --git a/src/Plugin/Validation/Constraint/SequenceKeysMatchRegexConstraintValidator.php b/src/Plugin/Validation/Constraint/SequenceKeysMatchRegexConstraintValidator.php new file mode 100644 index 0000000000000000000000000000000000000000..07f4b173798df74649f1c9e2b3299a05f6c50916 --- /dev/null +++ b/src/Plugin/Validation/Constraint/SequenceKeysMatchRegexConstraintValidator.php @@ -0,0 +1,36 @@ +<?php + +declare(strict_types=1); + +namespace Drupal\experience_builder\Plugin\Validation\Constraint; + +use Symfony\Component\Validator\Constraint; +use Symfony\Component\Validator\Constraints\RegexValidator; +use Symfony\Component\Validator\Exception\UnexpectedTypeException; + +/** + * Validates the SequenceKeysMatchRegex constraint. + */ +final class SequenceKeysMatchRegexConstraintValidator extends RegexValidator { + + /** + * {@inheritdoc} + */ + public function validate(mixed $value, Constraint $constraint): void { + // If the value isn't NULL, it needs to be an associative array. + if ($value === NULL) { + return; + } + if (!is_array($value)) { + throw new UnexpectedTypeException($value, 'array'); + } + if ($value && array_is_list($value)) { + throw new UnexpectedTypeException($value, 'associative array'); + } + + foreach (array_keys($value) as $key) { + parent::validate($key, $constraint); + } + } + +} diff --git a/src/Plugin/Validation/Constraint/ValidExposedSlotConstraint.php b/src/Plugin/Validation/Constraint/ValidExposedSlotConstraint.php new file mode 100644 index 0000000000000000000000000000000000000000..a71b7e7682b2999a33395c13554ea6620b109ee0 --- /dev/null +++ b/src/Plugin/Validation/Constraint/ValidExposedSlotConstraint.php @@ -0,0 +1,43 @@ +<?php + +declare(strict_types=1); + +namespace Drupal\experience_builder\Plugin\Validation\Constraint; + +use Drupal\Core\StringTranslation\TranslatableMarkup; +use Drupal\Core\Validation\Attribute\Constraint; +use Symfony\Component\Validator\Constraint as SymfonyConstraint; + +#[Constraint( + id: self::PLUGIN_ID, + label: new TranslatableMarkup('Validates an exposed slot', [], ['context' => 'Validation']), +)] +final class ValidExposedSlotConstraint extends SymfonyConstraint { + + public const string PLUGIN_ID = 'ValidExposedSlot'; + + /** + * The view mode in which the exposed slots will be used. + * + * @var string + */ + public string $viewMode = 'full'; + + public string $rootExposedMessage = 'Exposing the full component tree is not allowed.'; + + public string $unknownComponentMessage = 'The component %id does not exist in the tree.'; + + public string $slotNotEmptyMessage = 'The %slot slot must be empty.'; + + public string $undefinedSlotMessage = 'The component %id does not have a %slot slot.'; + + public string $viewModeMismatchMessage = 'Exposed slots are only allowed in the %mode view mode.'; + + /** + * {@inheritdoc} + */ + public function getDefaultOption(): string { + return 'viewMode'; + } + +} diff --git a/src/Plugin/Validation/Constraint/ValidExposedSlotConstraintValidator.php b/src/Plugin/Validation/Constraint/ValidExposedSlotConstraintValidator.php new file mode 100644 index 0000000000000000000000000000000000000000..355859e6b09b555035fbadc9da24ff2c36f609ed --- /dev/null +++ b/src/Plugin/Validation/Constraint/ValidExposedSlotConstraintValidator.php @@ -0,0 +1,79 @@ +<?php + +declare(strict_types=1); + +namespace Drupal\experience_builder\Plugin\Validation\Constraint; + +use Drupal\experience_builder\ComponentSource\ComponentSourceWithSlotsInterface; +use Drupal\experience_builder\Entity\ContentTemplate; +use Drupal\experience_builder\Plugin\DataType\ComponentTreeStructure; +use Symfony\Component\Validator\Constraint; +use Symfony\Component\Validator\ConstraintValidator; +use Symfony\Component\Validator\Exception\UnexpectedTypeException; + +/** + * Validates the `ValidExposedSlot` constraint. + */ +final class ValidExposedSlotConstraintValidator extends ConstraintValidator { + + /** + * {@inheritdoc} + */ + public function validate(mixed $value, Constraint $constraint): void { + assert($constraint instanceof ValidExposedSlotConstraint); + + assert(is_array($value), new UnexpectedTypeException($value, 'array')); + + // The root UUID (i.e., the entire component tree) cannot be exposed. + if ($value['component_uuid'] === ComponentTreeStructure::ROOT_UUID) { + $this->context->addViolation($constraint->rootExposedMessage); + return; + } + + $root = $this->context->getRoot()->getEntity(); + assert($root instanceof ContentTemplate); + /** @var \Drupal\experience_builder\Plugin\DataType\ComponentTreeStructure $tree */ + $tree = $root->getComponentTree()->get('tree'); + + $slot_exists = FALSE; + try { + $source = $tree->getComponentSource($value['component_uuid']); + if ($source instanceof ComponentSourceWithSlotsInterface) { + $slot_exists = array_key_exists($value['slot_name'], $source->getSlotDefinitions()); + } + } + catch (\OutOfRangeException) { + // The component that contains the exposed slot isn't in the tree at all, + // so there's nothing else for us to do. + $this->context->addViolation($constraint->unknownComponentMessage, [ + '%id' => $value['component_uuid'], + ]); + return; + } + + // The component has to actually define the slot being exposed. + if ($slot_exists === FALSE) { + $this->context->addViolation($constraint->undefinedSlotMessage, [ + '%id' => $value['component_uuid'], + '%slot' => $value['slot_name'], + ]); + } + + // The exposed slot has to be empty. + foreach ($tree->getSlotChildrenDepthFirst() as $parent_uuid => $child) { + if ($parent_uuid === $value['component_uuid']) { + $this->context->addViolation($constraint->slotNotEmptyMessage, [ + '%slot' => $child['slot'], + ]); + break; + } + } + + if ($root->getMode() !== $constraint->viewMode) { + $this->context->addViolation($constraint->viewModeMismatchMessage, [ + '%mode' => $constraint->viewMode, + ]); + } + } + +} diff --git a/tests/src/Kernel/Config/ContentTemplateValidationTest.php b/tests/src/Kernel/Config/ContentTemplateValidationTest.php index 48736f4f664b33310cbf22115d31cc359138f29b..f754e9784197e04c7c71082b6b12cd94c8a6ae1b 100644 --- a/tests/src/Kernel/Config/ContentTemplateValidationTest.php +++ b/tests/src/Kernel/Config/ContentTemplateValidationTest.php @@ -94,6 +94,8 @@ final class ContentTemplateValidationTest extends BetterConfigEntityValidationTe ['uuid' => 'sdc-dynamic-bundle-field', 'component' => 'sdc.xb_test_sdc.props-no-slots'], // A block component. ['uuid' => 'block', 'component' => 'block.system_branding_block'], + // An SDC with a slot that can be exposed. + ['uuid' => 'b4937e35-ddc2-4f36-8d4c-b1cc14aaefef', 'component' => 'sdc.xb_test_sdc.props-slots'], ], ]), 'inputs' => self::encodeXBData([ @@ -123,6 +125,13 @@ final class ContentTemplateValidationTest extends BetterConfigEntityValidationTe 'use_site_name' => TRUE, 'use_site_slogan' => TRUE, ], + 'b4937e35-ddc2-4f36-8d4c-b1cc14aaefef' => [ + 'heading' => [ + 'sourceType' => 'static:field_item:string', + 'value' => 'There be a slot here', + 'expression' => 'ℹ︎string␟value', + ], + ], ]), ], ]); @@ -148,6 +157,7 @@ final class ContentTemplateValidationTest extends BetterConfigEntityValidationTe 'experience_builder.component.js.my-cta', 'experience_builder.component.sdc.sdc_test.my-cta', 'experience_builder.component.sdc.xb_test_sdc.props-no-slots', + 'experience_builder.component.sdc.xb_test_sdc.props-slots', ], ], $this->entity->getDependencies() @@ -159,6 +169,7 @@ final class ContentTemplateValidationTest extends BetterConfigEntityValidationTe 'experience_builder.component.js.my-cta', 'experience_builder.component.sdc.sdc_test.my-cta', 'experience_builder.component.sdc.xb_test_sdc.props-no-slots', + 'experience_builder.component.sdc.xb_test_sdc.props-slots', 'experience_builder.js_component.my-cta', ], 'module' => [ @@ -357,4 +368,114 @@ final class ContentTemplateValidationTest extends BetterConfigEntityValidationTe ]); } + public function testExposedSlotMustBeEmpty(): void { + assert($this->entity instanceof ContentTemplate); + + // Add a component in one of the open slots. + $item = $this->entity->getComponentTree(); + $tree = json_decode($item->get('tree')->getValue(), TRUE); + $tree['b4937e35-ddc2-4f36-8d4c-b1cc14aaefef']['the_footer'][] = [ + 'uuid' => 'greeting', + 'component' => 'sdc.xb_test_sdc.props-no-slots', + ]; + $inputs = json_decode($item->get('inputs')->getValue(), TRUE); + $inputs['greeting']['heading'] = [ + 'sourceType' => 'dynamic', + 'expression' => 'ℹ︎␜entity:node:alpha␝title␞␟value', + ]; + $this->entity->set('component_tree', [ + 'tree' => self::encodeXBData($tree), + 'inputs' => self::encodeXBData($inputs), + ]); + + $this->entity->set('exposed_slots', [ + 'filled_footer' => [ + 'component_uuid' => 'b4937e35-ddc2-4f36-8d4c-b1cc14aaefef', + 'slot_name' => 'the_footer', + 'label' => "Something's already here!", + ], + ]); + $this->assertValidationErrors([ + 'exposed_slots.filled_footer' => 'The <em class="placeholder">the_footer</em> slot must be empty.', + ]); + } + + public static function providerInvalidExposedSlot(): iterable { + yield 'root uuid is exposed' => [ + [ + 'not_allowed' => [ + 'component_uuid' => ComponentTreeStructure::ROOT_UUID, + 'slot_name' => 'not-a-thing', + 'label' => "This won't work", + ], + ], + [ + 'exposed_slots.not_allowed' => 'Exposing the full component tree is not allowed.', + ], + ]; + + yield 'component exposing the slot does not exist in the tree' => [ + [ + 'not_a_thing' => [ + 'component_uuid' => '6348ee20-cf62-49e3-bc86-cf62abc09c74', + 'slot_name' => 'not-a-thing', + 'label' => "Can't expose a slot in a component we don't have!", + ], + ], + [ + 'exposed_slots.not_a_thing' => 'The component <em class="placeholder">6348ee20-cf62-49e3-bc86-cf62abc09c74</em> does not exist in the tree.', + ], + ]; + + yield 'exposed slot is not defined by the component' => [ + [ + 'filled_footer' => [ + 'component_uuid' => 'b4937e35-ddc2-4f36-8d4c-b1cc14aaefef', + 'slot_name' => 'not_a_real_slot', + 'label' => "Whither this slot you speak of?", + ], + ], + [ + 'exposed_slots.filled_footer' => 'The component <em class="placeholder">b4937e35-ddc2-4f36-8d4c-b1cc14aaefef</em> does not have a <em class="placeholder">not_a_real_slot</em> slot.', + ], + ]; + + yield 'exposed slot machine name is not valid' => [ + [ + 'not a valid exposed slot name' => [ + 'component_uuid' => 'b4937e35-ddc2-4f36-8d4c-b1cc14aaefef', + 'slot_name' => 'the_footer', + 'label' => "I got your footer right here", + ], + ], + [ + 'exposed_slots' => 'The <em class="placeholder">"not a valid exposed slot name"</em> key is not a valid machine name.', + ], + ]; + } + + /** + * @dataProvider providerInvalidExposedSlot + */ + public function testInvalidExposedSlot(array $exposed_slots, array $expected_errors): void { + $this->entity->set('exposed_slots', $exposed_slots); + $this->assertValidationErrors($expected_errors); + } + + public function testExposedSlotsOnlyAllowedInFullViewMode(): void { + $this->entity = $this->entity->createDuplicate(); + $this->entity->set('content_entity_type_view_mode', 'teaser'); + $this->entity->set('id', 'node.alpha.teaser'); + $this->entity->set('exposed_slots', [ + 'footer_for_you' => [ + 'component_uuid' => 'b4937e35-ddc2-4f36-8d4c-b1cc14aaefef', + 'slot_name' => 'the_footer', + 'label' => "I got your footer right here", + ], + ]); + $this->assertValidationErrors([ + 'exposed_slots.footer_for_you' => 'Exposed slots are only allowed in the <em class="placeholder">full</em> view mode.', + ]); + } + }