diff --git a/openapi.yml b/openapi.yml index 7e7e0d4ff6310b76e6dda8c9461ed6cdbe90aa9f..2ef2f5793d9311149ceb45274dd22380c277298e 100644 --- a/openapi.yml +++ b/openapi.yml @@ -855,6 +855,18 @@ 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 + examples: + Find content with title 'My page': + value: 'My page' + Search for marketing update content: + value: 'Marketing update' responses: 200: description: Page list generated successfully. diff --git a/src/AutoSave/AutoSaveManager.php b/src/AutoSave/AutoSaveManager.php index bac223534e6d669aaa4f09b04278dbb46fa5c46d..0d579a49cab314868866a62ef6f240cf091337a0 100644 --- a/src/AutoSave/AutoSaveManager.php +++ b/src/AutoSave/AutoSaveManager.php @@ -191,16 +191,18 @@ 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 + - // Append the owner and updated data into each entry. + 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, and an entity object + // upon request. [ // 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..a18a351aad632a8ddf3d3d99a23d0c99a45e6fdd 100644 --- a/src/Controller/ApiContentControllers.php +++ b/src/Controller/ApiContentControllers.php @@ -4,29 +4,40 @@ declare(strict_types=1); namespace Drupal\experience_builder\Controller; +use Drupal\Component\Utility\NestedArray; use Drupal\Core\Access\AccessResult; use Drupal\Core\Cache\CacheableJsonResponse; use Drupal\Core\Cache\CacheableMetadata; +use Drupal\Core\Cache\RefinableCacheableDependencyInterface; +use Drupal\Core\Entity\ContentEntityTypeInterface; use Drupal\Core\Entity\EntityInterface; use Drupal\Core\Entity\EntityPublishedInterface; +use Drupal\Core\Entity\EntityReferenceSelection\SelectionInterface; +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\LanguageInterface; +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; use Drupal\Core\StringTranslation\TranslatableMarkup; use Drupal\Core\Url; use Drupal\experience_builder\AutoSave\AutoSaveManager; +use Drupal\experience_builder\Entity\Page; 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; +use Symfony\Component\HttpKernel\Exception\BadRequestHttpException; /** * HTTP API for interacting with XB-eligible Content entity types. @@ -38,11 +49,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,58 +127,185 @@ 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 { + if ($entity_type !== Page::ENTITY_TYPE_ID) { + throw new BadRequestHttpException('Only the `xb_page` content entity type is supported right now, will be generalized in a child issue of https://www.drupal.org/project/experience_builder/issues/3498525.'); + } $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. - $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 = $request->query->get('search', default: NULL); + $query_cacheability->addCacheContexts(['url.query_args:search']); + + // Get the (ordered) list of content entity IDs to load, either: + // - without a search term: get the N newest content entities + if ($search === NULL) { + $content_entity_type = $this->entityTypeManager->getDefinition($entity_type); + assert($content_entity_type instanceof ContentEntityTypeInterface); + $revision_created_field_name = $content_entity_type->getRevisionMetadataKey('revision_created'); + // @todo Ensure this is one of the required characteristics in https://www.drupal.org/project/experience_builder/issues/3498525. + assert(is_string($revision_created_field_name)); + + $entity_query = $storage->getQuery() + ->accessCheck(TRUE) + ->sort($revision_created_field_name, direction: 'DESC') + ->range(0, self::MAX_SEARCH_RESULTS); + + $ids = $this->executeQueryInRenderContext($entity_query, $query_cacheability); + } + // - with a search term: get the N best matches using the entity reference + // selection plugin, get all auto-save matches, and combine both + else { + assert(is_string($search)); + $search = trim($search); + $ids = $this->filterAndMergeIds( + // TRICKY: covered by the "list cacheability" at the top. + $this->getMatchingStoredEntityIds($entity_type, $search), + $this->getMatchingAutoSavedEntityIds($entity_type, $search, $query_cacheability) + ); + } + /** @var \Drupal\Core\Entity\EntityPublishedInterface[] $content_entities */ $content_entities = $storage->loadMultiple($ids); $content_list = []; + foreach ($content_entities as $id => $content_entity) { + $content_list[$id] = $this->normalize($content_entity, $query_cacheability); + } - foreach ($content_entities as $content_entity) { - $id = (int) $content_entity->id(); - $generated_url = $content_entity->toUrl()->toString(TRUE); + $json_response = new CacheableJsonResponse($content_list); + // @todo add cache contexts for query params when introducing pagination in https://www.drupal.org/i/3502691. + $json_response->addCacheableDependency($query_cacheability); - $autoSaveData = $this->autoSaveManager->getAutoSaveEntity($content_entity); - // Expose available entity operations. - $linkCollection = $this->getEntityOperations($content_entity); - $autoSaveEntity = $autoSaveData->isEmpty() ? NULL : $autoSaveData->entity; + return $json_response; + } - // @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(), '/')); - } + /** + * Normalizes content entity. + * + * @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. + * + * @return array + * An associative array containing the normalized entity. + */ + private function normalize(EntityPublishedInterface $content_entity, CacheableMetadata $url_cacheability): array { + $generated_url = $content_entity->toUrl()->toString(TRUE); - $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); + $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(), '/')); } - $json_response = new CacheableJsonResponse($content_list); - // @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); + + $url_cacheability->addCacheableDependency($generated_url) + ->addCacheableDependency($linkCollection) + ->addCacheableDependency($autoSaveData); + + return [ + 'id' => (int) $content_entity->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(), + ]; + } + + /** + * Gets N first saved ("live") entity IDs matching the search term. + * + * @param string $entity_type_id + * The entity type ID. + * @param string $search + * The (transliterated) search term to match against entities. + * + * @return array + * An array of entity IDs that match the search term. + */ + private function getMatchingStoredEntityIds(string $entity_type_id, string $search): array { + /** @var \Drupal\Core\Entity\EntityReferenceSelection\SelectionInterface $selection_handler */ + $selection_handler = $this->selectionManager->getInstance([ + 'target_type' => $entity_type_id, + 'handler' => 'default', + ]); + assert($selection_handler instanceof SelectionInterface); + $matching_data = $selection_handler->getReferenceableEntities( + $search, + 'CONTAINS', + self::MAX_SEARCH_RESULTS + ); + + return array_keys(NestedArray::mergeDeepArray($matching_data, TRUE)); + } + + /** + * Gets N first auto-saved ("draft") entity IDs matching the search term. + * + * @param string $entity_type_id + * The entity type ID. + * @param string $search + * The search term to match against entities. + * @param \Drupal\Core\Cache\RefinableCacheableDependencyInterface $cacheability + * The cacheability of the given query, to be refined to match the + * refinements made to the query. + * + * @return array + * An array of entity IDs that match the search criteria. + */ + private function getMatchingAutoSavedEntityIds(string $entity_type_id, string $search, RefinableCacheableDependencyInterface $cacheability): array { + $cacheability->addCacheTags([AutoSaveManager::CACHE_TAG]); + $auto_saved_entities_of_type = \array_filter($this->autoSaveManager->getAllAutoSaveList(TRUE), static fn (array $entry): bool => $entry['entity_type'] === $entity_type_id); + + // Transliterate the search term using the negotiated content language. + $cacheability->addCacheContexts(['languages:' . LanguageInterface::TYPE_CONTENT]); + $langcode = $this->languageManager->getCurrentLanguage(LanguageInterface::TYPE_CONTENT)->getId(); + $transliterated_search = $this->transliteration->transliterate(mb_strtolower($search), $langcode); + + // Check if the transliterated search term is contained by any of the auto- + // saved entities of this type. + $matching_unsaved_ids = []; + foreach ($auto_saved_entities_of_type as ['entity' => $entity]) { + \assert($entity instanceof EntityInterface); + $transliterated_label = $this->transliteration->transliterate(mb_strtolower((string) $entity->label()), $langcode); + if (str_contains($transliterated_label, $transliterated_search)) { + $matching_unsaved_ids[] = $entity->id(); + } } - return $json_response; + + return $matching_unsaved_ids; + } + + /** + * 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; } /** diff --git a/tests/src/Functional/XbContentEntityHttpApiTest.php b/tests/src/Functional/XbContentEntityHttpApiTest.php index b30db9a376bf7f6c0eea5f512cc8f23725b4f8db..95dd9b907207653daed1634f422c666dd698fb4c 100644 --- a/tests/src/Functional/XbContentEntityHttpApiTest.php +++ b/tests/src/Functional/XbContentEntityHttpApiTest.php @@ -7,6 +7,7 @@ namespace Drupal\Tests\experience_builder\Functional; use Drupal\Core\Cache\Cache; use Drupal\Core\Entity\ContentEntityInterface; use Drupal\Core\Entity\EntityInterface; +use Drupal\Core\Language\LanguageInterface; use Drupal\Core\Url; use Drupal\experience_builder\AutoSave\AutoSaveManager; use Drupal\experience_builder\Entity\Page; @@ -93,7 +94,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 +149,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, ['languages:' . LanguageInterface::TYPE_CONTENT, '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, ['languages:' . LanguageInterface::TYPE_CONTENT, '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, ['languages:' . LanguageInterface::TYPE_CONTENT, '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 +196,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 +206,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 +218,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 +228,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'); } /** @@ -211,10 +251,9 @@ final class XbContentEntityHttpApiTest extends HttpApiTestBase { $user = $this->createUser($permissions); 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 +332,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..83fa9c1ac49b04c053e54579bce360996f5b035b --- /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\EntityPublishedInterface; +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(); + + $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 assertValidResultData(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->assertValidResultData($data[$page_id], $this->getEntityData($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', + // Access check on home-page adds this. + 'config:system.site', + AutoSaveManager::CACHE_TAG, + ]; + $actual_cache_tags = $cache_metadata->getCacheTags(); + + $expected_cache_contexts = [ + 'url.query_args:search', + 'user.permissions', + ]; + $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->assertValidResultData($data[$page_id], $this->getEntityData($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'); + + $database_type = $this->container->get('database')->driver(); + if ($database_type !== 'pgsql' && $database_type !== 'sqlite') { + // LIKE queries that perform transliteration are MYSQL/MariaDB specific. + $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->assertValidResultData($data[$page_id], $this->getEntityData($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->assertValidResultData($data[$page_id], $this->getEntityData($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\Core\Entity\EntityPublishedInterface $entity + * The entity to extract data from. + * + * @return array + * An array containing the entity's ID, title, status, and path. + */ + private function getEntityData(EntityPublishedInterface $entity) { + return [ + 'id' => (int) $entity->id(), + 'title' => $entity->label(), + 'status' => $entity->isPublished(), + 'path' => $entity->toUrl()->toString(), + ]; + } + +} diff --git a/ui/src/components/navigation/Navigation.tsx b/ui/src/components/navigation/Navigation.tsx index 382b12f9be18786c8ca4fa0c6d8f4e4e3fe02f9c..d6bfdbf4dda2c9cde8c29856f47a5bc71978a1ef 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 content" size="1" > <TextField.Slot> diff --git a/ui/src/components/pageInfo/PageInfo.tsx b/ui/src/components/pageInfo/PageInfo.tsx index b874ce0025a2232e2785d2e587f5e40752d97f31..5bf15cd1cec3edc5f6ae9a2ec53ca0f523359094 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,22 @@ const PageInfo = () => { setEditorEntity('xb_page', String(item.id)); } + // @todo Fix in https://www.drupal.org/project/experience_builder/issues/3533096 + 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 +210,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..8f7cb3e5d1ec07215468ba0cc83c708fcc161b2d 100644 --- a/ui/src/services/content.ts +++ b/ui/src/services/content.ts @@ -20,13 +20,29 @@ 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); }, diff --git a/ui/tests/e2e/navigation.cy.js b/ui/tests/e2e/navigation.cy.js index 22b6230aeef49dcacf5d4f20c795ae83b8e68d10..4196b00e9a88219db7f0a40526965a8338e4515f 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 that search works', () => { + cy.loadURLandWaitForXBLoaded({ url: 'xb/xb_page/1' }); + cy.findByTestId(navigationButtonTestId).click(); + cy.findByLabelText('Search content').clear(); + cy.findByLabelText('Search content').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 content').clear(); + cy.findByLabelText('Search content').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 content').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' });