diff --git a/openapi.yml b/openapi.yml index 7ad22f7aa276558edf5a96f8325e2110a5583fae..536959d5def5c543ade3c1a1faaf8aa8288141df 100644 --- a/openapi.yml +++ b/openapi.yml @@ -1,3 +1,6 @@ +#file: noinspection YAMLSchemaValidation +# ^ To suppress false positive validation errors in PhpStorm. + # For readability and to prevent needless merge conflicts, follow all possible # conventions in the official OpenAPI specification and documentation, including # the order of fixed fields like paths and components, spacing, and quoting of @@ -18,14 +21,19 @@ # any. openapi: 3.1.0 + info: title: Experience Builder description: API Spec for Experience Builder version: 0.x +# @see https://spec.openapis.org/oas/v3.1.0.html#paths-object +# @see https://spec.openapis.org/oas/v3.1.0.html#path-item-object paths: '/api/layout/{entityTypeId}/{entityId}': + description: TODO get: + description: TODO parameters: - $ref: '#/components/parameters/entityTypeId' - $ref: '#/components/parameters/entityId' @@ -68,7 +76,7 @@ paths: 'created[0][value][date]': '2024-12-18' 'created[0][value][time]': '00:18:25' 'field_hero[0][target_id]': '1' - 'field_hero[0][alt]': 'A man walks a border collie along a beach.' + 'field_hero[0][alt]': A man walks a border collie along a beach. 'field_hero[0][title]': '' 'field_hero[0][width]': '207' 'field_hero[0][height]': '475' @@ -87,8 +95,8 @@ paths: 'promote[value]': '1' 'status[value]': '1' 'sticky[value]': '0' - 'title[0][value]': 'XB Needs This For The Time Being' - 'uid[0][target_id]': 'Anonymous (0)' + 'title[0][value]': XB Needs This For The Time Being + 'uid[0][target_id]': Anonymous (0) 'langcode[0][value]': en 'revision_log[0][value]': '' schema: @@ -111,7 +119,9 @@ paths: type: object description: The full entity data. '/api/preview/{entityTypeId}/{entityId}': + description: TODO post: + description: TODO parameters: - $ref: '#/components/parameters/entityTypeId' - $ref: '#/components/parameters/entityId' @@ -131,7 +141,9 @@ paths: type: string description: The HTML preview. '/xb/api/content-update/{entityTypeId}/{entityId}': + description: TODO patch: + description: TODO parameters: - $ref: '#/components/parameters/entityTypeId' - $ref: '#/components/parameters/entityId' @@ -153,7 +165,9 @@ paths: items: type: string /xb-components: + description: TODO get: + description: TODO responses: '200': description: All available components @@ -222,10 +236,12 @@ paths: '^\\[a-zA-Z0-9_-]:[a-zA-Z0-9_-]$': $ref: '#/components/schemas/Component' '/xb-field-form/{entityTypeId}/{entityId}': + description: TODO parameters: - $ref: '#/components/parameters/entityTypeId' - $ref: '#/components/parameters/entityId' get: + description: TODO responses: '200': description: Component props form @@ -258,6 +274,7 @@ paths: Stringified settings info structured for use by `settings` AJAX command post: + description: TODO parameters: # @see \Drupal\Core\Form\FormBuilderInterface::AJAX_FORM_REQUEST # @see \Drupal\Core\Form\EventSubscriber\FormAjaxSubscriber @@ -303,8 +320,9 @@ paths: '200': $ref: '#/components/responses/FormResponse' '/xb/api/entity-form/{entityTypeId}/{entityId}': - description: Fetches the entity form with the "default" form mode + description: TODO get: + description: Fetches the entity form with the "default" form mode parameters: - $ref: '#/components/parameters/entityTypeId' - $ref: '#/components/parameters/entityId' @@ -312,7 +330,9 @@ paths: '200': $ref: '#/components/responses/FormResponse' /xb/api/log-error: + description: TODO post: + description: TODO requestBody: description: Log an error message with a specified log level. content: @@ -348,7 +368,8 @@ paths: properties: status: type: string - example: Error logged successfully + examples: + - Error logged successfully description: The success message returned after logging the error. '400': description: Invalid request payload @@ -361,7 +382,9 @@ paths: type: string description: Error description /xb/api/publish-all: + description: TODO post: + description: TODO requestBody: content: application/json: @@ -378,7 +401,8 @@ paths: message: type: string title: Success message - example: Successfully published 3 items + examples: + - Successfully published 3 items '204': description: No items to publish content: @@ -389,7 +413,8 @@ paths: message: type: string title: Status message - example: No items to publish + examples: + - No items to publish '422': description: Validation errors exist content: @@ -405,7 +430,9 @@ paths: schema: $ref: '#/components/schemas/Error' /xb/api/autosave: + description: TODO get: + description: TODO responses: '200': description: All current auto-save entries @@ -456,8 +483,120 @@ paths: owner: 1 schema: $ref: '#/components/schemas/AutoSaveCollection' + /xb/api/config/pattern: + description: TODO + get: + description: TODO + responses: + '200': + description: Collection of Sections/Patterns + content: + application/json: + schema: + type: object + minProperties: 0 + patternProperties: + '^\\[a-zA-Z0-9_-]$': + $ref: '#/components/schemas/PatternPreview' + post: + description: TODO + requestBody: + description: Create new Pattern/Section. + content: + application/json: + schema: + type: object + properties: + name: + type: string + description: Human-readable Pattern/Section label + layout: + type: array + items: + $ref: '#/components/schemas/LayoutComponent' + model: + $ref: '#/components/schemas/Model' + responses: + '201': + description: Successful save of Section/Pattern + content: + application/json: + examples: + mySectionPattern: + value: + layoutModel: + layout: + - uuid: 0b755464-281c-4487-82a0-ece34a9b1121 + nodeType: component + type: sdc.experience_builder.my-section + slots: [] + model: + 0b755464-281c-4487-82a0-ece34a9b1121: + text: >- + Our mission is to deliver the best products and + services to our customers. We strive to exceed + expectations and continuously improve our offerings. + name: Section + id: section + default_markup: >- + <!-- xb-start-0b755464-281c-4487-82a0-ece34a9b1121 --><div + class="my-section__wrapper"> + <div class="my-section__content-wrapper"> + <h2 class="my-section__h2">Our Mission</h2> + <p class="my-section__paragraph"> + <!-- xb-prop-start-text -->Our mission is to deliver the best products and services to our customers. We strive to exceed expectations and continuously improve our offerings.<!-- xb-prop-end-text --> + </p> + <p class="my-section__paragraph"> + Join us on our journey to innovation and excellence. Your satisfaction is our priority. + </p> + </div> + <div class="my-section__image-wrapper"> + <img alt="Placeholder Image" class="my-section__img" width="500" height="500"> + </div> + </div> <!-- xb-end-0b755464-281c-4487-82a0-ece34a9b1121 + --> + css: > + <link rel="stylesheet" media="all" + href="/sites/default/files/css/css.css" /> + js_header: '' + js_footer: '' + schema: + $ref: '#/components/schemas/PatternPreview' + '/xb/api/config/pattern/{configEntityId}': + description: TODO + get: + description: TODO + parameters: + - $ref: '#/components/parameters/configEntityId' + responses: + '200': + description: Individual Section/Pattern + content: + application/json: + schema: + $ref: '#/components/schemas/PatternPreview' + patch: + description: TODO + requestBody: + description: Update specific Pattern/Section. + required: true + content: + application/json: + schema: + $ref: '#/components/schemas/Pattern' + parameters: + - $ref: '#/components/parameters/configEntityId' + responses: + '200': + description: Individual Section/Pattern + content: + application/json: + schema: + $ref: '#/components/schemas/PatternPreview' +# @see https://spec.openapis.org/oas/v3.1.0.html#components-object components: + # @see https://spec.openapis.org/oas/v3.1.0.html#schema-object schemas: Component: title: Component @@ -522,7 +661,8 @@ components: properties: nodeType: type: string - enum: [component] + enum: + - component description: The layout node type uuid: type: string @@ -548,7 +688,9 @@ components: properties: nodeType: type: string - enum: [region, slot] + enum: + - region + - slot description: The layout node type id: type: string @@ -589,20 +731,24 @@ components: title: Owner details description: Details of the last user to save the entry examples: - Logged in user: - name: 'Garrett Bobby Ferguson, Jr' - avatar: /sites/default/files/user-pictures/garrett.jpg - uri: /user/12 - id: 12 + - Logged in user: + name: 'Garrett Bobby Ferguson, Jr' + avatar: /sites/default/files/user-pictures/garrett.jpg + uri: /user/12 + id: 12 properties: name: type: string title: User's name avatar: type: string + format: uri + nullable: true title: URL to user's avatar picture uri: type: string + format: uri + nullable: true title: URL to user's profile page id: type: integer @@ -611,95 +757,165 @@ components: type: integer title: Updated timestamp description: Unix timestamp for auto-save entry last updated time - example: 1732763679 + examples: + - 1732763679 entity_type: type: string title: Entity Type ID - example: node + examples: + - node langcode: type: string title: Language code - example: en + nullable: true + examples: + - en data_hash: type: string title: Hash of the item's data - example: 4711efecf75d2d1a + examples: + - 4711efecf75d2d1a entity_id: type: - string - integer title: Entity ID description: ID of the entity - example: 3 + examples: + - 3 label: type: string title: Entity label description: Label of the entity - example: Home page + examples: + - Home page AutoSaveCollection: - schema: - type: object - # @todo Add key validation for the format in {entity_type}:{entity_id}:{langcode} https://drupal.org/i/3471064. - additionalProperties: - $ref: '#/components/schemas/AutoSaveEntry' + type: object + # @todo Add key validation for the format in {entity_type}:{entity_id}:{langcode} https://drupal.org/i/3471064. + additionalProperties: + $ref: '#/components/schemas/AutoSaveEntry' PublishAllRequest: - schema: + type: object + # @todo Add key validation for the format in {entity_type}:{entity_id}:{langcode} https://drupal.org/i/3471064. + additionalProperties: type: object - # @todo Add key validation for the format in {entity_type}:{entity_id}:{langcode} https://drupal.org/i/3471064. - additionalProperties: + title: Entries to publish + description: > + The body should be an object with keys that match unpublished + entries and a data-hash property for each item. + properties: + data_hash: + type: string + title: Expected hash for this item + Error: + type: object + required: + - detail + - source + properties: + detail: + type: string + title: Error description + examples: + - I can't let you do that Dave + source: type: object - title: Entries to publish - description: > - The body should be an object with keys that match unpublished - entries and a data-hash property for each item. + required: + - pointer properties: - data_hash: + pointer: type: string - title: Expected hash for this item - Error: - schema: - type: object - required: - - detail - - source - properties: - detail: - type: string - title: Error description - example: I can't let you do that Dave - source: - type: object - required: - - pointer - properties: - pointer: - type: string - title: Pointer/path to source of error - example: title.0.value - code: - type: int - title: Error code - example: 1 - meta: - type: object - properties: - entity_type: - type: string - example: node - title: Entity Type ID - entity_id: - type: - - string - - integer - example: 3 - title: Entity ID - label: - type: string - example: Spring sale now on - title: Item label + title: Pointer/path to source of error + examples: + - title.0.value + code: + type: integer + title: Error code + examples: + - 1 + meta: + type: object + properties: + entity_type: + type: string + examples: + - node + title: Entity Type ID + entity_id: + type: + - string + - integer + examples: + - 3 + title: Entity ID + label: + type: string + examples: + - Spring sale now on + title: Item label Model: title: model type: object + PatternPreview: + title: 'Pattern with generated ID and preview metadata (default markup + CSS + JS)' + description: '@see \Drupal\experience_builder\Controller\ApiConfigControllers::normalizePattern()' + type: object + required: + - layoutModel + - name + - id + - default_markup + - css + - js_header + - js_footer + additionalProperties: false + properties: + layoutModel: + type: object + properties: + layout: + type: array + items: + $ref: '#/components/schemas/LayoutComponent' + minItems: 1 + model: + $ref: '#/components/schemas/Model' + additionalProperties: false + name: + type: string + description: Human-readable Pattern/Section label + id: + type: string + description: The ID that was auto-generated from the given name. + default_markup: + type: string + css: + type: string + js_header: + type: string + js_footer: + type: string + Pattern: + title: Pattern + description: '@see \Drupal\experience_builder\Controller\ApiConfigControllers::denormalizePattern()' + type: object + required: + - name + - layout + - model + properties: + name: + type: string + description: Human-readable Pattern/Section label + layout: + type: array + items: + $ref: '#/components/schemas/LayoutComponent' + minItems: 1 + model: + $ref: '#/components/schemas/Model' + additionalProperties: false + # @see https://spec.openapis.org/oas/v3.1.0.html#response-object responses: FormResponse: description: The form @@ -715,8 +931,8 @@ components: - html properties: html: - schema: - type: string + type: string + # @see https://spec.openapis.org/oas/v3.1.0.html#parameter-object parameters: entityTypeId: name: entityTypeId @@ -736,6 +952,17 @@ components: examples: arbitraryEntityId: value: 42 + configEntityId: + name: configEntityId + in: path + required: true + description: Config entity ID + schema: + type: string + examples: + arbitraryEntityId: + value: section_fg4n3m0b + # @see https://spec.openapis.org/oas/v3.1.0.html#header-object requestBodies: Layout: content: diff --git a/phpstan.neon b/phpstan.neon index 45a938ba3b6758f86c57f9ca96065b4d581ff2c2..0e2e9ff7c02f2334d7cbb8f988ccbacc85d705ff 100644 --- a/phpstan.neon +++ b/phpstan.neon @@ -45,3 +45,7 @@ parameters: message: "#^Static property Drupal\\\\Tests\\\\experience_builder\\\\Kernel\\\\Config\\\\PatternValidationTest\\:\\:\\$propertiesWithRequiredKeys \\(array<string>\\) does not accept default value of type array<string, array<int, string>>\\.$#" count: 1 path: tests/src/Kernel/Config/PatternValidationTest.php + - + message: "#^Method Drupal\\\\experience_builder\\\\Entity\\\\Pattern\\:\\:preCreate\\(\\) has no return type specified\\.$#" + count: 1 + path: src/Entity/Pattern.php diff --git a/src/Controller/ApiConfigControllers.php b/src/Controller/ApiConfigControllers.php index 0f533dbb9d1aef2afe5dd6feea6c6e3845262801..eb7d35efd713737f8526030a4a17c477cfb1d9d4 100644 --- a/src/Controller/ApiConfigControllers.php +++ b/src/Controller/ApiConfigControllers.php @@ -4,16 +4,22 @@ declare(strict_types=1); namespace Drupal\experience_builder\Controller; -use Drupal\Component\Assertion\Inspector; +use Drupal\Core\Asset\AttachedAssets; use Drupal\Core\Cache\CacheableJsonResponse; use Drupal\Core\Cache\CacheableMetadata; use Drupal\Core\Config\Entity\ConfigEntityTypeInterface; use Drupal\Core\Entity\EntityTypeInterface; use Drupal\Core\Entity\EntityTypeManagerInterface; -use Drupal\Core\GeneratedUrl; +use Drupal\Core\Render\RendererInterface; use Drupal\Core\Url; +use Drupal\experience_builder\AssetRenderer; +use Drupal\experience_builder\Entity\Pattern; use Drupal\experience_builder\Entity\XbHttpApiEligibleConfigEntityInterface; use Drupal\experience_builder\Exception\ConstraintViolationException; +use Drupal\experience_builder\Plugin\DataType\ComponentTreeHydrated; +use Drupal\experience_builder\Plugin\DataType\ComponentTreeStructure; +use Drupal\experience_builder\Plugin\Field\FieldType\ComponentTreeItem; +use Drupal\experience_builder\Plugin\Field\FieldType\ComponentTreeItemInstantiatorTrait; use Symfony\Component\HttpFoundation\JsonResponse; use Symfony\Component\HttpFoundation\Request; use Symfony\Component\HttpKernel\Exception\BadRequestHttpException; @@ -29,8 +35,13 @@ use Symfony\Component\HttpKernel\Exception\UnprocessableEntityHttpException; */ final class ApiConfigControllers extends ApiControllerBase { + use ClientServerConversionTrait; + use ComponentTreeItemInstantiatorTrait; + public function __construct( private readonly EntityTypeManagerInterface $entityTypeManager, + private readonly RendererInterface $renderer, + private readonly AssetRenderer $assetRenderer, ) {} /** @@ -53,38 +64,19 @@ final class ApiConfigControllers extends ApiControllerBase { ->addCacheTags($xb_config_entity_type->getListCacheTags()); /** @var array<\Drupal\experience_builder\Entity\XbHttpApiEligibleConfigEntityInterface> $config_entities */ $config_entities = $this->entityTypeManager->getStorage($xb_config_entity_type_id)->loadMultiple(); + $normalizations = []; + foreach ($config_entities as $key => &$entity) { + $normalizations[$key] = $this->normalize($entity); + } - $normalizations = self::normalizeConfigEntities($xb_config_entity_type, $config_entities); - - // Ensure each normalized config entity is identical to the response at the - // "individual" config entity XB HTTP API route, but point to it. - $individual_urls = array_map( - fn (string $entity) => Url::fromRoute('experience_builder.api.config.get', [ - 'xb_config_entity_type_id' => $xb_config_entity_type_id, - 'xb_config_entity' => $entity, - ]) - ->toString(TRUE), - array_keys($normalizations), - ); - $urls_cacheability = new CacheableMetadata(); - array_reduce( - $individual_urls, - fn (CacheableMetadata $cacheability, GeneratedUrl $url) => $cacheability->addCacheableDependency($url), - $urls_cacheability - ); - - return (new CacheableJsonResponse(array_combine( - array_map(fn (GeneratedUrl $url): string => $url->getGeneratedUrl(), $individual_urls), - $normalizations, - ))) - ->addCacheableDependency($query_cacheability) - ->addCacheableDependency($urls_cacheability); + return (new CacheableJsonResponse($normalizations)) + ->addCacheableDependency($query_cacheability); } public function get(Request $request, XbHttpApiEligibleConfigEntityInterface $xb_config_entity): CacheableJsonResponse { $xb_config_entity_type = $xb_config_entity->getEntityType(); assert($xb_config_entity_type instanceof ConfigEntityTypeInterface); - $normalization = self::normalizeConfigEntities($xb_config_entity_type, [$xb_config_entity])[0]; + $normalization = $this->normalize($xb_config_entity); return (new CacheableJsonResponse(status: 200, data: $normalization)) ->addCacheableDependency($xb_config_entity); } @@ -96,19 +88,26 @@ final class ApiConfigControllers extends ApiControllerBase { // Decode, then denormalize. $decoded = self::decode($request); - // ⚠️ For now, there's no denormalization. This may change in the future. - $denormalized = $decoded; + $denormalized = $this->denormalize($xb_config_entity_type_id, $decoded); // Create an in-memory config entity and validate it. $xb_config_entity = $this->entityTypeManager ->getStorage($xb_config_entity_type_id) ->create($denormalized); assert($xb_config_entity instanceof XbHttpApiEligibleConfigEntityInterface); - $this->validate($xb_config_entity); + try { + $this->validate($xb_config_entity); + } + catch (ConstraintViolationException $e) { + throw $e->renamePropertyPaths([ + 'component_tree.props' => 'model', + 'component_tree' => 'layout', + ]); + } // Save the XB config entity, respond with a 201. $xb_config_entity->save(); - $normalization = self::normalizeConfigEntities($xb_config_entity_type, [$xb_config_entity])[0]; + $normalization = $this->normalize($xb_config_entity); return new JsonResponse(status: 201, data: $normalization, headers: [ 'Location' => Url::fromRoute( 'experience_builder.api.config.get', @@ -131,20 +130,27 @@ final class ApiConfigControllers extends ApiControllerBase { public function patch(Request $request, XbHttpApiEligibleConfigEntityInterface $xb_config_entity): JsonResponse { // Decode, then denormalize. $decoded = self::decode($request); - // ⚠️ For now, there's no denormalization. This may change in the future. - $denormalized = $decoded; + $denormalized = $this->denormalize($xb_config_entity->getEntityTypeId(), $decoded); // Modify the loaded entity using the denormalized data and validate it. foreach ($denormalized as $property_name => $property_value) { $xb_config_entity->set($property_name, $property_value); } - $this->validate($xb_config_entity); + try { + $this->validate($xb_config_entity); + } + catch (ConstraintViolationException $e) { + throw $e->renamePropertyPaths([ + 'component_tree.props' => 'model', + 'component_tree' => 'layout', + ]); + } // Save the XB config entity, respond with a 200. $xb_config_entity->save(); $xb_config_entity_type = $xb_config_entity->getEntityType(); assert($xb_config_entity_type instanceof ConfigEntityTypeInterface); - $normalization = self::normalizeConfigEntities($xb_config_entity_type, [$xb_config_entity])[0]; + $normalization = $this->normalize($xb_config_entity); return new JsonResponse(status: 200, data: $normalization); } @@ -155,51 +161,6 @@ final class ApiConfigControllers extends ApiControllerBase { } } - /** - * Normalizes all config entities of a given config entity type. - * - * Associates the config entity type's list cache contexts and tags, because - * the given list of config entities is assumed to be the complete list. - * - * @param \Drupal\Core\Config\Entity\ConfigEntityTypeInterface $config_entity_type - * The config entity type whose config entities are being normalized. - * @param \Drupal\experience_builder\Entity\XbHttpApiEligibleConfigEntityInterface[] $config_entities - * All config entities stored for the given config entity type. - * - * @return array - * An array containing the normalization of each config entity, in the same - * order, with the same keys. - */ - private static function normalizeConfigEntities(ConfigEntityTypeInterface $config_entity_type, array $config_entities): array { - assert(Inspector::assertAll(fn ($v) => get_class($v) === $config_entity_type->getClass(), $config_entities)); - // All exportable config entity properties should be present in the - // normalization because they may be edited, with the exception of the - // immutable properties. - $editable_config_entity_properties = array_diff( - $config_entity_type->get('config_export'), - $config_entity_type->getConstraints()['ImmutableProperties'], - ); - - $cacheability = new CacheableMetadata(); - $normalizations = array_map( - // Exclude not only `_core`, but really everything that is not part of the - // explicit export. For example: `dependencies` should not be listed here, - // because it is not a concern for the XB UI to create/edit/delete - // PageTemplate config entities. - // @see \Drupal\serialization\Normalizer\ConfigEntityNormalizer - fn (XbHttpApiEligibleConfigEntityInterface $c) => array_intersect_key( - $c->toArray(), - array_flip($editable_config_entity_properties) - ), - $config_entities - ); - $cacheability - ->addCacheContexts($config_entity_type->getListCacheContexts()) - ->addCacheTags($config_entity_type->getListCacheTags()); - - return $normalizations; - } - /** * Decodes a request whose body contains JSON. * @@ -229,4 +190,121 @@ final class ApiConfigControllers extends ApiControllerBase { return $data; } + private function denormalize(string $xb_config_entity_type_id, array $data): array { + return match ($xb_config_entity_type_id) { + 'pattern' => $this->denormalizePattern($data), + default => $data, + }; + } + + /** + * @todo Move to \Symfony\Component\Serializer\Normalizer\DenormalizerInterface implementation. + */ + private function denormalizePattern(array $data): array { + ['layout' => $layout, 'model' => $model, 'name' => $label] = $data; + ['tree' => $tree, 'props' => $props] = $this->convertClientToServer($layout, $model); + + return [ + 'label' => $label, + 'component_tree' => [ + 'tree' => $tree, + 'props' => $props, + ], + ]; + } + + private function normalize(XbHttpApiEligibleConfigEntityInterface $entity): array { + return match(TRUE) { + $entity instanceof Pattern => $this->normalizePattern($entity), + TRUE => [], + }; + } + + /** + * @see docs/adr/0005-Keep-the-front-end-simple.md + */ + private function normalizePattern(Pattern $pattern): array { + $item = $pattern->getComponentTree(); + assert($item instanceof ComponentTreeItem); + ['layout' => $layout, 'model' => $model] = $this->convertComponentTreeItemToLayoutModel($item); + $build = $pattern->getComponentTree()->toRenderable(); + $default_markup = $this->renderer->renderInIsolation($build); + $assets = AttachedAssets::createFromRenderArray($build); + return [ + 'layoutModel' => [ + 'layout' => $layout, + 'model' => $model, + ], + 'name' => $pattern->label(), + 'id' => $pattern->id(), + // A pre-rendered version of the Pattern is provided so no requests + // are needed when adding it to the layout which includes a default + // markup, CSS files, JS files in the header and JS files in the + // footer. + // @see \Drupal\experience_builder\ComponentSource\ComponentSourceInterface::getClientSideInfo() + 'default_markup' => $default_markup, + 'css' => $this->assetRenderer->renderCssAssets($assets), + 'js_header' => $this->assetRenderer->renderJsHeaderAssets($assets), + 'js_footer' => $this->assetRenderer->renderJsFooterAssets($assets), + ]; + } + + /** + * Converts server side data shape into client side data shape. + * + * @param \Drupal\experience_builder\Plugin\Field\FieldType\ComponentTreeItem $item + * + * @return array{'layout': array{'uuid': string, 'nodeType': 'component', 'type': 'string', 'slots': array}, 'model': array<string, array>} + * + * @todo Follow up issue to extract this logic into a trait: https://www.drupal.org/project/experience_builder/issues/3499632 + */ + private function convertComponentTreeItemToLayoutModel(ComponentTreeItem $item): array { + assert($item instanceof ComponentTreeItem); + $tree = $item->get('tree'); + assert($tree instanceof ComponentTreeStructure); + $hydrated = $item->get('hydrated'); + assert($hydrated instanceof ComponentTreeHydrated); + + $layout = []; + $model = []; + $decoded_tree = json_decode($tree->getValue(), TRUE); + + $this->buildLayoutAndModel($layout, $model, $item, $decoded_tree[ComponentTreeStructure::ROOT_UUID], $hydrated->getValue()->getTree()[ComponentTreeStructure::ROOT_UUID]); + + return [ + 'layout' => $layout, + 'model' => $model, + ]; + } + + private function buildLayoutAndModel(array &$layout, array &$model, ComponentTreeItem $item, array $tree_tier, array $hydrated): void { + $tree = $item->get('tree'); + assert($tree instanceof ComponentTreeStructure); + $full_tree = json_decode($tree->getValue(), TRUE); + foreach ($tree_tier as ['uuid' => $component_instance_uuid, 'component' => $component_type]) { + $component_instance = [ + 'uuid' => $component_instance_uuid, + 'nodeType' => 'component', + 'type' => $component_type, + 'slots' => [], + ]; + if (isset($hydrated[$component_instance_uuid])) { + $model[$component_instance_uuid] = $hydrated[$component_instance_uuid]['props'] ?? []; + } + if (isset($full_tree[$component_instance_uuid])) { + foreach ($full_tree[$component_instance_uuid] as $slot_name => $slot_children) { + $component_instance_slot = [ + 'id' => $component_instance_uuid . '/' . $slot_name, + 'name' => $slot_name, + 'nodeType' => 'slot', + 'components' => [], + ]; + $this->buildLayoutAndModel($component_instance_slot['components'], $model, $item, $slot_children, $hydrated[$component_instance_uuid]['slots'][$slot_name]); + $component_instance['slots'][] = $component_instance_slot; + } + } + $layout[] = $component_instance; + } + } + } diff --git a/src/Controller/ApiLayoutController.php b/src/Controller/ApiLayoutController.php index eeba562a28bcf75c6266fc0624efdd1f1076fd9a..63598d0f7865e3617c976cde68a66c28b1e86b7a 100644 --- a/src/Controller/ApiLayoutController.php +++ b/src/Controller/ApiLayoutController.php @@ -76,6 +76,9 @@ final class ApiLayoutController { ]); } + /** + * @todo Follow up issue to extract this logic into a trait: https://www.drupal.org/project/experience_builder/issues/3499632 + */ private function buildRegion(string $id, ?ComponentTreeItem $item = NULL, ?array &$model = NULL): array { if ($item) { $tree = $item->get('tree'); @@ -97,6 +100,9 @@ final class ApiLayoutController { ]; } + /** + * @todo Follow up issue to extract this logic into a trait: https://www.drupal.org/project/experience_builder/issues/3499632 + */ private function buildLayout(array &$model, ComponentTreeItem $item, array $tree_tier, array $hydrated): array { $layout = []; $tree = $item->get('tree'); diff --git a/src/Controller/ClientServerConversionTrait.php b/src/Controller/ClientServerConversionTrait.php index f8e007db7b8dac153a09b99cbb7e00c9d781e6f0..c826613ca6408d1572b015170864ff4bf38f78b6 100644 --- a/src/Controller/ClientServerConversionTrait.php +++ b/src/Controller/ClientServerConversionTrait.php @@ -25,7 +25,19 @@ trait ClientServerConversionTrait { private static function clientLayoutToServerTree(array $layout, bool $validate = TRUE): array { // Transform client-side representation to server-side representation. // The entire component tree is nested under the reserved root UUID. - $tree = self::doClientSlotToServerTree($layout, [], ComponentTreeStructure::ROOT_UUID); + // Top level of elements in component tree are regions, except for Patterns/Sections. + if (isset($layout['nodeType']) && $layout['nodeType'] === 'region') { + $tree = self::doClientSlotToServerTree($layout, [], ComponentTreeStructure::ROOT_UUID); + } + // For patterns/sections, the top level is array of components. + else { + $tree = []; + foreach ($layout as $component) { + assert($component['nodeType'] === 'component'); + $tree = self::doClientComponentToServerTree($component, $tree, ComponentTreeStructure::ROOT_UUID, NULL); + } + } + if (!$validate) { return $tree; } diff --git a/src/Entity/Pattern.php b/src/Entity/Pattern.php index 673a3c2926837b656053c269e32aa7ae4386f35b..fbb5cda8cd4a97f61a25f226ed063399ff635cf9 100644 --- a/src/Entity/Pattern.php +++ b/src/Entity/Pattern.php @@ -4,7 +4,9 @@ declare(strict_types=1); namespace Drupal\experience_builder\Entity; +use Drupal\Component\Utility\Random; use Drupal\Core\Config\Entity\ConfigEntityBase; +use Drupal\Core\Entity\EntityStorageInterface; use Drupal\experience_builder\Plugin\DataType\ComponentTreeStructure; use Drupal\experience_builder\Plugin\Field\FieldType\ComponentTreeItem; use Drupal\experience_builder\Plugin\Field\FieldType\ComponentTreeItemInstantiatorTrait; @@ -80,4 +82,37 @@ final class Pattern extends ConfigEntityBase implements XbHttpApiEligibleConfigE return $this; } + public static function preCreate(EntityStorageInterface $storage, array &$values) { + $values['id'] = self::generateId($values['label']); + parent::preCreate($storage, $values); + } + + /** + * Generates a valid ID from the given label. + */ + private static function generateId(string $label): string { + $id = mb_strtolower($label); + + $id = preg_replace('@[^a-z0-9_.]+@', '', $id); + assert(is_string($id)); + // Furthermore remove any characters that are not alphanumerical from the + // beginning and end of the transliterated string. + $id = preg_replace('@^([^a-z0-9]+)|([^a-z0-9]+)$@', '', $id); + assert(is_string($id)); + assert(is_string($id)); + if (strlen($id) > 23) { + $id = substr($id, 0, 23); + } + assert(is_string($id)); + + $query = \Drupal::entityTypeManager()->getStorage('pattern')->getQuery()->accessCheck(FALSE); + $ids = $query->execute(); + $id_exists = in_array($id, $ids, TRUE); + if ($id_exists) { + $id = $id . '_' . (new Random())->machineName(8); + } + + return $id; + } + } diff --git a/tests/src/Functional/XbConfigEntityHttpApiTest.php b/tests/src/Functional/XbConfigEntityHttpApiTest.php index 71aa04ccb14fa09dd0c50e12d58a99693bdae8b2..c2781d3cd13508df9459acd8ff08c4f5c6f8e2b1 100644 --- a/tests/src/Functional/XbConfigEntityHttpApiTest.php +++ b/tests/src/Functional/XbConfigEntityHttpApiTest.php @@ -3,7 +3,6 @@ declare(strict_types=1); use Drupal\Core\Url; -use Drupal\experience_builder\Plugin\DataType\ComponentTreeStructure; use Drupal\system\Entity\Menu; use Drupal\Tests\ApiRequestTrait; use Drupal\Tests\BrowserTestBase; @@ -71,6 +70,7 @@ class XbConfigEntityHttpApiTest extends BrowserTestBase { * @see \Drupal\experience_builder\Entity\Pattern */ public function testPattern(): void { + // cspell:ignore testpatternpleaseignore $base = rtrim(base_path(), '/'); $list_url = Url::fromUri('base:/xb/api/config/pattern'); @@ -85,76 +85,100 @@ class XbConfigEntityHttpApiTest extends BrowserTestBase { $body = $this->assertExpectedResponse('GET', $list_url, [], 200, ['user.permissions'], ['config:pattern_list', 'http_response'], 'UNCACHEABLE (request policy)', 'MISS'); $this->assertSame([], $body); - // Create a Pattern via the XB HTTP API, but forget crucial data: 422. + // Create a Pattern via the XB HTTP API, but forget crucial data that causes + // the required shape to be violated: 500, courtesy of OpenAPI. $pattern_to_send = [ - 'id' => 'test', - 'label' => 'Test pattern, please ignore', - 'component_tree' => NULL, + 'name' => 'Test pattern, please ignore', + 'layout' => NULL, + 'model' => NULL, ]; $request_options = [ - RequestOptions::JSON => $pattern_to_send, + RequestOptions::HEADERS => [ + 'Content-Type' => 'application/json', + ], + // TRICKY: this intentionally avoids using RequestOptions::JSON because + // that encodes `'props' => []` as `'props': []`, whereas the server side + // expects `'props': {}`. + // @see \Drupal\Tests\experience_builder\Traits\TestDataUtilitiesTrait::encodeXBData() + RequestOptions::BODY => self::encodeXBData($pattern_to_send), ]; - $body = $this->assertExpectedResponse('POST', $list_url, $request_options, 422, NULL, NULL, NULL, NULL); + $body = $this->assertExpectedResponse('POST', $list_url, $request_options, 500, NULL, NULL, NULL, NULL); $this->assertSame([ - 'errors' => [ - [ - 'detail' => 'This value should not be null.', - 'source' => ['pointer' => 'component_tree'], - ], - ], + 'message' => 'Body does not match schema for content-type "application/json" for Request [post /xb/api/config/pattern]', ], $body); - // Add missing crucial data, but still make a mistake: 422. - $pattern_to_send['component_tree']['tree'] = self::encodeXBData([ - ComponentTreeStructure::ROOT_UUID => [ - ['uuid' => 'uuid-in-root', 'component' => 'sdc.xb_test_sdc.props-no-slots'], - ['uuid' => 'uuid-in-root-another', 'component' => 'sdc.xb_test_sdc.props-no-slots'], + // Add missing crucial data, but leave a requires shape violation: 500, + // courtesy of OpenAPI. + $pattern_to_send['layout'] = [ + [ + 'uuid' => 'uuid-in-root', + 'nodeType' => 'component', + 'type' => 'sdc.xb_test_sdc.props-no-slots', + 'slots' => [], + ], + [ + 'uuid' => 'uuid-in-root-another', + 'nodeType' => 'component', + 'type' => 'sdc.xb_test_sdc.props-no-slots', + 'slots' => [], ], - ]); - $request_options = [ - RequestOptions::JSON => $pattern_to_send, ]; + $request_options[RequestOptions::BODY] = self::encodeXBData($pattern_to_send); + $body = $this->assertExpectedResponse('POST', $list_url, $request_options, 500, NULL, NULL, NULL, NULL); + $this->assertSame([ + 'message' => 'Body does not match schema for content-type "application/json" for Request [post /xb/api/config/pattern]', + ], $body); + + // Meet data shape requirements, but violate internal consistency for + // `model` (`props` on server side): 422 (i.e. validation constraint + // violation). + $pattern_to_send['model'] = []; + $request_options[RequestOptions::BODY] = self::encodeXBData($pattern_to_send); $body = $this->assertExpectedResponse('POST', $list_url, $request_options, 422, NULL, NULL, NULL, NULL); $this->assertSame([ 'errors' => [ [ - 'detail' => '\'props\' is a required key.', - 'source' => ['pointer' => 'component_tree'], + 'detail' => 'The required properties are missing.', + 'source' => ['pointer' => 'model.uuid-in-root'], ], [ - 'detail' => 'The array must contain a "props" key.', - 'source' => ['pointer' => 'component_tree'], + 'detail' => 'The required properties are missing.', + 'source' => ['pointer' => 'model.uuid-in-root-another'], ], ], ], $body); - // Add missing crucial data, but use disallowed component blocks: 422. - $pattern_to_send['component_tree']['tree'] = self::encodeXBData([ - ComponentTreeStructure::ROOT_UUID => [ - ['uuid' => 'uuid-main', 'component' => 'block.system_main_block'], - ['uuid' => 'uuid-title', 'component' => 'block.page_title_block'], - ['uuid' => 'uuid-messages', 'component' => 'block.system_messages_block'], + // Meet data shape requirements, but violate internal consistency for + // `layout` (`tree` on server side): 422 (i.e. validation constraint + // violation). + $generate_static_prop_source = function (string $label): array { + return [ + 'sourceType' => 'static:field_item:string', + 'value' => "Hello, $label!", + 'expression' => 'ℹ︎string␟value', + ]; + }; + $pattern_to_send['model'] = [ + 'uuid-in-root' => [ + 'heading' => $generate_static_prop_source('world'), ], - ]); - $pattern_to_send['component_tree']['props'] = self::encodeXBData([]); - - $request_options = [ - RequestOptions::JSON => $pattern_to_send, + 'uuid-in-root-another' => [ + 'heading' => $generate_static_prop_source('another world'), + ], + ]; + $pattern_to_send['layout'][] = [ + 'uuid' => 'uuid-main', + 'nodeType' => 'component', + 'type' => 'block.system_main_block', + 'slots' => [], ]; + $request_options[RequestOptions::BODY] = self::encodeXBData($pattern_to_send); $body = $this->assertExpectedResponse('POST', $list_url, $request_options, 422, NULL, NULL, NULL, NULL); $this->assertSame([ 'errors' => [ [ 'detail' => 'The \'Drupal\Core\Block\MainContentBlockPluginInterface\' component interface must be absent.', - 'source' => ['pointer' => 'component_tree'], - ], - [ - 'detail' => 'The \'Drupal\Core\Block\MessagesBlockPluginInterface\' component interface must be absent.', - 'source' => ['pointer' => 'component_tree'], - ], - [ - 'detail' => 'The \'Drupal\Core\Block\TitleBlockPluginInterface\' component interface must be absent.', - 'source' => ['pointer' => 'component_tree'], + 'source' => ['pointer' => 'layout'], ], ], ], $body); @@ -164,106 +188,167 @@ class XbConfigEntityHttpApiTest extends BrowserTestBase { $this->assertSame([], $body); // Create a Pattern via the XB HTTP API, correctly: 201. - $pattern_to_send['component_tree']['tree'] = self::encodeXBData([ - ComponentTreeStructure::ROOT_UUID => [ - ['uuid' => 'uuid-in-root', 'component' => 'sdc.xb_test_sdc.props-no-slots'], - ['uuid' => 'uuid-in-root-another', 'component' => 'sdc.xb_test_sdc.props-no-slots'], - ], - ]); - $pattern_to_send['component_tree']['props'] = []; - $generate_static_prop_source = function (string $label): array { - return [ - 'sourceType' => 'static:field_item:string', - 'value' => "Hello, $label!", - 'expression' => 'ℹ︎string␟value', - ]; - }; - $pattern_to_send['component_tree']['props'] = self::encodeXBData([ - 'uuid-in-root' => [ - 'heading' => $generate_static_prop_source('world'), - ], - 'uuid-in-root-another' => [ - 'heading' => $generate_static_prop_source('another world'), + array_pop($pattern_to_send['layout']); + $request_options[RequestOptions::BODY] = self::encodeXBData($pattern_to_send); + $body = $this->assertExpectedResponse('POST', $list_url, $request_options, 201, NULL, NULL, NULL, NULL, [ + 'Location' => [ + "$base/xb/api/config/pattern/testpatternpleaseignore", ], ]); - // Note how the key-value pairs under `component_tree` are sorted by key. $expected_pattern_normalization = [ - 'label' => 'Test pattern, please ignore', - 'component_tree' => [ - 'tree' => self::encodeXBData([ - ComponentTreeStructure::ROOT_UUID => [ - ['uuid' => 'uuid-in-root', 'component' => 'sdc.xb_test_sdc.props-no-slots'], - ['uuid' => 'uuid-in-root-another', 'component' => 'sdc.xb_test_sdc.props-no-slots'], + 'layoutModel' => [ + 'layout' => [ + [ + 'uuid' => 'uuid-in-root', + 'nodeType' => 'component', + 'type' => 'sdc.xb_test_sdc.props-no-slots', + 'slots' => [], ], - ]), - 'props' => self::encodeXBData([ + [ + 'uuid' => 'uuid-in-root-another', + 'nodeType' => 'component', + 'type' => 'sdc.xb_test_sdc.props-no-slots', + 'slots' => [], + ], + ], + 'model' => [ 'uuid-in-root' => [ - 'heading' => $generate_static_prop_source('world'), + 'heading' => 'Hello, world!', ], 'uuid-in-root-another' => [ - 'heading' => $generate_static_prop_source('another world'), + 'heading' => 'Hello, another world!', ], - ]), + ], ], + 'name' => 'Test pattern, please ignore', + 'id' => 'testpatternpleaseignore', + 'default_markup' => '<!-- xb-start-uuid-in-root --><div data-component-id="xb_test_sdc:props-no-slots" style="font-family: Helvetica, Arial, sans-serif; width: 100%; height: 100vh; background-color: #f5f5f5; display: flex; justify-content: center; align-items: center; flex-direction: column; text-align: center; padding: 20px; box-sizing: border-box;"> + <h1 style="font-size: 3em; margin: 0.5em 0; color: #333;"><!-- xb-prop-start-uuid-in-root/heading -->Hello, world!<!-- xb-prop-end-uuid-in-root/heading --></h1> +</div> +<!-- xb-end-uuid-in-root --><!-- xb-start-uuid-in-root-another --><div data-component-id="xb_test_sdc:props-no-slots" style="font-family: Helvetica, Arial, sans-serif; width: 100%; height: 100vh; background-color: #f5f5f5; display: flex; justify-content: center; align-items: center; flex-direction: column; text-align: center; padding: 20px; box-sizing: border-box;"> + <h1 style="font-size: 3em; margin: 0.5em 0; color: #333;"><!-- xb-prop-start-uuid-in-root-another/heading -->Hello, another world!<!-- xb-prop-end-uuid-in-root-another/heading --></h1> +</div> +<!-- xb-end-uuid-in-root-another -->', + 'css' => '', + 'js_header' => '', + 'js_footer' => '', ]; - $request_options = [ - RequestOptions::JSON => $pattern_to_send, + $this->assertSame($expected_pattern_normalization, $body); + + // Create a (more realistic) Pattern with nested components, but missing + // prop: 422. + $nested_components_pattern = $pattern_to_send; + $nested_components_pattern['name'] = 'Nested'; + $nested_components_pattern['layout'] = [ + [ + 'nodeType' => 'component', + 'slots' => [ + [ + 'components' => [ + [ + 'uuid' => 'uuid-in-root', + 'nodeType' => 'component', + 'type' => 'sdc.xb_test_sdc.props-no-slots', + 'slots' => [], + ], + [ + 'uuid' => 'uuid-in-root-another', + 'nodeType' => 'component', + 'type' => 'sdc.xb_test_sdc.props-no-slots', + 'slots' => [], + ], + ], + 'id' => 'c4074d1f-149a-4662-aaf3-615151531cf6/content', + 'name' => 'content', + 'nodeType' => 'slot', + ], + ], + 'type' => 'sdc.experience_builder.one_column', + 'uuid' => 'c4074d1f-149a-4662-aaf3-615151531cf6', + ], ]; - $body = $this->assertExpectedResponse('POST', $list_url, $request_options, 201, NULL, NULL, NULL, NULL, [ + $request_options[RequestOptions::BODY] = self::encodeXBData($nested_components_pattern); + $body = $this->assertExpectedResponse('POST', $list_url, $request_options, 422, NULL, NULL, NULL, NULL); + $this->assertSame([ + 'errors' => [ + [ + 'detail' => 'The required properties are missing.', + 'source' => ['pointer' => 'model.c4074d1f-149a-4662-aaf3-615151531cf6'], + ], + ], + ], $body); + + // Add missing missing prop: 201. + $nested_components_pattern['model']['c4074d1f-149a-4662-aaf3-615151531cf6'] = [ + 'width' => 'full', + ]; + $request_options[RequestOptions::BODY] = self::encodeXBData($nested_components_pattern); + $this->assertExpectedResponse('POST', $list_url, $request_options, 201, NULL, NULL, NULL, NULL, [ 'Location' => [ - "$base/xb/api/config/pattern/test", + "$base/xb/api/config/pattern/nested", ], ]); - $this->assertSame($expected_pattern_normalization, $body); + + // Delete the nested Pattern via the XB HTTP API: 204. + $this->assertExpectedResponse('DELETE', Url::fromUri('base:/xb/api/config/pattern/nested'), [], 204, NULL, NULL, NULL, NULL); // Re-retrieve list: 200, non-empty list. Dynamic Page Cache miss. $body = $this->assertExpectedResponse('GET', $list_url, [], 200, ['user.permissions'], ['config:pattern_list', 'http_response'], 'UNCACHEABLE (request policy)', 'MISS'); $this->assertSame([ - "$base/xb/api/config/pattern/test" => $expected_pattern_normalization, + "testpatternpleaseignore" => $expected_pattern_normalization, ], $body); // Use the individual URL in the list response body. - $individual_body = $this->assertExpectedResponse('GET', Url::fromUri('base:' . substr(array_keys($body)[0], strlen($base))), [], 200, ['user.permissions'], ['config:experience_builder.pattern.test', 'http_response'], 'UNCACHEABLE (request policy)', 'MISS'); - $this->assertSame($expected_pattern_normalization, $individual_body); + $individual_body = $this->assertExpectedResponse('GET', Url::fromUri('base:/xb/api/config/pattern/testpatternpleaseignore'), [], 200, ['user.permissions'], ['config:experience_builder.pattern.testpatternpleaseignore', 'http_response'], 'UNCACHEABLE (request policy)', 'MISS'); + $expected_individual_body_normalization = $expected_pattern_normalization; + $expected_individual_body_normalization['js_footer'] = str_replace('xb\/api\/config\/pattern', 'xb\/api\/config\/pattern\/testpatternpleaseignore', $expected_pattern_normalization['js_footer']); + $this->assertSame($expected_individual_body_normalization, $individual_body); + + // Modify a Pattern incorrectly (shape-wise): 500. + $request_options[RequestOptions::BODY] = self::encodeXBData([ + 'name' => $pattern_to_send['name'], + 'layout' => $pattern_to_send['layout'], + 'model' => NULL, + ]); + $body = $this->assertExpectedResponse('PATCH', Url::fromUri('base:/xb/api/config/pattern/testpatternpleaseignore'), $request_options, 500, NULL, NULL, NULL, NULL); + $this->assertSame([ + 'message' => 'Body does not match schema for content-type "application/json" for Request [patch /xb/api/config/pattern/{configEntityId}]', + ], $body); - // Modify a Pattern incorrectly: 422. - $temp_copy = $pattern_to_send['component_tree']['tree']; - $pattern_to_send['component_tree']['tree'] = '{}'; - $request_options = [ - RequestOptions::JSON => $pattern_to_send, - ]; - $body = $this->assertExpectedResponse('PATCH', Url::fromUri('base:/xb/api/config/pattern/test'), $request_options, 422, NULL, NULL, NULL, NULL); + // Modify a Pattern incorrectly (consistency-wise): 422. + $request_options[RequestOptions::BODY] = self::encodeXBData([ + 'name' => $pattern_to_send['name'], + 'layout' => $pattern_to_send['layout'], + 'model' => array_slice($pattern_to_send['model'], 1), + ]); + $body = $this->assertExpectedResponse('PATCH', Url::fromUri('base:/xb/api/config/pattern/testpatternpleaseignore'), $request_options, 422, NULL, NULL, NULL, NULL); $this->assertSame([ 'errors' => [ [ - 'detail' => 'The root UUID is missing.', - 'source' => ['pointer' => 'component_tree.tree[a548b48d-58a8-4077-aa04-da9405a6f418]'], + 'detail' => 'The required properties are missing.', + 'source' => ['pointer' => 'model.uuid-in-root'], ], ], ], $body); - $pattern_to_send['component_tree']['tree'] = $temp_copy; - // Modify a Pattern correctly: 200. - $request_options = [ - RequestOptions::JSON => $pattern_to_send, - ]; - $body = $this->assertExpectedResponse('PATCH', Url::fromUri('base:/xb/api/config/pattern/test'), $request_options, 200, NULL, NULL, NULL, NULL); - $this->assertSame($expected_pattern_normalization, $body); + $request_options[RequestOptions::BODY] = self::encodeXBData($pattern_to_send); + $body = $this->assertExpectedResponse('PATCH', Url::fromUri('base:/xb/api/config/pattern/testpatternpleaseignore'), $request_options, 200, NULL, NULL, NULL, NULL); + $this->assertSame($expected_individual_body_normalization, $body); // Re-retrieve list: 200, non-empty list. Dynamic Page Cache miss. $body = $this->assertExpectedResponse('GET', $list_url, [], 200, ['user.permissions'], ['config:pattern_list', 'http_response'], 'UNCACHEABLE (request policy)', 'MISS'); $this->assertSame([ - "$base/xb/api/config/pattern/test" => $expected_pattern_normalization, + "testpatternpleaseignore" => $expected_pattern_normalization, ], $body); - // Delete the sole Pattern via the XB HTTP API: 204. - $body = $this->assertExpectedResponse('DELETE', Url::fromUri('base:/xb/api/config/pattern/test'), [], 204, NULL, NULL, NULL, NULL); + // Delete the sole remaining Pattern via the XB HTTP API: 204. + $body = $this->assertExpectedResponse('DELETE', Url::fromUri('base:/xb/api/config/pattern/testpatternpleaseignore'), [], 204, NULL, NULL, NULL, NULL); $this->assertNull($body); - // Re-retrieve list: 200, non-empty list. Dynamic Page Cache miss. + // Re-retrieve list: 200, empty list. Dynamic Page Cache miss. $body = $this->assertExpectedResponse('GET', $list_url, [], 200, ['user.permissions'], ['config:pattern_list', 'http_response'], 'UNCACHEABLE (request policy)', 'MISS'); $this->assertSame([], $body); - $individual_body = $this->assertExpectedResponse('GET', Url::fromUri('base:/xb/api/config/pattern/test'), [], 404, NULL, NULL, 'UNCACHEABLE (request policy)', 'UNCACHEABLE (no cacheability)'); + $individual_body = $this->assertExpectedResponse('GET', Url::fromUri('base:/xb/api/config/pattern/testpatternpleaseignore'), [], 404, NULL, NULL, 'UNCACHEABLE (request policy)', 'UNCACHEABLE (no cacheability)'); $this->assertSame([], $individual_body); // This was now tested full circle! ✅ diff --git a/tests/src/Kernel/ClientServerConversionTraitTest.php b/tests/src/Kernel/ClientServerConversionTraitTest.php index 6ff6b7987718159176f02268784cafd9ada28f6a..8c28db44f8fa31da4be80787b2d74296e47de313 100644 --- a/tests/src/Kernel/ClientServerConversionTraitTest.php +++ b/tests/src/Kernel/ClientServerConversionTraitTest.php @@ -63,14 +63,6 @@ class ClientServerConversionTraitTest extends KernelTestBase { ], ], json_decode($converted_item['tree'], TRUE)); - // Ensure convert 'tree' and 'props' can be used both to create both a - // config entity and a content entity field value. - Pattern::create([ - 'id' => 'test_pattern', - 'label' => 'Test Pattern', - 'component_tree' => $converted_item, - ])->save(); - $node1 = Node::create([ 'type' => 'article', 'title' => '5 amazing uses for old toothbrushes', @@ -88,6 +80,29 @@ class ClientServerConversionTraitTest extends KernelTestBase { $this->getValidConvertedProps(), '5 amazing uses for old toothbrushes' ); + + ['layout' => $layout, 'model' => $model] = $this->getValidPatternJson(); + $converted_item = $this->convertClientToServer($layout, $model); + $this->assertSame($this->getValidConvertedProps(), json_decode($converted_item['props'], TRUE)); + $this->assertSame([ + ComponentTreeStructure::ROOT_UUID => [ + [ + 'uuid' => self::TEST_HEADING_UUID, + 'component' => 'sdc.experience_builder.heading', + ], + [ + 'uuid' => self::TEST_IMAGE_UUID, + 'component' => 'sdc.experience_builder.image', + ], + ], + ], json_decode($converted_item['tree'], TRUE)); + + Pattern::create([ + 'id' => 'test_pattern', + 'label' => 'Test Pattern', + 'component_tree' => $converted_item, + ])->save(); + } public function testConvertClientToServerErrors(): void { @@ -126,4 +141,38 @@ class ClientServerConversionTraitTest extends KernelTestBase { } } + protected function getValidPatternJson(): array { + return [ + 'layout' => [ + [ + 'nodeType' => 'component', + 'uuid' => self::TEST_HEADING_UUID, + 'type' => 'sdc.experience_builder.heading', + 'slots' => [], + ], + [ + 'nodeType' => 'component', + 'uuid' => self::TEST_IMAGE_UUID, + 'type' => 'sdc.experience_builder.image', + 'slots' => [], + ], + ], + 'model' => [ + self::TEST_HEADING_UUID => [ + 'text' => 'This is a random heading.', + 'style' => 'primary', + 'element' => 'h1', + ], + self::TEST_IMAGE_UUID => [ + 'image' => [ + 'src' => $this->getSrcPropertyFromFile($this->referencedImage), + 'alt' => 'This is a random image.', + 'width' => 100, + 'height' => 100, + ], + ], + ], + ]; + } + } diff --git a/tests/src/Kernel/Config/PatternValidationTest.php b/tests/src/Kernel/Config/PatternValidationTest.php index 05f9739d7fad6838c1aa1662f8d27a8f9ab1a5f9..b7bef0dcb194a6a98606c5a63888430ecc782786 100644 --- a/tests/src/Kernel/Config/PatternValidationTest.php +++ b/tests/src/Kernel/Config/PatternValidationTest.php @@ -4,6 +4,8 @@ declare(strict_types=1); namespace Drupal\Tests\experience_builder\Kernel\Config; +// cspell:ignore thisisatestpattern + use Drupal\experience_builder\Entity\Pattern; use Drupal\experience_builder\Plugin\DataType\ComponentTreeStructure; use Drupal\KernelTests\Core\Config\ConfigEntityValidationTestBase; @@ -122,6 +124,50 @@ class PatternValidationTest extends ConfigEntityValidationTestBase { ], $this->getAllDependencies($this->entity)); } + /** + * Pattern config entities atypically do not need an ID to be specified. + * + * @see \Drupal\experience_builder\Entity\Pattern::preCreate() + */ + public function testValidWithoutIdSpecified(): void { + $this->assertCount(1, Pattern::loadMultiple()); + + // Reuse most of the values of the test Pattern. + $values = $this->entity->toArray(); + // Each config entity must have a unique UUID; it is generated by config + // storage. + unset($values['uuid']); + + // Test creating Pattern entities with a specific label and no ID. + $values['label'] = 'This is a test pattern'; + unset($values['id']); + + for ($i = 0; $i < 3; $i++) { + $pattern = Pattern::create($values); + $pattern->save(); + $this->assertSame($values['label'], $pattern->label()); + if ($i === 0) { + // The first Pattern generated from this label does not have a suffix. + $this->assertSame('thisisatestpattern', $pattern->id()); + } + else { + // All others do. + // @phpstan-ignore-next-line + $this->assertMatchesRegularExpression('/^thisisatestpattern_[a-z0-9]+$/', $pattern->id()); + } + } + + $this->assertSame([ + 'Test pattern', + 'This is a test pattern', + 'This is a test pattern', + 'This is a test pattern', + ], array_map( + fn (Pattern $p): string => (string) $p->label(), + array_values(Pattern::loadMultiple()) + )); + } + /** * @dataProvider providerInvalidComponentTree * @covers \Drupal\experience_builder\Plugin\Validation\Constraint\ComponentTreeMeetsRequirementsConstraint diff --git a/ui/src/components/list/DummySectionList.tsx b/ui/src/components/list/DummySectionList.tsx deleted file mode 100644 index fd987293a0ca78c5bc50eaabb2bd663d0e5e0afa..0000000000000000000000000000000000000000 --- a/ui/src/components/list/DummySectionList.tsx +++ /dev/null @@ -1,25 +0,0 @@ -import List from '@/components/list/List'; -import { useGetDummySectionsQuery } from '@/services/sections'; -import { Text } from '@radix-ui/themes'; - -const DummySectionList = () => { - const { data: fakeSections } = useGetDummySectionsQuery(); - - return ( - <> - <Text size="1"> - The section template listed below is hard coded and is a proof of - concept. It should allow the user to add a hero with an image below it - in a single action. - </Text> - <List - items={fakeSections} - isLoading={false} - type="section" - label="Dummy Section templates" - /> - </> - ); -}; - -export default DummySectionList; diff --git a/ui/src/components/sidebar/Library.tsx b/ui/src/components/sidebar/Library.tsx index 73087281a3c592d39a294983821e89c26a5e1835..0f47057f9d3a047926f780adc9fde45e30e79dee 100644 --- a/ui/src/components/sidebar/Library.tsx +++ b/ui/src/components/sidebar/Library.tsx @@ -14,7 +14,6 @@ import { } from '@/features/ui/primaryPanelSlice'; import { useAppDispatch, useAppSelector } from '@/app/hooks'; import ErrorBoundary from '@/components/error/ErrorBoundary'; -import DummySectionList from '@/components/list/DummySectionList'; const Library = () => { const openItems = useAppSelector(selectOpenLayoutItems); @@ -62,7 +61,6 @@ const Library = () => { Sections </AccordionTrigger> <Accordion.Content> - <DummySectionList /> <ErrorBoundary title="An unexpected error has occurred while fetching section templates."> <SectionList /> </ErrorBoundary> diff --git a/ui/src/features/saveSection/SaveSectionDialog.tsx b/ui/src/features/saveSection/SaveSectionDialog.tsx index faaa6db789d7b8adfd0df5aa2ab2f27e95fbb401..3bbafdf384a5c2ca8ec9dbeb0b1df7e553a026c8 100644 --- a/ui/src/features/saveSection/SaveSectionDialog.tsx +++ b/ui/src/features/saveSection/SaveSectionDialog.tsx @@ -126,12 +126,6 @@ const SaveSectionDialog: React.FC = () => { return ( <Dialog.Root open={saveAsSection} onOpenChange={handleOpenChange}> <Dialog.Content maxWidth="550px"> - <Text size="1"> - <strong> - This is currently only a proof of concept, saving will fail until - the endpoint is fully implemented. - </strong> - </Text> <Dialog.Title>Add new section</Dialog.Title> <Dialog.Description size="2" mb="4"> Save "{selectedComponentName}" as a section. Unlike components, diff --git a/ui/src/hooks/usePreviewSortable.ts b/ui/src/hooks/usePreviewSortable.ts index 4f6fbb514201a8e8f045402ffa32fc84df0747c3..fc59799734bc1a462fcb9a7fe011ff98d1f3224f 100644 --- a/ui/src/hooks/usePreviewSortable.ts +++ b/ui/src/hooks/usePreviewSortable.ts @@ -12,7 +12,7 @@ import { import { setTargetSlot, unsetTargetSlot } from '@/features/ui/uiSlice'; import { findNodePathByUuid } from '@/features/layout/layoutUtils'; import { useGetComponentsQuery } from '@/services/components'; -import { useGetDummySectionsQuery } from '@/services/sections'; +import { useGetSectionsQuery } from '@/services/sections'; import type { SlotsMap } from '@/types/AnnotationMaps'; import { insertPlaceholderIfMatchingComments } from '@/utils/function-utils'; @@ -32,8 +32,7 @@ function usePreviewSortable( const model = useAppSelector(selectModel); const dispatch = useAppDispatch(); const { data: components } = useGetComponentsQuery(); - // TODO update to use the real section query once it works. - const { data: sections } = useGetDummySectionsQuery(); + const { data: sections } = useGetSectionsQuery(); const modelRef = useRef(model); const iframeDocumentRef = useRef<Document | null>(null); const componentsRef = useRef(components); diff --git a/ui/src/services/sections.ts b/ui/src/services/sections.ts index 87356c72546df760deb950c28474f87beb1973dd..cbd07afa3ec8005fa973f8c38af7c9996afc704b 100644 --- a/ui/src/services/sections.ts +++ b/ui/src/services/sections.ts @@ -1,5 +1,3 @@ -// cspell:ignore abcde, fghij, klmno - // Need to use the React-specific entry point to import createApi import { createApi } from '@reduxjs/toolkit/query/react'; import { baseQuery } from '@/services/baseQuery'; @@ -9,99 +7,15 @@ interface SaveSectionData extends LayoutModelPiece { name: string; } -const mockSections = { - fakeSection2: { - id: 'fakeSection2', - name: 'Fake Section 2', - css: '', - js_footer: '', - js_header: '', - layoutModel: { - layout: [ - { - uuid: 'abcde', - nodeType: 'component', - type: 'sdc.experience_builder.two_column', - slots: [ - { - id: 'abcde/column_one', - name: 'column_one', - nodeType: 'slot', - components: [ - { - uuid: 'fghij', - nodeType: 'component', - type: 'sdc.experience_builder.my-hero', - slots: [], - }, - ], - }, - { - id: 'abcde/column_two', - name: 'column_two', - nodeType: 'slot', - components: [ - { - uuid: 'klmno', - nodeType: 'component', - type: 'sdc.experience_builder.my-hero', - slots: [], - }, - ], - }, - ], - }, - ], - model: { - abcde: { - width: 50, - name: 'Two Column', - }, - fghij: { - heading: 'A hero in slot 1!', - subheading: 'This text was defined in the section.', - cta1: 'Yes', - cta2: 'No', - cta1href: 'https://drupal.org', - cta2href: 'https://google.com', - name: 'Hero', - }, - klmno: { - heading: 'A hero in slot 2!', - subheading: 'Text saved in the section', - cta1: 'Up', - cta2: 'Down', - cta1href: 'https://drupal.org', - cta2href: 'https://google.com', - name: 'Hero', - }, - }, - }, - default_markup: '<h1 style="background: black; color: white;">TODO</h1>', - }, -}; -// Custom baseQuery function to return mock data during development -// @ts-ignore -const customBaseQuery = async (args, api, extraOptions) => { - if (args === 'xb-sections') { - return { data: mockSections }; - } - return baseQuery(args, api, extraOptions); -}; - // Define a service using a base URL and expected endpoints export const sectionApi = createApi({ reducerPath: 'sectionsApi', - baseQuery: customBaseQuery, + baseQuery, + tagTypes: ['Sections'], endpoints: (builder) => ({ - getSectionById: builder.query<any, string>({ - query: (id) => `xb-section/${id}`, - }), - getDummySections: builder.query<any, void>({ - query: () => `xb-sections`, - }), getSections: builder.query<any, void>({ query: () => `/xb/api/config/pattern`, + providesTags: () => [{ type: 'Sections', id: 'LIST' }], }), saveSection: builder.mutation<{ html: string }, SaveSectionData>({ query: (body) => ({ @@ -109,15 +23,11 @@ export const sectionApi = createApi({ method: 'POST', body, }), + invalidatesTags: () => [{ type: 'Sections', id: 'LIST' }], }), }), }); // Export hooks for usage in functional sections, which are // auto-generated based on the defined endpoints -export const { - useGetSectionByIdQuery, - useGetSectionsQuery, - useGetDummySectionsQuery, - useSaveSectionMutation, -} = sectionApi; +export const { useGetSectionsQuery, useSaveSectionMutation } = sectionApi; diff --git a/ui/tests/e2e/component-operations.cy.js b/ui/tests/e2e/component-operations.cy.js index ef26fdd5264926e9f1c982b36f9d0bd5b1172a91..1c2f915ea551da4ce73486ccdadb365bcfa439bd 100644 --- a/ui/tests/e2e/component-operations.cy.js +++ b/ui/tests/e2e/component-operations.cy.js @@ -1,11 +1,6 @@ -import { onlyVisibleChars } from '../support/utils.js'; - describe('Perform CRUD operations on components', () => { - before(() => { - cy.drupalXbInstall(); - }); - beforeEach(() => { + cy.drupalXbInstall(); cy.drupalLogin('xbUser', 'xbUser'); }); @@ -182,65 +177,108 @@ describe('Perform CRUD operations on components', () => { }); it('Performs basic interaction with the Add section button', () => { - cy.loadURLandWaitForXBLoaded(); + const clickDefault = { + force: true, + scrollBehavior: false, + }; - // Check there are three heroes initially. - cy.testInIframe( - '[data-component-id="experience_builder:my-hero"]', - (myHeroComponent) => { - expect(myHeroComponent.length).to.equal(3); - }, - ); - // Check that the layers menu is initially open - cy.get('[data-testid="xb-primary-panel--layers"]').should( - 'have.attr', - 'data-state', - 'active', + cy.viewport(2000, 1320); + cy.loadURLandWaitForXBLoaded(); + cy.get( + '[data-xb-viewport-size="lg"] [aria-label="Two Column: Column One"]', + ).realClick({ position: 'bottom' }); + cy.log( + 'Save the entire node 1 layout as a section, so it can be added to a different node.', ); - cy.get('[data-xb-uuid="content"]').findByText('Hero').should('not.exist'); - cy.get('.primaryPanelContent').findByText('Two Column').click(); - cy.findByLabelText('Column Width').should('exist'); - cy.findAllByLabelText('Add section') + + // First remove the two image components because they will otherwise crash + // due to the test not creating them in a way that allows the media entity + // to be found based on filename. + cy.get( + '[data-xb-viewport-size="lg"] [data-xb-component-id="sdc.experience_builder.image"]', + ) .first() - .click({ scrollBehavior: 'center' }); + .trigger('contextmenu', clickDefault); + cy.findByText('Delete').click({ + force: true, + scrollBehavior: false, + }); + cy.get( + '[data-xb-viewport-size="lg"] [data-xb-component-id="sdc.experience_builder.image"]', + ) + .first() + .trigger('contextmenu', clickDefault); + cy.findByText('Delete').click({ + force: true, + scrollBehavior: false, + }); + cy.get( + '[data-xb-viewport-size="lg"] [aria-label="Two Column: Column One"]', + ).trigger('contextmenu', { + ...clickDefault, + position: 'bottom', + }); + cy.findByText('Create section').click(clickDefault); // Section name + cy.findByLabelText('Section name').clear(); + cy.findByLabelText('Section name').type('The Entire Node 1'); + cy.findByText('Add to Library').click({ scrollBehavior: false }); - // Check the active panel is the library panel. - cy.get('[data-testid="xb-primary-panel--library"]').should( - 'have.attr', - 'data-state', - 'active', - ); - cy.get('.primaryPanelContent').should('contain.text', 'Sections'); - // Click on Fake Section 2 inside menu. - cy.get('.primaryPanelContent').findByText('Fake Section 2').click(); - cy.waitForElementContentInIframe('div', 'A hero in slot 1!'); - cy.testInIframe( - '[data-component-id="experience_builder:my-hero"]', - (components) => { - expect(components.length).to.equal(5); - }, - ); - cy.testInIframe( - '[data-component-id="experience_builder:my-hero"] h1', - (components) => { - const heroText1 = onlyVisibleChars(components[3].textContent); - const heroText2 = onlyVisibleChars(components[4].textContent); - expect(heroText1).to.equal('A hero in slot 1!'); - expect(heroText2).to.equal('A hero in slot 2!'); - }, - ); + cy.openLibraryPanel(); + cy.get('.primaryPanelContent').within(() => { + cy.findByText('Sections').click(clickDefault); + cy.findByText('The Entire Node 1').should('exist'); + }); + cy.get('.primaryPanelContent') + .as('panel') + .should('contain.text', 'The Entire Node 1'); - cy.log( - 'The newly added Two Column component from the section should be selected', + cy.loadURLandWaitForXBLoaded({ url: 'xb/node/2' }); + cy.get('#edit-title-0-value').should('exist'); + cy.waitForElementContentNotInIframe('div', 'There goes my hero'); + cy.openLibraryPanel(); + // Add a single component so the 'Add section' button can appear when it is + // clicked. + cy.get('[data-xb-component-id="sdc.experience_builder.my-hero"]').should( + 'exist', ); + + // Ensure the element that can receive component drops is present. + cy.waitForElementInIframe('.xb--sortable-slot-empty-placeholder'); + + cy.getIframeBody().then(($iframe) => { + cy.get($iframe.find('.xb--sortable-slot-empty-placeholder')).then( + ($destination) => { + cy.get( + '[data-xb-component-id="sdc.experience_builder.my-hero"]', + ).realDnd($destination); + }, + ); + }); + cy.waitForElementContentInIframe('div', 'There goes my hero'); + + // There should be one Hero added. + cy.get( + '[data-xb-viewport-size="lg"] [data-xb-component-id="sdc.experience_builder.my-hero"]', + ).should('have.length', 1); + cy.findAllByLabelText('Add section').first().click(clickDefault); + + // Add the section that was created earlier in this test. + cy.get('.primaryPanelContent').within(() => { + cy.findByText('The Entire Node 1').should('exist'); + cy.findByText('The Entire Node 1').click(clickDefault); + }); + + // After adding the section, there should be four Hero components. + cy.get( + '[data-xb-viewport-size="lg"] [data-xb-component-id="sdc.experience_builder.my-hero"]', + ).should('have.length', 4); + + // The Two Column component that is the top level element of the section + // should be the currently selected layer. cy.openLayersPanel(); cy.findByTestId('xb-primary-panel').within(() => { - cy.findAllByText('Two Column').should('have.length', 2); - cy.log( - 'The second (new) Two Column should be selected (check that it has a parent treeItem with the data-xb-selected attr)', - ); + cy.findAllByText('Two Column').should('have.length', 1); cy.findAllByText('Two Column') - .eq(1) .parents('.treeItem') .should('have.attr', 'data-xb-selected', 'true'); }); diff --git a/ui/tests/e2e/undo-redo.cy.js b/ui/tests/e2e/undo-redo.cy.js index c64b4db78351139739659ab9d69481f10337975f..ed55e2d00e9220c6df8c757caa0897041af60d06 100644 --- a/ui/tests/e2e/undo-redo.cy.js +++ b/ui/tests/e2e/undo-redo.cy.js @@ -36,8 +36,6 @@ describe('Undo/Redo functionality', () => { 'data-state', 'active', ); - cy.get('.primaryPanelContent').should('contain.text', 'Sections'); - cy.get('.primaryPanelContent').findByText('Fake Section 2'); cy.intercept('POST', '**/api/preview/node/1').as('getPreview'); // Click on the menu item with data-xb-name="Hero" inside menu.