diff --git a/.gitlab-ci.yml b/.gitlab-ci.yml index 44b114152e85b90a88f3579da64559bdb1b8e7d9..2c0466ca80e503253596cf6dd4465e67c275b4e6 100644 --- a/.gitlab-ci.yml +++ b/.gitlab-ci.yml @@ -310,14 +310,14 @@ phpunit: _TARGET_DB_VERSION: '3' # @see https://git.drupalcode.org/project/drupal/-/commit/10466dba9d7322ed55165dd9224edb2153c9b27a _TARGET_PHP: '8.3-ubuntu' - _FOR_EVERY_MR_COMMIT: 'true' + _FOR_EVERY_MR_COMMIT: 'false' - _TARGET_DB_TYPE: 'mariadb' _TARGET_DB_VERSION: '10.6' _FOR_EVERY_MR_COMMIT: 'false' - _TARGET_DB_TYPE: 'mysql' # @todo Delete this when https://www.drupal.org/project/gitlab_templates/issues/3447105 lands. _TARGET_DB_VERSION: '8' - _FOR_EVERY_MR_COMMIT: 'false' + _FOR_EVERY_MR_COMMIT: 'true' - _TARGET_DB_TYPE: 'pgsql' _TARGET_DB_VERSION: '16' _FOR_EVERY_MR_COMMIT: 'false' diff --git a/openapi.yml b/openapi.yml index 7e7e0d4ff6310b76e6dda8c9461ed6cdbe90aa9f..9ffe8fefecb32b09d9fe11ca11550ec64d564979 100644 --- a/openapi.yml +++ b/openapi.yml @@ -855,6 +855,13 @@ paths: '/xb/api/v0/content/{entity_type}': get: description: Provides api for page listing. + parameters: + - in: query + name: search + required: false + description: Search term to filter content by title + schema: + type: string responses: 200: description: Page list generated successfully. diff --git a/src/AutoSave/AutoSaveManager.php b/src/AutoSave/AutoSaveManager.php index b4c54825c64ff87c62e0f443b82d672c8224de3a..1f98ec4ee3862241871d196bf7a053385b6b2c03 100644 --- a/src/AutoSave/AutoSaveManager.php +++ b/src/AutoSave/AutoSaveManager.php @@ -20,6 +20,7 @@ use Drupal\Core\TypedData\TypedDataInterface; use Drupal\experience_builder\AutoSaveEntity; use Drupal\experience_builder\Controller\ApiContentControllers; use Drupal\experience_builder\Entity\XbHttpApiEligibleConfigEntityInterface; +use Drupal\experience_builder\Plugin\Field\FieldType\ComponentTreeItem; use Symfony\Component\EventDispatcher\EventSubscriberInterface; use Symfony\Component\Validator\ConstraintViolationList; use Symfony\Component\Validator\ConstraintViolationListInterface; @@ -66,6 +67,16 @@ class AutoSaveManager implements EventSubscriberInterface { $data = self::normalizeEntity($entity); $data_hash = self::generateHash($data); $original_hash = $this->getUnchangedHash($entity); + // 💡 If you are debugging why an entry is being created, but you didn't + // expect one to be, the code below can be evaluated in a debugger and will + // show you which field varies. + // @code + // $original = self::normalizeEntity($this->entityTypeManager->getStorage($entity->getEntityTypeId())->loadUnchanged($entity->id())) + // $data_hash = \array_map(self::generateHash(...), $data) + // $original_hash = \array_map(self::generateHash(...), $original) + // \array_diff($data_hash, $original_hash) + // \array_diff($original_hash, $data_hash) + // @endcode if ($original_hash !== NULL && \hash_equals($original_hash, $data_hash)) { // We've reset back to the original values. Clear the auto-save entry but // keep the hash. @@ -126,6 +137,11 @@ class AutoSaveManager implements EventSubscriberInterface { } $normalized[$name] = \array_map( static function (FieldItemInterface $item): array { + if ($item instanceof ComponentTreeItem) { + // Optimize component inputs to ensure the normalized value is + // determinative. + $item->optimizeInputs(); + } $value = $item->toArray(); foreach (\array_filter($item->getProperties(), static fn (TypedDataInterface $property) => $property instanceof PrimitiveInterface) as $property) { \assert($property instanceof PrimitiveInterface); @@ -175,16 +191,17 @@ class AutoSaveManager implements EventSubscriberInterface { /** * Gets all auto-save data. * - * @return array<string, array{data: array, owner: int, updated: int, entity_type: string, entity_id: string|int, label: string, langcode: ?string}> + * @return array<string, array{data: array, owner: int, updated: int, entity_type: string, entity_id: string|int, label: string, langcode: ?string, entity: ?EntityInterface}> * All auto-save data entries. */ - public function getAllAutoSaveList(): array { - return \array_map(static fn (object $entry) => $entry->data + + public function getAllAutoSaveList(bool $with_entities = FALSE): array { + return \array_map(fn (object $entry) => $entry->data + // Append the owner and updated data into each entry. [ // Remove the unique session key for anonymous users. 'owner' => \is_numeric($entry->owner) ? (int) $entry->owner : 0, 'updated' => $entry->updated, + 'entity' => $with_entities ? $this->entityTypeManager->getStorage($entry->data['entity_type'])->create($entry->data['data']) : NULL, ], $this->getTempStore()->getAll()); } diff --git a/src/Controller/ApiAutoSaveController.php b/src/Controller/ApiAutoSaveController.php index c9306531f39579af0daac98cd8951feffcb104d6..62fc8a9bed4f0c64260ba6c29681ea2a3a9297d9 100644 --- a/src/Controller/ApiAutoSaveController.php +++ b/src/Controller/ApiAutoSaveController.php @@ -112,9 +112,9 @@ final class ApiAutoSaveController extends ApiControllerBase { // User display names depend on configuration. $cache->addCacheableDependency($this->configFactory->get('user.settings')); - // Remove 'data' key because this will reduce the amount of data sent to the - // client and back to the server. - $all = \array_map(fn(array $item) => \array_diff_key($item, ['data' => '']), $all); + // Remove 'data' and 'entity' key because this will reduce the amount of + // data sent to the client and back to the server. + $all = \array_map(fn(array $item) => \array_diff_key($item, \array_flip(['data', 'entity'])), $all); $withUserDetails = \array_map(fn(array $item) => [ // @phpstan-ignore-next-line @@ -139,7 +139,7 @@ final class ApiAutoSaveController extends ApiControllerBase { public function post(Request $request): JsonResponse { $client_auto_saves = \json_decode($request->getContent(), TRUE); \assert(\is_array($client_auto_saves)); - $all_auto_saves = $this->autoSaveManager->getAllAutoSaveList(); + $all_auto_saves = $this->autoSaveManager->getAllAutoSaveList(TRUE); if ($validation_response = self::validateExpectedAutoSaves($client_auto_saves, $all_auto_saves)) { return $validation_response; } @@ -160,8 +160,7 @@ final class ApiAutoSaveController extends ApiControllerBase { $access_error_labels = []; $access_error_cache = new CacheableMetadata(); $loadedEntities = []; - foreach ($publish_auto_saves as $autoSaveKey => $auto_save) { - $entity = $this->entityTypeManager->getStorage($auto_save['entity_type'])->create($auto_save['data']); + foreach ($publish_auto_saves as $autoSaveKey => ['entity' => $entity]) { assert($entity instanceof EntityInterface); // Auto-saves always are updates to existing entities. This just used // EntityStorageInterface::create() to construct an entity object from diff --git a/src/Controller/ApiContentControllers.php b/src/Controller/ApiContentControllers.php index e76a773239ade6b9dadc6ce4095aa28dda9cfab0..6501fe4d606f526a881a5bdbce0e466b494df0e8 100644 --- a/src/Controller/ApiContentControllers.php +++ b/src/Controller/ApiContentControllers.php @@ -9,11 +9,14 @@ use Drupal\Core\Cache\CacheableJsonResponse; use Drupal\Core\Cache\CacheableMetadata; use Drupal\Core\Entity\EntityInterface; use Drupal\Core\Entity\EntityPublishedInterface; +use Drupal\Core\Entity\EntityReferenceSelection\SelectionPluginManagerInterface; use Drupal\Core\Entity\EntityTypeInterface; use Drupal\Core\Entity\ContentEntityInterface; use Drupal\Core\Entity\EntityTypeManagerInterface; use Drupal\Core\Entity\FieldableEntityInterface; use Drupal\Core\Entity\Query\QueryInterface; +use Drupal\Core\Language\LanguageManagerInterface; +use Drupal\Component\Transliteration\TransliterationInterface; use Drupal\Core\Render\RenderContext; use Drupal\Core\Render\RendererInterface; use Drupal\Core\Routing\RouteProviderInterface; @@ -24,6 +27,7 @@ use Drupal\experience_builder\Resource\XbResourceLink; use Drupal\experience_builder\Resource\XbResourceLinkCollection; use Drupal\experience_builder\XbUriDefinitions; use http\Exception\InvalidArgumentException; +use Symfony\Component\DependencyInjection\Attribute\Autowire; use Symfony\Component\HttpFoundation\JsonResponse; use Symfony\Component\HttpFoundation\Request; use Symfony\Component\HttpFoundation\Response; @@ -38,11 +42,20 @@ use Symfony\Component\HttpFoundation\Response; */ final class ApiContentControllers { + /** + * The maximum number of entity search results to return. + */ + private const int MAX_SEARCH_RESULTS = 50; + public function __construct( private readonly EntityTypeManagerInterface $entityTypeManager, private readonly RendererInterface $renderer, private readonly AutoSaveManager $autoSaveManager, + private readonly SelectionPluginManagerInterface $selectionManager, private readonly RouteProviderInterface $routeProvider, + private readonly LanguageManagerInterface $languageManager, + #[Autowire(service: 'transliteration')] + private readonly TransliterationInterface $transliteration, ) {} public function post(Request $request, string $entity_type): JsonResponse { @@ -107,60 +120,239 @@ final class ApiContentControllers { * * @see https://www.drupal.org/project/experience_builder/issues/3500052#comment-15966496 */ - public function list(string $entity_type): CacheableJsonResponse { - // @todo introduce pagination in https://www.drupal.org/i/3502691 + public function list(string $entity_type, Request $request): CacheableJsonResponse { + $langcode = $this->languageManager->getCurrentLanguage()->getId(); $storage = $this->entityTypeManager->getStorage($entity_type); - $query_cacheability = (new CacheableMetadata()) - ->addCacheContexts($storage->getEntityType()->getListCacheContexts()) - ->addCacheTags($storage->getEntityType()->getListCacheTags()); - $url_cacheability = new CacheableMetadata(); - // We don't need to worry about the status of the page, as we need both - // published and unpublished pages on the frontend. + + // Setup cacheability metadata + $query_cacheability = $this->createInitialCacheability($storage); + + // Create an entity query with access check + /** @var \Drupal\Core\Entity\ContentEntityTypeInterface $field_definition */ + $field_definition = $this->entityTypeManager->getDefinition($entity_type); + $revision_created_field_name = $field_definition->getRevisionMetadataKey('revision_created'); $entity_query = $storage->getQuery()->accessCheck(TRUE); - $ids = $this->executeQueryInRenderContext($entity_query, $query_cacheability); + + // Prepare search term and determine if we're performing a search + $search = $this->prepareSearchTerm($request, $langcode); + $search_ids = []; + + if ($search === '') { + // Only apply sorting and range limiting when not searching + $entity_query->sort((string) $revision_created_field_name, direction: 'DESC') + ->range(0, self::MAX_SEARCH_RESULTS); + } + else { + // Handle search functionality when search term is not empty + $label_key = $storage->getEntityType()->getKey('label'); + + // @todo Remove this in https://www.drupal.org/project/experience_builder/issues/3498525. + if ($label_key === FALSE) { + throw new \LogicException('Unhandled.'); + } + + // Get matching entity IDs through selection handler + $matching_ids = $this->getMatchingEntityIds($entity_type, $search); + + // Find matching unsaved entities + $matching_unsaved_ids = $this->getMatchingUnsavedIds($search, $langcode, $entity_type); + + // Return empty response if no matches found + if (empty($matching_ids) && empty($matching_unsaved_ids)) { + return $this->createEmptyResponse($query_cacheability); + } + $search_ids = $this->filterAndMergeIds($matching_ids, $matching_unsaved_ids); + } + + // Get entity IDs and filter by search results if needed. + $ids = !empty($search_ids) ? $search_ids : $this->executeQueryInRenderContext($entity_query, $query_cacheability); + + // Load entities and prepare content list. /** @var \Drupal\Core\Entity\EntityPublishedInterface[] $content_entities */ $content_entities = $storage->loadMultiple($ids); $content_list = []; foreach ($content_entities as $content_entity) { $id = (int) $content_entity->id(); - $generated_url = $content_entity->toUrl()->toString(TRUE); - - $autoSaveData = $this->autoSaveManager->getAutoSaveEntity($content_entity); - // Expose available entity operations. - $linkCollection = $this->getEntityOperations($content_entity); - $autoSaveEntity = $autoSaveData->isEmpty() ? NULL : $autoSaveData->entity; - - // @todo Dynamically use the entity 'path' key to determine which field is - // the path in https://drupal.org/i/3503446. - $autoSavePath = NULL; - if ($autoSaveEntity instanceof FieldableEntityInterface && $autoSaveEntity->hasField('path')) { - $autoSavePath = $autoSaveEntity->get('path')->first()?->getValue()['alias'] ?? \sprintf('/%s', \ltrim($autoSaveEntity->toUrl()->getInternalPath(), '/')); - } - - $content_list[$id] = [ - 'id' => $id, - 'title' => $content_entity->label(), - 'status' => $content_entity->isPublished(), - 'path' => $generated_url->getGeneratedUrl(), - 'autoSaveLabel' => $autoSaveEntity?->label(), - 'autoSavePath' => $autoSavePath, - // @see https://jsonapi.org/format/#document-links - 'links' => $linkCollection->asArray(), - ]; - $url_cacheability->addCacheableDependency($generated_url) - ->addCacheableDependency($linkCollection); + $content_list[$id] = $this->prepareEntityData($content_entity, $query_cacheability, $id); } + $json_response = new CacheableJsonResponse($content_list); + $this->addSearchCacheability($query_cacheability); // @todo add cache contexts for query params when introducing pagination in https://www.drupal.org/i/3502691. - $json_response->addCacheableDependency($query_cacheability) - ->addCacheableDependency($url_cacheability); - if (isset($autoSaveData)) { - $json_response->addCacheableDependency($autoSaveData); + $json_response->addCacheableDependency($query_cacheability); + + return $json_response; + } + + /** + * Prepares entity data for the response. + * + * @param \Drupal\Core\Entity\EntityPublishedInterface $content_entity + * The content entity to prepare data for. + * @param \Drupal\Core\Cache\CacheableMetadata $url_cacheability + * The cacheability metadata object to add URL dependencies to. + * @param int $id + * The entity ID. + * + * @return array + * An associative array containing the prepared entity data. + */ + private function prepareEntityData(EntityPublishedInterface $content_entity, CacheableMetadata $url_cacheability, int $id): array { + $generated_url = $content_entity->toUrl()->toString(TRUE); + + $autoSaveData = $this->autoSaveManager->getAutoSaveEntity($content_entity); + // Expose available entity operations. + $linkCollection = $this->getEntityOperations($content_entity); + $autoSaveEntity = $autoSaveData->isEmpty() ? NULL : $autoSaveData->entity; + + // @todo Dynamically use the entity 'path' key to determine which field is + // the path in https://drupal.org/i/3503446. + $autoSavePath = NULL; + if ($autoSaveEntity instanceof FieldableEntityInterface && $autoSaveEntity->hasField('path')) { + $autoSavePath = $autoSaveEntity->get('path')->first()?->getValue()['alias'] ?? \sprintf('/%s', \ltrim($autoSaveEntity->toUrl()->getInternalPath(), '/')); } + + $url_cacheability->addCacheableDependency($generated_url) + ->addCacheableDependency($linkCollection) + ->addCacheableDependency($autoSaveData); + + return [ + 'id' => $id, + 'title' => $content_entity->label(), + 'status' => $content_entity->isPublished(), + 'path' => $generated_url->getGeneratedUrl(), + 'autoSaveLabel' => $autoSaveEntity?->label(), + 'autoSavePath' => $autoSavePath, + // @see https://jsonapi.org/format/#document-links + 'links' => $linkCollection->asArray(), + ]; + } + + /** + * Creates initial cacheability metadata based on entity storage. + * + * @param \Drupal\Core\Entity\EntityStorageInterface $storage + * The entity storage. + * + * @return \Drupal\Core\Cache\CacheableMetadata + * The initialized cacheability metadata. + */ + private static function createInitialCacheability($storage): CacheableMetadata { + return (new CacheableMetadata()) + ->addCacheContexts($storage->getEntityType()->getListCacheContexts()) + ->addCacheTags($storage->getEntityType()->getListCacheTags()); + } + + /** + * Prepares and normalizes the search term from the request. + * + * @param \Symfony\Component\HttpFoundation\Request $request + * The HTTP request object. + * @param string $langcode + * The language code to use for transliteration. + * + * @return string + * The normalized search term. + */ + private function prepareSearchTerm(Request $request, string $langcode): string { + if ($request->query->has('search')) { + $search = trim((string) $request->query->get('search')); + return $this->transliteration->transliterate(mb_strtolower($search), $langcode); + } + return ''; + } + + /** + * Gets entity IDs matching the search term using selection handler. + * + * @param string $entity_type + * The entity type ID. + * @param string $search + * The search term to match against entities. + * + * @return array + * An array of entity IDs that match the search criteria. + */ + private function getMatchingEntityIds(string $entity_type, string $search): array { + /** @var \Drupal\Core\Entity\EntityReferenceSelection\SelectionInterface $selection_handler */ + $selection_handler = $this->selectionManager->getInstance([ + 'target_type' => $entity_type, + 'handler' => 'default', + 'match_operator' => 'CONTAINS', + ]); + + $matching_data = $selection_handler->getReferenceableEntities( + $search, + 'CONTAINS', + self::MAX_SEARCH_RESULTS + ); + + return isset($matching_data[$entity_type]) ? array_keys($matching_data[$entity_type]) : []; + } + + /** + * Gets unsaved entity IDs matching the search term. + * + * @param string $search + * The search term to match against entities. + * @param string $langcode + * The language code to use for transliteration. + * + * @return array + * An array of entity IDs that match the search criteria. + */ + private function getMatchingUnsavedIds(string $search, string $langcode, string $entity_type_id): array { + $matching_unsaved_ids = []; + $unsaved_entries = \array_filter($this->autoSaveManager->getAllAutoSaveList(TRUE), static fn (array $entry): bool => $entry['entity_type'] === $entity_type_id); + + foreach ($unsaved_entries as ['entity' => $entity]) { + \assert($entity instanceof EntityInterface); + $label = $this->transliteration->transliterate(mb_strtolower((string) $entity->label()), $langcode); + if (str_contains(mb_strtolower($label), $search)) { + $matching_unsaved_ids[] = $entity->id(); + } + } + + return $matching_unsaved_ids; + } + + /** + * Creates an empty response with proper cacheability metadata. + * + * @param \Drupal\Core\Cache\CacheableMetadata $query_cacheability + * The cacheability metadata to attach to the response. + * + * @return \Drupal\Core\Cache\CacheableJsonResponse + * An empty JSON response with cacheability metadata attached. + */ + private static function createEmptyResponse(CacheableMetadata $query_cacheability): CacheableJsonResponse { + $json_response = new CacheableJsonResponse([]); + self::addSearchCacheability($query_cacheability); + $json_response->addCacheableDependency($query_cacheability); return $json_response; } + /** + * Filters and merges entity IDs based on search results. + * + * @param array $matching_ids + * The array of entity IDs that match the search term. + * @param array $matching_unsaved_ids + * The array of unsaved entity IDs that match the search term. + * + * @return array + * The filtered and merged array of entity IDs. + */ + private static function filterAndMergeIds(array $matching_ids, array $matching_unsaved_ids): array { + // Sort by newest first (keys will be numeric IDs) and limit to max results + $ids = array_unique(array_merge($matching_ids, $matching_unsaved_ids)); + arsort($ids); + $ids = array_slice($ids, 0, self::MAX_SEARCH_RESULTS, TRUE); + + return $ids; + } + /** * Duplicates entity. * @@ -227,6 +419,17 @@ final class ApiContentControllers { return new TranslatableMarkup('Untitled @singular_entity_type_label', ['@singular_entity_type_label' => $entity_type->getSingularLabel()]); } + /** + * Adds search-related cache contexts and tags to cacheability metadata. + * + * @param \Drupal\Core\Cache\CacheableMetadata $query_cacheability + * The cacheability metadata object to add contexts and tags to. + */ + private static function addSearchCacheability(CacheableMetadata $query_cacheability): void { + $query_cacheability->addCacheContexts(['url.query_args:search']); + $query_cacheability->addCacheTags([AutoSaveManager::CACHE_TAG]); + } + public function getEntityOperations(EntityPublishedInterface $content_entity): XbResourceLinkCollection { $links = new XbResourceLinkCollection([]); // Link relation type => route name. diff --git a/src/Plugin/Field/FieldType/ComponentTreeItem.php b/src/Plugin/Field/FieldType/ComponentTreeItem.php index a809da6a8f1d72940ccf4551fb0d62f486f01db2..8eb5d74c498e00bd1e1026caeaf882feb2ccc8e8 100644 --- a/src/Plugin/Field/FieldType/ComponentTreeItem.php +++ b/src/Plugin/Field/FieldType/ComponentTreeItem.php @@ -591,13 +591,7 @@ class ComponentTreeItem extends FieldItemBase { if ($input_values === NULL && $source->requiresExplicitInput()) { throw new \LogicException(sprintf('Missing input for component instance with UUID %s', $component_instance_uuid)); } - - // Allow component source plugins to normalize the stored data. - $inputs = $this->getInputs(); - if ($inputs !== NULL && $source = $this->getComponent()?->getComponentSource()) { - $inputs = $source->optimizeExplicitInput($inputs); - $this->setInput($inputs); - } + $this->optimizeInputs(); // @todo Omit defaults that are stored at the content type template level, e.g. in core.entity_view_display.node.article.default.yml // $template_tree = '@todo'; // $template_inputs = '@todo'; @@ -652,4 +646,20 @@ class ComponentTreeItem extends FieldItemBase { return $changed; } + public function optimizeInputs(): void { + $source = $this->getComponent()?->getComponentSource(); + if ($source === NULL) { + // This could be running against data that has not been validated, in + // which case there is nothing we can do without a valid component or + // source. + return; + } + // Allow component source plugins to normalize the stored data. + $inputs = $this->getInputs(); + if ($inputs !== NULL) { + $inputs = $source->optimizeExplicitInput($inputs); + $this->setInput($inputs); + } + } + } diff --git a/tests/src/Functional/XbContentEntityHttpApiTest.php b/tests/src/Functional/XbContentEntityHttpApiTest.php index b30db9a376bf7f6c0eea5f512cc8f23725b4f8db..a529cc16db988fbaf3198d244957f3f8dae15421 100644 --- a/tests/src/Functional/XbContentEntityHttpApiTest.php +++ b/tests/src/Functional/XbContentEntityHttpApiTest.php @@ -93,7 +93,14 @@ final class XbContentEntityHttpApiTest extends HttpApiTestBase { $this->drupalLogin($user); // We have a cache tag for page 2 as it's the homepage, set in system.site // config. - $body = $this->assertExpectedResponse('GET', $url, [], 200, ['user.permissions'], [AutoSaveManager::CACHE_TAG, 'config:system.site', 'http_response', 'xb_page:2', 'xb_page_list'], 'UNCACHEABLE (request policy)', 'MISS'); + $expected_tags = [ + AutoSaveManager::CACHE_TAG, + 'config:system.site', + 'http_response', + 'xb_page:2', + 'xb_page_list', + ]; + $body = $this->assertExpectedResponse('GET', $url, [], 200, ['url.query_args:search', 'user.permissions'], $expected_tags, 'UNCACHEABLE (request policy)', 'MISS'); $no_auto_save_expected_pages = [ // Page 1 has a path alias. '1' => [ @@ -141,7 +148,39 @@ final class XbContentEntityHttpApiTest extends HttpApiTestBase { $no_auto_save_expected_pages, $body ); - $this->assertExpectedResponse('GET', $url, [], 200, ['user.permissions'], [AutoSaveManager::CACHE_TAG, 'config:system.site', 'http_response', 'xb_page:2', 'xb_page_list'], 'UNCACHEABLE (request policy)', 'HIT'); + $this->assertExpectedResponse('GET', $url, [], 200, ['url.query_args:search', 'user.permissions'], $expected_tags, 'UNCACHEABLE (request policy)', 'HIT'); + + // Test searching by query parameter + $search_url = Url::fromUri('base:/xb/api/v0/content/xb_page', ['query' => ['search' => 'Page 1']]); + // Because page 2 isn't in these results, we don't get its cache tag. + $expected_tags_without_page_2 = \array_diff($expected_tags, ['xb_page:2']); + // Confirm that the cache is not hit when a different request is made with query parameter. + $search_body = $this->assertExpectedResponse('GET', $search_url, [], 200, ['url.query_args:search', 'user.permissions'], $expected_tags_without_page_2, 'UNCACHEABLE (request policy)', 'MISS'); + $this->assertEquals( + [ + '1' => [ + 'id' => 1, + 'title' => 'Page 1', + 'status' => TRUE, + 'path' => base_path() . 'page-1', + 'autoSaveLabel' => NULL, + 'autoSavePath' => NULL, + 'links' => [ + // @todo https://www.drupal.org/i/3498525 should remove the hardcoded `xb_page` from these. + XbUriDefinitions::LINK_REL_EDIT => Url::fromUri('base:/xb/xb_page/1')->toString(), + XbUriDefinitions::LINK_REL_DUPLICATE => Url::fromUri('base:/xb/api/v0/content/xb_page')->toString(), + ], + ], + ], + $search_body + ); + // Confirm that the cache is hit when the same request is made again. + $this->assertExpectedResponse('GET', $search_url, [], 200, ['url.query_args:search', 'user.permissions'], $expected_tags_without_page_2, 'UNCACHEABLE (request policy)', 'HIT'); + + // Test searching by query parameter - substring match. + $substring_search_url = Url::fromUri('base:/xb/api/v0/content/xb_page', ['query' => ['search' => 'age']]); + $substring_search_body = $this->assertExpectedResponse('GET', $substring_search_url, [], 200, ['url.query_args:search', 'user.permissions'], $expected_tags, 'UNCACHEABLE (request policy)', 'MISS'); + $this->assertEquals($no_auto_save_expected_pages, $substring_search_body); $autoSaveManager = $this->container->get(AutoSaveManager::class); $page_1 = Page::load(1); @@ -156,7 +195,7 @@ final class XbContentEntityHttpApiTest extends HttpApiTestBase { $page_2->set('path', ['alias' => "/the-new-path"]); $autoSaveManager->saveEntity($page_2); - $body = $this->assertExpectedResponse('GET', $url, [], 200, ['user.permissions'], [AutoSaveManager::CACHE_TAG, 'config:system.site', 'http_response', 'xb_page:2', 'xb_page_list'], 'UNCACHEABLE (request policy)', 'MISS'); + $body = $this->assertExpectedResponse('GET', $url, [], 200, ['url.query_args:search', 'user.permissions'], $expected_tags, 'UNCACHEABLE (request policy)', 'MISS'); $auto_save_expected_pages = $no_auto_save_expected_pages; $auto_save_expected_pages['1']['autoSaveLabel'] = 'The updated title.'; $auto_save_expected_pages['1']['autoSavePath'] = '/the-updated-path'; @@ -166,7 +205,7 @@ final class XbContentEntityHttpApiTest extends HttpApiTestBase { $auto_save_expected_pages, $body ); - $this->assertExpectedResponse('GET', $url, [], 200, ['user.permissions'], [AutoSaveManager::CACHE_TAG, 'config:system.site', 'http_response', 'xb_page:2', 'xb_page_list'], 'UNCACHEABLE (request policy)', 'HIT'); + $this->assertExpectedResponse('GET', $url, [], 200, ['url.query_args:search', 'user.permissions'], $expected_tags, 'UNCACHEABLE (request policy)', 'HIT'); // Confirm that if path alias is empty, the system path is used, not the // existing alias if set. @@ -178,7 +217,7 @@ final class XbContentEntityHttpApiTest extends HttpApiTestBase { $page_2->set('path', NULL); $autoSaveManager->saveEntity($page_2); - $body = $this->assertExpectedResponse('GET', $url, [], 200, ['user.permissions'], [AutoSaveManager::CACHE_TAG, 'config:system.site', 'http_response', 'xb_page:2', 'xb_page_list'], 'UNCACHEABLE (request policy)', 'MISS'); + $body = $this->assertExpectedResponse('GET', $url, [], 200, ['url.query_args:search', 'user.permissions'], $expected_tags, 'UNCACHEABLE (request policy)', 'MISS'); $auto_save_expected_pages['1']['autoSavePath'] = '/page/1'; $auto_save_expected_pages['2']['autoSavePath'] = '/page/2'; $this->assertEquals( @@ -188,12 +227,12 @@ final class XbContentEntityHttpApiTest extends HttpApiTestBase { $autoSaveManager->delete($page_1); $autoSaveManager->delete($page_2); - $body = $this->assertExpectedResponse('GET', $url, [], 200, ['user.permissions'], [AutoSaveManager::CACHE_TAG, 'config:system.site', 'http_response', 'xb_page:2', 'xb_page_list'], 'UNCACHEABLE (request policy)', 'MISS'); + $body = $this->assertExpectedResponse('GET', $url, [], 200, ['url.query_args:search', 'user.permissions'], $expected_tags, 'UNCACHEABLE (request policy)', 'MISS'); $this->assertEquals( $no_auto_save_expected_pages, $body ); - $this->assertExpectedResponse('GET', $url, [], 200, ['user.permissions'], [AutoSaveManager::CACHE_TAG, 'config:system.site', 'http_response', 'xb_page:2', 'xb_page_list'], 'UNCACHEABLE (request policy)', 'HIT'); + $this->assertExpectedResponse('GET', $url, [], 200, ['url.query_args:search', 'user.permissions'], $expected_tags, 'UNCACHEABLE (request policy)', 'HIT'); } /** @@ -212,9 +251,7 @@ final class XbContentEntityHttpApiTest extends HttpApiTestBase { assert($user instanceof UserInterface); $this->drupalLogin($user); - // We have a cache tag for page 2 as it's the homepage, set in system.site - // config. - $body = $this->assertExpectedResponse('GET', $url, [], 200, Cache::mergeContexts(['user.permissions'], $extraCacheContexts), Cache::mergeTags([AutoSaveManager::CACHE_TAG, 'config:system.site', 'http_response', 'xb_page:2', 'xb_page_list'], $extraCacheTags), 'UNCACHEABLE (request policy)', 'MISS'); + $body = $this->assertExpectedResponse('GET', $url, [], 200, Cache::mergeContexts(['url.query_args:search', 'user.permissions'], $extraCacheContexts), Cache::mergeTags([AutoSaveManager::CACHE_TAG, 'config:system.site', 'http_response', 'xb_page:2', 'xb_page_list'], $extraCacheTags), 'UNCACHEABLE (request policy)', 'MISS'); assert(\is_array($body)); assert(\array_key_exists('1', $body) && \array_key_exists('links', $body['1'])); $this->assertEquals( @@ -293,7 +330,7 @@ final class XbContentEntityHttpApiTest extends HttpApiTestBase { $user = $this->createUser([Page::EDIT_PERMISSION, Page::DELETE_PERMISSION], 'administer_xb_page_user'); assert($user instanceof UserInterface); $this->drupalLogin($user); - $body = $this->assertExpectedResponse('GET', $url, [], 200, ['user.permissions'], [AutoSaveManager::CACHE_TAG, 'config:system.site', 'http_response', 'xb_page:2', 'xb_page_list'], 'UNCACHEABLE (request policy)', 'MISS'); + $body = $this->assertExpectedResponse('GET', $url, [], 200, ['url.query_args:search', 'user.permissions'], [AutoSaveManager::CACHE_TAG, 'config:system.site', 'http_response', 'xb_page:2', 'xb_page_list'], 'UNCACHEABLE (request policy)', 'MISS'); assert(\is_array($body)); assert(\array_key_exists('2', $body) && \array_key_exists('links', $body['2'])); $this->assertEquals( diff --git a/tests/src/Kernel/Controller/ApiContentControllersListTest.php b/tests/src/Kernel/Controller/ApiContentControllersListTest.php new file mode 100644 index 0000000000000000000000000000000000000000..2ae183c2d305a56a51c873367403650c149174f2 --- /dev/null +++ b/tests/src/Kernel/Controller/ApiContentControllersListTest.php @@ -0,0 +1,442 @@ +<?php + +declare(strict_types=1); + +namespace Drupal\Tests\experience_builder\Kernel\Controller; + +// cspell:ignore Gábor Hojtsy uniquesearchterm gà bor autosave searchterm + +use Drupal\Component\Transliteration\TransliterationInterface; +use Drupal\Core\Entity\ContentEntityInterface; +use Drupal\Core\Entity\EntityTypeManagerInterface; +use Drupal\experience_builder\AutoSave\AutoSaveManager; +use Drupal\experience_builder\Controller\ApiContentControllers; +use Drupal\experience_builder\Entity\Page; +use Drupal\KernelTests\KernelTestBase; +use Drupal\Tests\user\Traits\UserCreationTrait; +use Symfony\Component\HttpFoundation\JsonResponse; +use Symfony\Component\HttpFoundation\Request; + +/** + * Tests the ApiContentControllers::list() method. + * + * @group experience_builder + * @coversDefaultClass \Drupal\experience_builder\Controller\ApiContentControllers + */ +class ApiContentControllersListTest extends KernelTestBase { + use UserCreationTrait; + + /** + * Base path for the content API endpoint. + * + * @todo Strip `xb_page` in https://www.drupal.org/i/3498525, and add test coverage for other content entity types. + */ + private const API_BASE_PATH = '/api/xb/content/xb_page'; + + /** + * {@inheritdoc} + */ + protected static $modules = [ + 'experience_builder', + 'system', + 'xb_test_page', + 'user', + 'field', + 'text', + 'filter', + 'path_alias', + 'path', + 'media', + 'image', + ]; + + /** + * The API Content controller service. + * + * @var \Drupal\experience_builder\Controller\ApiContentControllers + */ + protected ApiContentControllers $apiContentController; + + /** + * The AutoSaveManager service. + * + * @var \Drupal\experience_builder\AutoSave\AutoSaveManager + */ + protected AutoSaveManager $autoSaveManager; + + /** + * The entity type manager service. + * + * @var \Drupal\Core\Entity\EntityTypeManagerInterface + */ + protected EntityTypeManagerInterface $entityTypeManager; + + /** + * The transliteration service. + * + * @var \Drupal\Component\Transliteration\TransliterationInterface + */ + protected TransliterationInterface $transliteration; + + /** + * Test pages. + * + * @var \Drupal\experience_builder\Entity\Page[] + */ + protected $pages = []; + + /** + * {@inheritdoc} + */ + protected function setUp(): void { + parent::setUp(); + + // Skip the test for PostgreSQL and SQLite databases. + $database_type = $this->container->get('database')->driver(); + if ($database_type === 'pgsql' || $database_type === 'sqlite') { + $this->markTestSkipped('This test is only for MySQL/MariaDB database drivers.'); + } + + $this->installEntitySchema('user'); + $this->installEntitySchema('xb_page'); + $this->installEntitySchema('path_alias'); + $this->installEntitySchema('media'); + $this->installConfig(['system', 'field', 'filter', 'path_alias']); + + // Create a user with appropriate permissions. + $this->setUpCurrentUser([], ['access content', Page::CREATE_PERMISSION, Page::EDIT_PERMISSION, Page::DELETE_PERMISSION]); + + $this->apiContentController = $this->container->get(ApiContentControllers::class); + $this->autoSaveManager = $this->container->get(AutoSaveManager::class); + $this->entityTypeManager = $this->container->get(EntityTypeManagerInterface::class); + $this->transliteration = $this->container->get('transliteration'); + + $this->createTestPages(); + } + + /** + * Creates test pages for the tests. + */ + protected function createTestPages(): void { + $page1 = Page::create([ + 'title' => "Published XB Page", + 'status' => TRUE, + 'path' => ['alias' => "/page-1"], + ]); + $page1->save(); + $this->pages['published'] = $page1; + + $page2 = Page::create([ + 'title' => "Unpublished XB Page", + 'status' => FALSE, + ]); + $page2->save(); + $this->pages['unpublished'] = $page2; + + // Create page with unique searchable title. + $page3 = Page::create([ + 'title' => "UniqueSearchTerm XB Page", + 'status' => TRUE, + 'path' => ['alias' => "/page-3"], + ]); + $page3->save(); + $this->pages['searchable'] = $page3; + + // Create a page with diacritical marks (accents) in title. + $page4 = Page::create([ + 'title' => "Gábor Hojtsy Page", + 'status' => TRUE, + 'path' => ['alias' => "/page-4"], + ]); + $page4->save(); + $this->pages['accented'] = $page4; + } + + /** + * Creates auto-save data for an entity. + * + * @param \Drupal\Core\Entity\ContentEntityInterface $entity + * The entity to create auto-save data for. + * @param string $label + * The label to use in auto-save data. + * @param string|null $path + * The path alias to use in auto-save data. + */ + protected function createAutoSaveData(ContentEntityInterface $entity, string $label, ?string $path = NULL): void { + $autoSaveEntity = $entity::create($entity->toArray()); + $autoSaveEntity->set('title', $label); + if ($path !== NULL) { + $autoSaveEntity->set('path', ['alias' => $path]); + } + $this->autoSaveManager->saveEntity($autoSaveEntity); + } + + /** + * Helper method to execute list request and return parsed response data. + * + * @param array $query + * Optional query parameters. + * + * @return array + * Parsed JSON response data. + */ + protected function executeListRequest(array $query = []): array { + $request = Request::create(self::API_BASE_PATH, 'GET', $query); + $response = $this->apiContentController->list(Page::ENTITY_TYPE_ID, $request); + self::assertInstanceOf(JsonResponse::class, $response); + + $content = $response->getContent(); + self::assertNotEmpty($content); + + return json_decode($content, TRUE); + } + + /** + * Helper method to validate page data in response. + * + * @param array $response_data + * The response data to validate. + * @param array $expected_search_result_data + * Expected search result data to validate against. + * @param array $expected_auto_save_data + * Optional auto-save data to validate. + */ + protected function assertValidPageData(array $response_data, array $expected_search_result_data, array $expected_auto_save_data = []): void { + // Assert that all expected fields are present and correct + foreach ($expected_search_result_data as $key => $expected_value) { + self::assertArrayHasKey($key, $response_data, "Response should contain key: {$key}"); + self::assertSame($expected_value, $response_data[$key], "Value for {$key} should match expected value"); + } + + if (!empty($expected_auto_save_data['label'])) { + self::assertSame($expected_auto_save_data['label'], $response_data['autoSaveLabel']); + } + + if (isset($expected_auto_save_data['path'])) { + self::assertSame($expected_auto_save_data['path'], $response_data['autoSavePath']); + } + } + + /** + * Tests basic list functionality with no search parameter. + * + * @covers ::list + */ + public function testBasicList(): void { + $response = $this->apiContentController->list('xb_page', Request::create(self::API_BASE_PATH, 'GET')); + + self::assertInstanceOf(JsonResponse::class, $response, 'Response should be a JsonResponse'); + $content = $response->getContent(); + self::assertNotEmpty($content, 'Response content should not be empty'); + + $data = json_decode($content, TRUE); + self::assertIsArray($data, 'Response data should be an array'); + + self::assertCount(count($this->pages), $data, 'Response should contain all test pages'); + + foreach ($this->pages as $page) { + $page_id = (int) $page->id(); + self::assertArrayHasKey($page_id, $data, "Page {$page_id} should be in the results"); + $this->assertValidPageData($data[$page_id], $this->getPageData($page)); + } + + $cache_metadata = $response->getCacheableMetadata(); + + // Expected cache tags should include entity list tag, auto-save tag, + // and individual entity tags + $expected_cache_tags = [ + 'xb_page_list', + AutoSaveManager::CACHE_TAG, + ]; + $actual_cache_tags = $cache_metadata->getCacheTags(); + + $expected_cache_contexts = [ + 'user.permissions', + 'url.query_args:search', + ]; + $actual_cache_contexts = $cache_metadata->getCacheContexts(); + self::assertEquals($expected_cache_tags, $actual_cache_tags, 'All expected cache tags should be present'); + self::assertEquals($expected_cache_contexts, $actual_cache_contexts, 'All expected cache contexts should be present'); + } + + /** + * Tests list method with search parameter. + * + * @covers ::list + */ + public function testListWithSearch(): void { + $data = $this->executeListRequest(['search' => 'UniqueSearchTerm']); + self::assertCount(1, $data, 'Search should return exactly one result'); + $page_id = (int) $this->pages['searchable']->id(); + self::assertArrayHasKey($page_id, $data, "Searchable page should be in the results"); + $this->assertValidPageData($data[$page_id], $this->getPageData($this->pages['searchable'])); + + $data = $this->executeListRequest(['search' => 'XB Page']); + self::assertGreaterThan(1, count($data), 'Search should return multiple results'); + + $data = $this->executeListRequest(['search' => 'NoMatchingTerm']); + self::assertEmpty($data, 'Search with no matches should return empty array'); + + $data = $this->executeListRequest(['search' => 'uniquesearchterm']); + self::assertCount(1, $data, 'Search should be case-insensitive'); + + $data = $this->executeListRequest(['search' => 'Gábor']); + self::assertCount(1, $data, 'Search with accented character should match page'); + $page_id = (int) $this->pages['accented']->id(); + self::assertArrayHasKey($page_id, $data, "Accented page should be in the results"); + $this->assertValidPageData($data[$page_id], $this->getPageData($this->pages['accented'])); + + $data = $this->executeListRequest(['search' => 'gabor']); + self::assertCount(1, $data, 'Search without accent should match page with accented character'); + $page_id = (int) $this->pages['accented']->id(); + self::assertArrayHasKey($page_id, $data, "Accented page should be in the results"); + $this->assertValidPageData($data[$page_id], $this->getPageData($this->pages['accented'])); + + $data = $this->executeListRequest(['search' => 'puBliSHed']); + self::assertCount(2, $data, 'Search with mixed case should match published and unpublished page'); + } + + /** + * Tests search when no searchable content entities (currently only pages) exist yet. + * + * @covers ::list + */ + public function testEmptyEntityList(): void { + foreach ($this->pages as $page) { + $page->delete(); + } + $this->pages = []; + + $data = $this->executeListRequest(); + self::assertSame([], $data, 'Search should return empty array when no entities exist'); + + // Now create a temporary page with auto-save data and then delete it + // to test interaction with orphaned auto-save data + $temp_page = Page::create([ + 'title' => "Temporary Page for AutoSave", + 'status' => TRUE, + ]); + $temp_page->save(); + + $this->createAutoSaveData($temp_page, "AutoSave Only Content", "/autosave-only"); + + // Verify auto-save data was created by checking that it would be found if entity existed + $temp_page_id = (int) $temp_page->id(); + $data = $this->executeListRequest(['search' => 'AutoSave Only Content']); + self::assertCount(1, $data, 'Auto-save data should be found when entity exists'); + self::assertArrayHasKey($temp_page_id, $data, 'Page with auto-save data should be in results'); + + // Now delete the page, leaving orphaned auto-save data + $temp_page->delete(); + + // Test search with no entities but orphaned auto-save data should return empty + $data = $this->executeListRequest(['search' => 'AutoSave Only Content']); + self::assertSame([], $data, 'Search should return empty results when auto-save data exists but no entities exist'); + + // Test search with general term should also return empty + $data = $this->executeListRequest(['search' => 'Temporary']); + self::assertSame([], $data, 'Search should return empty results when searching for deleted entity content'); + } + + /** + * Tests that search results are sorted by most recently updated first. + * + * @covers ::list + * @covers ::filterAndMergeIds + */ + public function testSearchSortOrder(): void { + // Create test pages with the same search term but different titles + $pages_data = [ + 'page1' => "XB Search Term One", + 'page2' => "XB Search Term Two", + 'page3' => "XB Search Term Three", + ]; + + $page_ids = []; + + foreach ($pages_data as $key => $title) { + $page = Page::create([ + 'title' => $title, + 'status' => TRUE, + ]); + $page->save(); + $this->pages[$key] = $page; + $page_ids[$key] = (int) $page->id(); + } + + // Update the pages in reverse order to change their revision timestamps + // This ensures page1 is most recently updated, followed by page2, then page3 + $update_order = array_reverse(array_keys($pages_data)); + foreach ($update_order as $key) { + $this->pages[$key]->set('title', "{$pages_data[$key]} Updated"); + $this->pages[$key]->save(); + } + + $data = $this->executeListRequest(['search' => 'XB Search Term']); + self::assertCount(3, $data, 'Search should return all three matching pages'); + + // Get the IDs in order they appear in the results + $result_ids = array_map(function ($item) { + return $item['id']; + }, $data); + + $result_ids = array_keys($result_ids); + + // Verify the order is by most recently updated (page1, page2, page3) + self::assertSame($page_ids['page1'], $result_ids[2]); + self::assertSame($page_ids['page2'], $result_ids[1]); + self::assertSame($page_ids['page3'], $result_ids[0]); + } + + /** + * Tests auto-save entries in search results. + * + * @covers ::list + * @covers ::filterAndMergeIds + */ + public function testSearchWithAutoSave(): void { + $page = Page::create([ + 'title' => "Original Title Page", + 'status' => TRUE, + 'path' => ['alias' => "/original-page"], + ]); + $page->save(); + + $this->createAutoSaveData($page, "AutoSave SearchTerm Title", "/autosave-path"); + $data = $this->executeListRequest(['search' => 'AutoSave SearchTerm']); + self::assertCount(1, $data, 'Search should find the page with matching auto-save data'); + + // Verify that both original and auto-save data are correctly included + $page_id = (int) $page->id(); + self::assertArrayHasKey($page_id, $data); + self::assertSame($page_id, $data[$page_id]['id']); + self::assertSame($page->label(), $data[$page_id]['title']); + self::assertSame("AutoSave SearchTerm Title", $data[$page_id]['autoSaveLabel']); + self::assertSame("/autosave-path", $data[$page_id]['autoSavePath']); + + $data = $this->executeListRequest(['search' => 'autosave searchterm']); + self::assertCount(1, $data, 'Search should be case-insensitive for auto-save data'); + + $data = $this->executeListRequest(['search' => 'Original Title']); + self::assertCount(1, $data, 'Search should find original title when auto-save exists'); + } + + /** + * Extracts essential data from a Page entity for test assertions. + * + * @param \Drupal\experience_builder\Entity\Page $page + * The Page entity to extract data from. + * + * @return array + * An array containing the page's ID, title, status, and path. + */ + private function getPageData(Page $page) { + return [ + 'id' => (int) $page->id(), + 'title' => $page->label(), + 'status' => $page->isPublished(), + 'path' => $page->toUrl()->toString(), + ]; + } + +} diff --git a/ui/src/components/navigation/Navigation.tsx b/ui/src/components/navigation/Navigation.tsx index 382b12f9be18786c8ca4fa0c6d8f4e4e3fe02f9c..3175637f86e4d1178678f9aebb1f406de281f7ab 100644 --- a/ui/src/components/navigation/Navigation.tsx +++ b/ui/src/components/navigation/Navigation.tsx @@ -42,7 +42,7 @@ const ContentGroup = ({ }) => { if (items.length === 0) { return ( - <Callout.Root size="1" color="gray"> + <Callout.Root size="1" color="gray" data-testid="xb-navigation-results"> <Callout.Icon> <InfoCircledIcon /> </Callout.Icon> @@ -56,12 +56,18 @@ const ContentGroup = ({ <Heading as="h5" size="1" color="gray"> {title} </Heading> - <Flex direction="column" gap="2" mt="2"> + <Flex + data-testid="xb-navigation-results" + direction="column" + gap="2" + mt="2" + > {items.map((item) => { return ( <Flex direction={'row'} align={'center'} + role={'list'} mr="4" p="1" pr="2" @@ -70,6 +76,7 @@ const ContentGroup = ({ data-xb-page-id={item.id} > <Flex + role={'listitem'} className={styles.pageLink} flexGrow="1" onClick={onSelect ? () => onSelect(item) : undefined} @@ -224,7 +231,7 @@ const Navigation = ({ <Flex direction="row" gap="2" mb="4"> <form className={styles.search} - onSubmit={(event: FormEvent<HTMLFormElement>) => { + onChange={(event: FormEvent<HTMLFormElement>) => { event.preventDefault(); const form = event.currentTarget; const formElements = form.elements as typeof form.elements & { @@ -237,6 +244,7 @@ const Navigation = ({ id="xb-navigation-search" placeholder="Search…" radius="medium" + aria-label="Search Pages" size="1" > <TextField.Slot> diff --git a/ui/src/components/pageInfo/PageInfo.tsx b/ui/src/components/pageInfo/PageInfo.tsx index b874ce0025a2232e2785d2e587f5e40752d97f31..9bf9aaea0f0e503f2cc23ac512e3480ff7d23341 100644 --- a/ui/src/components/pageInfo/PageInfo.tsx +++ b/ui/src/components/pageInfo/PageInfo.tsx @@ -14,6 +14,7 @@ import { Popover, } from '@radix-ui/themes'; import { useAppSelector } from '@/app/hooks'; +import { debounce } from 'lodash'; import { selectPageData } from '@/features/pageData/pageDataSlice'; import type { ReactElement } from 'react'; import { useEffect } from 'react'; @@ -30,6 +31,7 @@ import { } from '@/services/content'; import useEditorNavigation from '@/hooks/useEditorNavigation'; import { useErrorBoundary } from 'react-error-boundary'; +import { useState } from 'react'; import type { ContentStub } from '@/types/Content'; import ErrorCard from '@/components/error/ErrorCard'; import PageStatus from '@/components/pageStatus/PageStatus'; @@ -68,12 +70,16 @@ const PageInfo = () => { const entity_form_fields = useAppSelector(selectPageData); const title = entity_form_fields[`${xbSettings.entityTypeKeys.label}[0][value]`]; - + const [searchTerm, setSearchTerm] = useState<string>(''); const { data: pageItems, isLoading: isPageItemsLoading, error: pageItemsError, - } = useGetContentListQuery('xb_page'); + } = useGetContentListQuery({ + // @todo Generalize in https://www.drupal.org/i/3498525 + entityType: 'xb_page', + search: searchTerm, + }); const entityId = useAppSelector(selectEntityId); const entityType = useAppSelector(selectEntityType); const baseUrl = getBaseUrl(); @@ -136,6 +142,21 @@ const PageInfo = () => { setEditorEntity('xb_page', String(item.id)); } + useEffect(() => { + const debouncedSearch = debounce((term: string) => { + if (term.length === 0 || term.length >= 3) { + setSearchTerm(term); + } + }, 400); + + // Set up the debounced search effect + debouncedSearch(searchTerm); + + return () => { + debouncedSearch.cancel(); + }; + }, [searchTerm]); + useEffect(() => { if (isCreateContentSuccess) { setEditorEntity( @@ -188,7 +209,7 @@ const PageInfo = () => { loading={isPageItemsLoading} items={pageItems || []} onNewPage={handleNewPage} - onSearch={handleNonWorkingBtn} + onSearch={(value) => setSearchTerm(value)} onSelect={handleOnSelect} onRename={handleNonWorkingBtn} onDuplicate={handleDuplication} diff --git a/ui/src/services/content.ts b/ui/src/services/content.ts index 820be5ee8322dec19c55b94f5508d2ecf36c1ce5..4b9ac8742d8bcbd1e70d78e24c128b010441f153 100644 --- a/ui/src/services/content.ts +++ b/ui/src/services/content.ts @@ -20,15 +20,36 @@ export interface CreateContentRequest { entity_id?: string; entity_type: string; } + +export interface ContentListParams { + entityType: string; + search?: string; +} + export const contentApi = createApi({ reducerPath: 'contentApi', baseQuery, tagTypes: ['Content'], endpoints: (builder) => ({ - getContentList: builder.query<ContentStub[], string>({ - query: (entityType) => `/xb/api/v0/content/${entityType}`, + getContentList: builder.query<ContentStub[], ContentListParams>({ + query: ({ entityType, search }) => { + const params = new URLSearchParams(); + if (search) { + const normalizedSearch = search.toLowerCase().trim(); + params.append('search', normalizedSearch); + } + return { + url: `/xb/api/v0/content/${entityType}`, + params: search ? params : undefined, + }; + }, transformResponse: (response: ContentListResponse) => { - return Object.values(response); + // Convert response object to array while preserving order + // Sort by ID in descending order to maintain newest first order + return Object.values(response).sort((a, b) => { + // Convert IDs to numbers for comparison, newer IDs are typically higher + return Number(b.id) - Number(a.id); + }); }, providesTags: [{ type: 'Content', id: 'LIST' }], }), diff --git a/ui/tests/e2e/navigation.cy.js b/ui/tests/e2e/navigation.cy.js index 22b6230aeef49dcacf5d4f20c795ae83b8e68d10..01e81f0170eb53e0efb4d0ba4d4fe58e2b6c0e18 100644 --- a/ui/tests/e2e/navigation.cy.js +++ b/ui/tests/e2e/navigation.cy.js @@ -1,5 +1,6 @@ const navigationButtonTestId = 'xb-navigation-button'; const navigationContentTestId = 'xb-navigation-content'; +const navigationResultsTestId = 'xb-navigation-results'; const navigationNewButtonTestId = 'xb-navigation-new-button'; const navigationNewPageButtonTestId = 'xb-navigation-new-page-button'; @@ -43,6 +44,41 @@ describe('Navigation functionality', () => { .and('contain.text', 'Empty Page'); }); + it('Verify if search works', () => { + cy.loadURLandWaitForXBLoaded({ url: 'xb/xb_page/1' }); + cy.findByTestId(navigationButtonTestId).click(); + cy.findByLabelText('Search Pages').clear(); + cy.findByLabelText('Search Pages').type('ome'); + cy.findByTestId(navigationResultsTestId) + .findAllByRole('listitem') + .should(($children) => { + const count = $children.length; + expect(count).to.be.eq(1); + expect($children.text()).to.contain('Homepage'); + expect($children.text()).to.not.contain('Empty Page'); + }); + cy.findByLabelText('Search Pages').clear(); + cy.findByLabelText('Search Pages').type('NonExistentPage'); + cy.findByTestId(navigationResultsTestId) + .findAllByRole('listitem') + .should(($children) => { + const count = $children.length; + expect(count).to.be.eq(0); + }); + cy.findByTestId(navigationResultsTestId) + .findByText('No pages found', { exact: false }) + .should('exist'); + cy.findByLabelText('Search Pages').clear(); + cy.findByTestId(navigationResultsTestId) + .findAllByRole('listitem') + .should(($children) => { + const count = $children.length; + expect(count).to.be.eq(3); + expect($children.text()).to.contain('Homepage'); + expect($children.text()).to.contain('Empty Page'); + }); + }); + it('Clicking "New page" creates a new page and navigates to it', () => { cy.loadURLandWaitForXBLoaded({ url: 'xb/xb_page/1' }); @@ -214,8 +250,8 @@ describe('Navigation functionality', () => { cy.wait('@deletePage').its('response.statusCode').should('eq', 204); // Wait for the GET request to the list endpoint which should be triggered by the deletion of a page. cy.wait('@getList').its('response.statusCode').should('eq', 200); - cy.url().should('not.contain', '/xb/xb_page/4'); - cy.url().should('contain', '/xb/xb_page/1'); + cy.url().should('not.contain', '/xb/xb_page/3'); + cy.url().should('contain', '/xb/xb_page/5'); cy.findByTestId(navigationButtonTestId).click(); cy.findByTestId(navigationContentTestId) .should('exist')