Skip to content
Snippets Groups Projects

Author should be able to search pages by name in navigation

Compare and
10 files
+ 809
59
Compare changes
  • Side-by-side
  • Inline
Files
10
@@ -7,10 +7,13 @@ namespace Drupal\experience_builder\Controller;
use Drupal\Core\Cache\CacheableJsonResponse;
use Drupal\Core\Cache\CacheableMetadata;
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\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\StringTranslation\TranslatableMarkup;
@@ -31,11 +34,19 @@ 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 ClientDataToEntityConverter $clientDataToEntityConverter,
private readonly LanguageManagerInterface $languageManager,
private readonly TransliterationInterface $transliteration,
) {}
public function post(Request $request, string $entity_type): JsonResponse {
@@ -100,62 +111,277 @@ 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());
// Setup cacheability metadata
$query_cacheability = $this->createInitialCacheability($storage);
$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.
// Create 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);
// Only apply sorting and range limiting when not searching
$is_search = $request->query->has('search');
if (!$is_search) {
$entity_query->sort((string) $revision_created_field_name, direction: 'DESC')
->range(0, self::MAX_SEARCH_RESULTS);
}
// Handle search functionality if search parameter is present
$matching_ids = [];
$matching_unsaved_ids = [];
if ($request->query->has('search')) {
$search = $this->prepareSearchTerm($request, $langcode);
$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.');
}
if (!empty($search)) {
// 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);
// Return empty response if no matches found
if (empty($matching_ids) && empty($matching_unsaved_ids)) {
return $this->createEmptyResponse($query_cacheability);
}
}
}
// Get entity IDs and filter by search results if needed
$ids = $this->executeQueryInRenderContext($entity_query, $query_cacheability);
$ids = $this->filterAndMergeIds($ids, $matching_ids, $matching_unsaved_ids);
// 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->getAutoSaveData($content_entity);
$autoSavePath = NULL;
// @todo Dynamically use the entity 'path' key to determine which field is
// the path in https://drupal.org/i/3503446.
$path_form_key = 'path[0][alias]';
if (isset($autoSaveData->data['entity_form_fields'][$path_form_key])) {
// If an alias is not set in the auto-save data, fall back to the
// internal path as any alias in the saved entity will be removed.
if (empty($autoSaveData->data['entity_form_fields'][$path_form_key])) {
$autoSavePath = '/' . $content_entity->toUrl()->getInternalPath();
}
else {
// The alias user input should always start with '/'.
$autoSavePath = $autoSaveData->data['entity_form_fields'][$path_form_key];
assert(str_starts_with($autoSavePath, '/'));
}
}
$content_list[$id] = [
'id' => $id,
'title' => $content_entity->label(),
'status' => $content_entity->isPublished(),
'path' => $generated_url->getGeneratedUrl(),
'autoSaveLabel' => is_null($autoSaveData->data) ? NULL : AutoSaveManager::getLabelToSave($content_entity, $autoSaveData->data),
'autoSavePath' => $autoSavePath,
];
$url_cacheability->addCacheableDependency($generated_url);
$entity_data = $this->prepareEntityData($content_entity, $url_cacheability);
$content_list[$entity_data['id']] = $entity_data;
}
// Create and configure the response
$json_response = new CacheableJsonResponse($content_list);
// @todo add cache contexts for query params when introducing pagination in https://www.drupal.org/i/3502691.
$this->addSearchCacheability($query_cacheability);
$json_response->addCacheableDependency($query_cacheability)
->addCacheableDependency($url_cacheability);
if (isset($autoSaveData)) {
$json_response->addCacheableDependency($autoSaveData);
return $json_response;
}
/**
* 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 {
$search = trim((string) $request->query->get('search'));
return $this->transliteration->transliterate(mb_strtolower($search), $langcode);
}
/**
* 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 !empty($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): array {
$matching_unsaved_ids = [];
$unsaved_entries = $this->autoSaveManager->getAllAutoSaveList();
foreach ($unsaved_entries as $entry) {
$label = $this->transliteration->transliterate(mb_strtolower($entry['label']), $langcode);
if (isset($label) && isset($entry['entity_id']) &&
str_contains(mb_strtolower($label), $search)) {
$matching_unsaved_ids[] = $entry['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 function createEmptyResponse(CacheableMetadata $query_cacheability): CacheableJsonResponse {
$json_response = new CacheableJsonResponse([]);
$this->addSearchCacheability($query_cacheability);
$json_response->addCacheableDependency($query_cacheability);
return $json_response;
}
/**
* Filters and merges entity IDs based on search results.
*
* @param array $ids
* The array of entity IDs from the database query.
* @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 function filterAndMergeIds(array $ids, array $matching_ids, array $matching_unsaved_ids): array {
// If there are matching entities from search, filter the results
if (!empty($matching_ids)) {
$ids = array_intersect($ids, $matching_ids);
}
// Merge with matching unsaved IDs, ensuring uniqueness
if (!empty($matching_unsaved_ids)) {
$ids = array_unique(array_merge($ids, $matching_unsaved_ids));
}
// Apply the limit to search results
if (!empty($matching_ids) || !empty($matching_unsaved_ids)) {
// Sort by newest first (keys will be numeric IDs) and limit to max results
arsort($ids);
$ids = array_slice($ids, 0, self::MAX_SEARCH_RESULTS, TRUE);
}
return $ids;
}
/**
* 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.
*
* @return array
* An associative array containing the prepared entity data.
*/
private function prepareEntityData(EntityPublishedInterface $content_entity, CacheableMetadata $url_cacheability): array {
$id = (int) $content_entity->id();
$generated_url = $content_entity->toUrl()->toString(TRUE);
$autoSaveData = $this->autoSaveManager->getAutoSaveData($content_entity);
$autoSavePath = $this->determineAutoSavePath($content_entity, $autoSaveData);
$entity_data = [
'id' => $id,
'title' => $content_entity->label(),
'status' => $content_entity->isPublished(),
'path' => $generated_url->getGeneratedUrl(),
'autoSaveLabel' => is_null($autoSaveData->data) ? NULL : AutoSaveManager::getLabelToSave($content_entity, $autoSaveData->data),
'autoSavePath' => $autoSavePath,
];
$url_cacheability->addCacheableDependency($generated_url);
return $entity_data;
}
/**
* Determines the auto-save path for an entity.
*
* @param \Drupal\Core\Entity\EntityPublishedInterface $content_entity
* The content entity to get the auto-save path for.
* @param object $autoSaveData
* The auto-save data object containing path information.
*
* @return string|null
* The auto-save path, or NULL if none exists.
*/
private function determineAutoSavePath(EntityPublishedInterface $content_entity, $autoSaveData): ?string {
// @todo Dynamically use the entity 'path' key to determine which field is
// the path in https://drupal.org/i/3503446.
$path_form_key = 'path[0][alias]';
if (!isset($autoSaveData->data['entity_form_fields'][$path_form_key])) {
return NULL;
}
// If an alias is not set in the auto-save data, fall back to the
// internal path as any alias in the saved entity will be removed.
if (empty($autoSaveData->data['entity_form_fields'][$path_form_key])) {
return '/' . $content_entity->toUrl()->getInternalPath();
}
// The alias user input should always start with '/'.
$autoSavePath = $autoSaveData->data['entity_form_fields'][$path_form_key];
assert(str_starts_with($autoSavePath, '/'));
return $autoSavePath;
}
/**
* Duplicates entity.
*
@@ -232,4 +458,15 @@ 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 function addSearchCacheability(CacheableMetadata $query_cacheability): void {
$query_cacheability->addCacheContexts(['url.query_args:search']);
$query_cacheability->addCacheTags([AutoSaveManager::CACHE_TAG]);
}
}
Loading