diff --git a/facets.install b/facets.install index 8a5b4e6..794d5c8 100644 --- a/facets.install +++ b/facets.install @@ -8,6 +8,7 @@ use Drupal\facets\Entity\Facet; use Drupal\facets\Entity\FacetSource; use Drupal\block\Entity\Block; +use Drupal\facets\Plugin\facets\facet_source\SearchApiDisplay; /** * Implements hook_update_dependencies(). @@ -174,17 +175,8 @@ function facets_update_8005() { * Update facet blocks configuration with a block id used for AJAX support. */ function facets_update_8006() { - $query = \Drupal::entityQuery('block') - ->condition('plugin', 'facet_block', 'STARTS_WITH') - ->execute(); - - foreach ($query as $block_id) { - $block = Block::load($block_id); - $configuration = $block->get('settings'); - $configuration['block_id'] = $block_id; - $block->set('settings', $configuration); - $block->save(); - } + // Empty update hook, we do not support this anymore. + // @see https://www.drupal.org/project/facets/issues/3073444 } /** @@ -216,3 +208,37 @@ function facets_update_8009() { $facet->save(); } } + +/** + * Enable facet block caching for the views with "Search API tag or time" cache. + */ +function facets_update_8010() { + $facet_storage = \Drupal::entityTypeManager()->getStorage('facets_facet'); + $processed_views = []; + /** @var \Drupal\facets\FacetInterface $facet */ + foreach ($facet_storage->loadMultiple() as $facet) { + if ( + ($source = $facet->getFacetSource()) + && $source instanceof SearchApiDisplay + && ($view_executable = $source->getViewsDisplay()) + && !in_array($view_executable->id(), $processed_views) + && ($cache_plugin = $view_executable->getDisplay()->getPlugin('cache')) + && in_array( + $cache_plugin->getPluginId(), + ['search_api_tag', 'search_api_time'] + ) + ) { + $view_executable->save(); + $processed_views[] = $view_executable->id(); + } + } + if (!empty($processed_views)) { + return t( + 'Facet caching was enabled for the following views: %views.', + ['%views' => implode(', ', $processed_views)] + ); + } + else { + return t('There are no views with search API cache plugins and facets in the same time, so nothing has been updated.'); + } +} diff --git a/facets.module b/facets.module index bc7d27e..e619677 100644 --- a/facets.module +++ b/facets.module @@ -14,6 +14,7 @@ use Drupal\Core\Url; use Drupal\facets\Entity\Facet; use Drupal\facets\Entity\FacetSource; use Drupal\facets\FacetInterface; +use Drupal\search_api\Entity\Index; use Drupal\views\Entity\View; use Drupal\Core\Entity\EntityInterface; use Symfony\Component\DependencyInjection\Exception\ServiceNotFoundException; @@ -66,6 +67,11 @@ function facets_theme($existing, $type, $theme, $path) { 'context' => [], ], ], + 'facets_views_plugin' => [ + 'variables' => [ + 'content' => [], + ], + ], ]; } @@ -365,3 +371,23 @@ function facets_theme_suggestions_facets_result_item(array $variables) { } return $suggestions; } + +/** + * Implements hook_views_data_alter(). + */ +function facets_views_data_alter(array &$data) { + + /** @var \Drupal\search_api\IndexInterface $index */ + foreach (Index::loadMultiple() as $index) { + $data['search_api_index_' . $index->id()]['facets'] = [ + 'title' => t('Facets'), + 'help' => t('Displays facets in a filter or area.'), + 'filter' => [ + 'id' => 'facets_filter', + ], + 'area' => [ + 'id' => 'facets_area', + ], + ]; + } +} diff --git a/facets.routing.yml b/facets.routing.yml index 9f26911..1e897e7 100644 --- a/facets.routing.yml +++ b/facets.routing.yml @@ -48,10 +48,3 @@ entity.facets_facet_source.edit_form: _title: 'Edit facet source configuration' requirements: _entity_create_access: 'facets_facet' - -facets.block.ajax: - path: '/facets-block-ajax' - defaults: - _controller: '\Drupal\facets\Controller\FacetBlockAjaxController::ajaxFacetBlockView' - requirements: - _access: 'TRUE' diff --git a/facets.services.yml b/facets.services.yml index 103f94a..7c31490 100644 --- a/facets.services.yml +++ b/facets.services.yml @@ -24,6 +24,7 @@ services: - '@plugin.manager.facets.facet_source' - '@plugin.manager.facets.processor' - '@entity_type.manager' + - '@current_route_match' facets.utility.date_handler: class: Drupal\facets\Utility\FacetsDateHandler arguments: @@ -38,6 +39,10 @@ services: arguments: ['@plugin.manager.block'] tags: - { name: event_subscriber } + facets.route_alter: + class: \Drupal\facets\EventSubscriber\RouteAlterSubscriber + tags: + - { name: event_subscriber } facets.search_api_subscriber: class: Drupal\facets\EventSubscriber\SearchApiSubscriber arguments: ['@facets.manager'] diff --git a/js/base-widget.js b/js/base-widget.js index 8ca3e6c..3af9e8b 100644 --- a/js/base-widget.js +++ b/js/base-widget.js @@ -24,7 +24,7 @@ * .once('my-custom-widget-on-change') * .on('change', function () { * // In this example $(this).val() will provide needed URL. - * $(this).trigger('facets_filter', [ $(this).val() ]); + * $(this).trigger('facets_filter', [ $(this).val(), false ]); * }); * * The facets module will trigger "facets_filtering" before filter is diff --git a/modules/facets_summary/facets_summary.module b/modules/facets_summary/facets_summary.module index 6720862..d2505eb 100644 --- a/modules/facets_summary/facets_summary.module +++ b/modules/facets_summary/facets_summary.module @@ -5,6 +5,8 @@ * Hook implementations for the facets summary module. */ +use Drupal\search_api\Entity\Index; + /** * Implements hook_theme(). */ @@ -98,3 +100,23 @@ function facets_summary_theme_suggestions_facets_summary_item_list(array $variab function facets_summary_preprocess_facets_summary_item_list(array &$variables) { template_preprocess_item_list($variables); } + +/** + * Implements hook_views_data_alter(). + */ +function facets_summary_views_data_alter(array &$data) { + + /** @var \Drupal\search_api\IndexInterface $index */ + foreach (Index::loadMultiple() as $index) { + $data['search_api_index_' . $index->id()]['facets_summary'] = [ + 'title' => t('Facets summary'), + 'help' => t('Displays facets summary in a filter or area'), + 'filter' => [ + 'id' => 'facets_summary_filter', + ], + 'area' => [ + 'id' => 'facets_summary_area', + ], + ]; + } +} diff --git a/modules/facets_summary/src/Form/FacetsSummarySettingsForm.php b/modules/facets_summary/src/Form/FacetsSummarySettingsForm.php index 2b19def..d61295e 100644 --- a/modules/facets_summary/src/Form/FacetsSummarySettingsForm.php +++ b/modules/facets_summary/src/Form/FacetsSummarySettingsForm.php @@ -245,9 +245,12 @@ class FacetsSummarySettingsForm extends EntityForm { if (isset($facet_source) && $facet_source instanceof SearchApiFacetSourceInterface) { $view = $facet_source->getViewsDisplay(); if ($view !== NULL) { - $view->display_handler->overrideOption('cache', ['type' => 'none']); - $view->save(); - $this->messenger()->addMessage($this->t('Caching of view %view has been disabled.', ['%view' => $view->storage->label()])); + $views_cache_type = $view->display_handler->getOption('cache')['type']; + if ($views_cache_type !== 'none' && strpos($views_cache_type, 'search_api_') === FALSE) { + $view->display_handler->overrideOption('cache', ['type' => 'none']); + $view->save(); + \Drupal::messenger()->addMessage($this->t('Caching of view %view has been disabled.', ['%view' => $view->storage->label()])); + } } } diff --git a/modules/facets_summary/src/Plugin/Block/FacetsSummaryBlock.php b/modules/facets_summary/src/Plugin/Block/FacetsSummaryBlock.php index e805c5e..9990aa1 100644 --- a/modules/facets_summary/src/Plugin/Block/FacetsSummaryBlock.php +++ b/modules/facets_summary/src/Plugin/Block/FacetsSummaryBlock.php @@ -5,7 +5,6 @@ namespace Drupal\facets_summary\Plugin\Block; use Drupal\Core\Block\BlockBase; use Drupal\Core\Cache\UncacheableDependencyTrait; use Drupal\Core\Plugin\ContainerFactoryPluginInterface; -use Drupal\Core\Url; use Drupal\facets_summary\Entity\FacetsSummary; use Drupal\facets_summary\FacetsSummaryBlockInterface; use Drupal\facets_summary\FacetsSummaryManager\DefaultFacetsSummaryManager; @@ -102,18 +101,6 @@ class FacetsSummaryBlock extends BlockBase implements FacetsSummaryBlockInterfac ]; } - /** @var \Drupal\views\ViewExecutable $view */ - if ($view = $facets_summary->getFacetSource()->getViewsDisplay()) { - $build['#attached']['drupalSettings']['facets_views_ajax'] = [ - 'facets_summary_ajax' => [ - 'facets_summary_id' => $facets_summary->id(), - 'view_id' => $view->id(), - 'current_display_id' => $view->current_display, - 'ajax_path' => Url::fromRoute('views.ajax')->toString(), - ], - ]; - } - return $build; } diff --git a/modules/facets_summary/src/Plugin/facets_summary/processor/HideWhenNotRenderedProcessor.php b/modules/facets_summary/src/Plugin/facets_summary/processor/HideWhenNotRenderedProcessor.php index 2f41f67..4780b3c 100644 --- a/modules/facets_summary/src/Plugin/facets_summary/processor/HideWhenNotRenderedProcessor.php +++ b/modules/facets_summary/src/Plugin/facets_summary/processor/HideWhenNotRenderedProcessor.php @@ -19,7 +19,44 @@ use Drupal\facets_summary\Processor\ProcessorPluginBase; * } * ) */ -class HideWhenNotRenderedProcessor extends ProcessorPluginBase implements BuildProcessorInterface { +class HideWhenNotRenderedProcessor extends ProcessorPluginBase implements BuildProcessorInterface, ContainerFactoryPluginInterface { + + /** + * The current route match. + * + * @var \Drupal\Core\Routing\RouteMatchInterface + */ + protected $routeMatch; + + /** + * Constructs a new object. + * + * @param array $configuration + * A configuration array containing information about the plugin instance. + * @param string $plugin_id + * The plugin_id for the plugin instance. + * @param mixed $plugin_definition + * The plugin implementation definition. + * @param \Drupal\Core\Routing\RouteMatchInterface $route_match + * The current route match. + */ + public function __construct(array $configuration, $plugin_id, $plugin_definition, RouteMatchInterface $route_match = NULL) { + parent::__construct($configuration, $plugin_id, $plugin_definition); + + $this->routeMatch = $route_match; + } + + /** + * {@inheritdoc} + */ + public static function create(ContainerInterface $container, array $configuration, $plugin_id, $plugin_definition) { + return new static( + $configuration, + $plugin_id, + $plugin_definition, + $container->get('current_route_match') + ); + } /** * {@inheritdoc} diff --git a/modules/facets_summary/src/Plugin/views/FacetsSummaryViewsPluginTrait.php b/modules/facets_summary/src/Plugin/views/FacetsSummaryViewsPluginTrait.php new file mode 100644 index 0000000..8f58b36 --- /dev/null +++ b/modules/facets_summary/src/Plugin/views/FacetsSummaryViewsPluginTrait.php @@ -0,0 +1,93 @@ +facetSummaryStorage->loadMultiple(); + + $format = 'search_api:views_%s__%s__%s'; + $source = sprintf($format, $this->view->getDisplay()->getPluginId(), $this->view->id(), $this->view->current_display); + foreach ($facets_summaries as $facets_summary) { + if ($facets_summary->getFacetSourceId() === $source) { + $options[$facets_summary->id()] = $facets_summary->label(); + } + } + + $form['facet_summary'] = [ + '#title' => 'Facet summary', + '#options' => $options, + '#type' => 'radios', + '#required' => TRUE, + '#default_value' => isset($this->options['facet_summary']) ? $this->options['facet_summary'] : [], + ]; + } + + /** + * Gets the facets summary to render. + * + * @return array + * A summary of the facets being used. + */ + public function FacetsViewsGetFacetSummary() { + $build = []; + + /** @var \Drupal\facets_summary\Entity\FacetsSummary $summary */ + $summary = $this->facetSummaryStorage->load($this->options['facet_summary']); + if ($summary) { + $facet_summary = $this->facetSummaryManager->build($summary); + if (!empty($facet_summary)) { + $summary_build = [ + '#theme' => 'block', + '#configuration' => [ + 'provider' => 'facets_summary', + 'label' => $summary->label(), + 'label_display' => TRUE, + ], + '#id' => $summary->id(), + '#plugin_id' => 'facet_summary_block:' . $summary->id(), + '#base_plugin_id' => 'facet_block', + '#derivative_plugin_id' => $summary->id(), + '#weight' => 0, + '#cache' => [ + 'contexts' => [], + 'tags' => [], + 'max-age' => 0, + ], + 'content' => $facet_summary, + ]; + } + } + + if (!empty($summary_build)) { + $build = [ + '#theme' => 'facets_views_plugin', + '#content' => $summary_build, + ]; + + if ($this->view->getDisplay()->ajaxEnabled()) { + $build['#attached']['library'][] = 'facets/drupal.facets.views-ajax'; + } + } + + return $build; + } + +} diff --git a/modules/facets_summary/src/Plugin/views/area/FacetsSummaryArea.php b/modules/facets_summary/src/Plugin/views/area/FacetsSummaryArea.php new file mode 100644 index 0000000..0773387 --- /dev/null +++ b/modules/facets_summary/src/Plugin/views/area/FacetsSummaryArea.php @@ -0,0 +1,103 @@ +facetSummaryManager = $facet_summary_manager; + $this->facetSummaryStorage = $facet_storage; + } + + /** + * {@inheritdoc} + */ + public static function create(ContainerInterface $container, array $configuration, $plugin_id, $plugin_definition) { + return new static( + $configuration, + $plugin_id, + $plugin_definition, + $container->get('facets_summary.manager'), + $container->get('entity_type.manager')->getStorage('facets_summary') + ); + } + + /** + * {@inheritdoc} + */ + protected function defineOptions() { + $options = parent::defineOptions(); + // Set the default to TRUE so it shows on empty pages by default. + $options['empty']['default'] = TRUE; + $options['facet_summary'] = ['default' => '']; + return $options; + } + + /** + * {@inheritdoc} + */ + public function buildOptionsForm(&$form, FormStateInterface $form_state) { + $this->FacetsSummaryiewsBuildOptionsForm($form, $form_state); + } + + /** + * {@inheritdoc} + */ + public function adminSummary() { + return ''; + } + + /** + * {@inheritdoc} + */ + public function render($empty = FALSE) { + return $this->FacetsViewsGetFacetSummary(); + } + +} diff --git a/modules/facets_summary/src/Plugin/views/filter/FacetsSummaryFilter.php b/modules/facets_summary/src/Plugin/views/filter/FacetsSummaryFilter.php new file mode 100644 index 0000000..a7fbb23 --- /dev/null +++ b/modules/facets_summary/src/Plugin/views/filter/FacetsSummaryFilter.php @@ -0,0 +1,143 @@ +facetSummaryManager = $facet_summary_manager; + $this->facetSummaryStorage = $facet_storage; + } + + /** + * {@inheritdoc} + */ + public static function create(ContainerInterface $container, array $configuration, $plugin_id, $plugin_definition) { + return new static( + $configuration, + $plugin_id, + $plugin_definition, + $container->get('facets_summary.manager'), + $container->get('entity_type.manager')->getStorage('facets_summary') + ); + } + + /** + * {@inheritdoc} + */ + protected function defineOptions() { + $random = new Random(); + $options = parent::defineOptions(); + $options['exposed'] = ['default' => TRUE]; + $options['expose']['contains']['identifier'] = ['default' => 'facet_summary_' . $random->name()]; + $options['facet_summary']['default'] = ''; + return $options; + } + + /** + * {@inheritdoc} + */ + public function buildOptionsForm(&$form, FormStateInterface $form_state) { + $this->FacetsSummaryiewsBuildOptionsForm($form, $form_state); + } + + /** + * {@inheritdoc} + */ + public function adminSummary() { + return ''; + } + + /** + * {@inheritdoc} + */ + public function valueForm(&$form, FormStateInterface $form_state) { + static $is_processing = NULL; + + if ($is_processing) { + $form['value'] = []; + return; + } + + $is_processing = TRUE; + $form['value'] = $this->FacetsViewsGetFacetSummary(); + $is_processing = FALSE; + } + + /** + * {@inheritdoc} + */ + public function acceptExposedInput($input) { + return FALSE; + } + + /** + * {@inheritdoc} + */ + public function validateExposeForm($form, FormStateInterface $form_state) {} + + /** + * {@inheritdoc} + */ + public function canGroup() { + return FALSE; + } + + /** + * {@inheritdoc} + */ + public function query() {} + +} diff --git a/src/Controller/FacetsViewsAjaxController.php b/src/Controller/FacetsViewsAjaxController.php new file mode 100644 index 0000000..3f60abe --- /dev/null +++ b/src/Controller/FacetsViewsAjaxController.php @@ -0,0 +1,25 @@ +facetsRemoveQueryParams($request); + $response = parent::ajaxView($request); + return $this->addExposedBlockToResponse($response); + } + +} diff --git a/src/Controller/FacetsViewsAjaxGetController.php b/src/Controller/FacetsViewsAjaxGetController.php new file mode 100644 index 0000000..756ff39 --- /dev/null +++ b/src/Controller/FacetsViewsAjaxGetController.php @@ -0,0 +1,25 @@ +facetsRemoveQueryParams($request); + $response = parent::ajaxView($request); + return $this->addExposedBlockToResponse($response); + } + +} diff --git a/src/Entity/Facet.php b/src/Entity/Facet.php index f615f35..6dd4b2e 100644 --- a/src/Entity/Facet.php +++ b/src/Entity/Facet.php @@ -1063,6 +1063,13 @@ class Facet extends ConfigEntityBase implements FacetInterface { if (!$update) { self::clearBlockCache(); } + // @todo move into condition above when this issue + // https://www.drupal.org/project/search_api/issues/3197050 will be fixed. + // Right now it clears the search api results and views cache by re-saving a + // views view config. + if ($source = $this->getFacetSource()) { + $this->getFacetSource()->registerFacet($this); + } } /** diff --git a/src/EventSubscriber/RouteAlterSubscriber.php b/src/EventSubscriber/RouteAlterSubscriber.php new file mode 100644 index 0000000..54cae76 --- /dev/null +++ b/src/EventSubscriber/RouteAlterSubscriber.php @@ -0,0 +1,39 @@ +getRouteCollection(); + if ($route = $collection->get('views.ajax')) { + $controller = $route->getDefault('_controller'); + // Views AJAX get support. + if ($controller == '\Drupal\views_ajax_get\Controller\ViewsAjaxController::ajaxView') { + $route->setDefault('_controller', '\Drupal\facets\Controller\FacetsViewsAjaxGetController::ajaxView'); + } + else { + $route->setDefault('_controller', '\Drupal\facets\Controller\FacetsViewsAjaxController::ajaxView'); + } + } + } + + /** + * {@inheritdoc} + */ + public static function getSubscribedEvents() { + $events[RoutingEvents::ALTER][] = ['onRouteAlter', -100]; + return $events; + } + +} diff --git a/src/FacetManager/DefaultFacetManager.php b/src/FacetManager/DefaultFacetManager.php index b3e93df..f2e5fc7 100644 --- a/src/FacetManager/DefaultFacetManager.php +++ b/src/FacetManager/DefaultFacetManager.php @@ -2,7 +2,11 @@ namespace Drupal\facets\FacetManager; +use Drupal\Core\Cache\CacheableDependencyInterface; +use Drupal\Core\Cache\CacheableMetadata; +use Drupal\Core\Cache\RefinableCacheableDependencyInterface; use Drupal\Core\Entity\EntityTypeManagerInterface; +use Drupal\Core\Routing\CurrentRouteMatch; use Drupal\Core\StringTranslation\StringTranslationTrait; use Drupal\facets\Exception\InvalidProcessorException; use Drupal\facets\FacetInterface; @@ -78,23 +82,34 @@ class DefaultFacetManager { */ protected $builtFacets = []; + /** + * @var \Drupal\Core\Routing\CurrentRouteMatch + */ + private $routeMatch; + /** * Constructs a new instance of the DefaultFacetManager. * - * @param \Drupal\facets\QueryType\QueryTypePluginManager $query_type_plugin_manager + * @param \Drupal\facets\QueryType\QueryTypePluginManager $query_type_plugin_manager * The query type plugin manager. - * @param \Drupal\facets\FacetSource\FacetSourcePluginManager $facet_source_manager + * @param \Drupal\facets\FacetSource\FacetSourcePluginManager $facet_source_manager * The facet source plugin manager. - * @param \Drupal\facets\Processor\ProcessorPluginManager $processor_plugin_manager + * @param \Drupal\facets\Processor\ProcessorPluginManager $processor_plugin_manager * The processor plugin manager. - * @param \Drupal\Core\Entity\EntityTypeManagerInterface $entity_type_manager + * @param \Drupal\Core\Entity\EntityTypeManagerInterface $entity_type_manager * The entity type plugin manager. + * @param \Drupal\Core\Routing\CurrentRouteMatch $route_match + * The current route match. + * + * @throws \Drupal\Component\Plugin\Exception\InvalidPluginDefinitionException + * @throws \Drupal\Component\Plugin\Exception\PluginNotFoundException */ - public function __construct(QueryTypePluginManager $query_type_plugin_manager, FacetSourcePluginManager $facet_source_manager, ProcessorPluginManager $processor_plugin_manager, EntityTypeManagerInterface $entity_type_manager) { + public function __construct(QueryTypePluginManager $query_type_plugin_manager, FacetSourcePluginManager $facet_source_manager, ProcessorPluginManager $processor_plugin_manager, EntityTypeManagerInterface $entity_type_manager, CurrentRouteMatch $route_match) { $this->queryTypePluginManager = $query_type_plugin_manager; $this->facetSourcePluginManager = $facet_source_manager; $this->processorPluginManager = $processor_plugin_manager; $this->facetStorage = $entity_type_manager->getStorage('facets_facet'); + $this->routeMatch = $route_match; } /** @@ -103,17 +118,21 @@ class DefaultFacetManager { * This method is called by the implementing module to initialize the facet * display process. * - * @param mixed $query + * @param mixed $query * The backend's native query object. - * @param string $facetsource_id + * @param string $facetsource_id * The facet source ID to process. + * + * @throws \Drupal\Component\Plugin\Exception\PluginException */ public function alterQuery(&$query, $facetsource_id) { + $query_is_cacheable = $query instanceof RefinableCacheableDependencyInterface; /** @var \Drupal\facets\FacetInterface[] $facets */ $facets = $this->getFacetsByFacetSourceId($facetsource_id); - foreach ($facets as $facet) { + foreach ($facets as $facet) { $processors = $facet->getProcessors(); + if (isset($processors['dependent_processor'])) { $conditions = $processors['dependent_processor']->getConfiguration(); @@ -126,7 +145,6 @@ class DefaultFacetManager { } foreach ($enabled_conditions as $facet_id => $condition_settings) { - if (!isset($facets[$facet_id]) || !$processors['dependent_processor']->isConditionMet($condition_settings, $facets[$facet_id])) { // The conditions are not met anymore, remove the active items. $facet->setActiveItems([]); @@ -156,6 +174,10 @@ class DefaultFacetManager { ] ); $query_type_plugin->execute(); + // Merge cache medata that gathered from facet and its processors. + if ($query_is_cacheable) { + $query->addCacheableDependency($facet); + } } } @@ -172,11 +194,12 @@ class DefaultFacetManager { /** * Returns currently rendered facets filtered by facetsource ID. * - * @param string $facetsource_id + * @param string $facetsource_id * The facetsource ID to filter by. * * @return \Drupal\facets\FacetInterface[] * An array of enabled facets. + * @throws \Drupal\facets\Exception\InvalidProcessorException */ public function getFacetsByFacetSourceId($facetsource_id) { // Immediately initialize the facets. @@ -235,7 +258,11 @@ class DefaultFacetManager { throw new InvalidProcessorException("The processor {$processor->getPluginDefinition()['id']} has a post_query definition but doesn't implement the required PostQueryProcessor interface"); } $post_query_processor->postQuery($facet); + if ($post_query_processor instanceof CacheableDependencyInterface) { + $facet->addCacheableDependency($post_query_processor); + } } + $this->processedFacets[$facetsource_id][$facet->id()] = $facet; } } @@ -263,6 +290,9 @@ class DefaultFacetManager { throw new InvalidProcessorException("The processor {$processor->getPluginDefinition()['id']} has a pre_query definition but doesn't implement the required PreQueryProcessorInterface interface"); } $pre_query_processor->preQuery($facet); + if ($processor instanceof CacheableDependencyInterface) { + $facet->addCacheableDependency($processor); + } } } } @@ -311,12 +341,18 @@ class DefaultFacetManager { throw new InvalidProcessorException("The processor {$processor->getPluginDefinition()['id']} has a build definition but doesn't implement the required BuildProcessorInterface interface"); } $results = $processor->build($facet, $results); + if ($processor instanceof CacheableDependencyInterface) { + $facet->addCacheableDependency($processor); + } } // Trigger sort stage. $active_sort_processors = []; foreach ($facet->getProcessorsByStage(ProcessorInterface::STAGE_SORT) as $processor) { $active_sort_processors[] = $processor; + if ($processor instanceof CacheableDependencyInterface) { + $facet->addCacheableDependency($processor); + } } // Sort the actual results if we have enabled sort processors. @@ -369,7 +405,7 @@ class DefaultFacetManager { /** @var \Drupal\facets\Widget\WidgetPluginInterface $widget */ $widget = $facet->getWidgetInstance(); $build = $widget->build($facet); - + CacheableMetadata::createFromObject($facet)->applyTo($build); // No results behavior handling. Return a custom text or false depending on // settings. if (empty($facet->getResults())) { @@ -392,10 +428,9 @@ class DefaultFacetManager { ]; } else { - // If the facet has no results, but it is being rendered trough ajax we - // should render a container (that is empty). This is because the - // javascript needs to be able to find a div to replace with the new - // content. + // If the facet has no results, but it is being rendered trough AJAX it + // should render an empty container. This is because the JavaScript + // needs to be able to find a div to replace with the new content. return [ [ 0 => $build, @@ -415,8 +450,10 @@ class DefaultFacetManager { /** * Updates all facets of a given facet source with the raw results. * - * @param string $facetsource_id + * @param string $facetsource_id * The facet source ID of the currently processed facet. + * + * @throws \Drupal\facets\Exception\InvalidProcessorException|\Drupal\Component\Plugin\Exception\PluginException */ public function updateResults($facetsource_id) { $facets = $this->getFacetsByFacetSourceId($facetsource_id); @@ -438,11 +475,12 @@ class DefaultFacetManager { * Keep in mind that if you want to have the facet's build processor executed, * call returnBuiltFacet() instead. * - * @param \Drupal\facets\FacetInterface $facet + * @param \Drupal\facets\FacetInterface $facet * The facet to process. * * @return \Drupal\facets\FacetInterface|null * The updated facet if it exists, NULL otherwise. + * @throws \Drupal\facets\Exception\InvalidProcessorException */ public function returnProcessedFacet(FacetInterface $facet) { $this->processFacets($facet->getFacetSourceId()); @@ -452,11 +490,12 @@ class DefaultFacetManager { /** * Returns one of the built facets. * - * @param \Drupal\facets\FacetInterface $facet + * @param \Drupal\facets\FacetInterface $facet * The facet to process. * * @return \Drupal\facets\FacetInterface * The built facet. + * @throws \Drupal\facets\Exception\InvalidProcessorException */ public function returnBuiltFacet(FacetInterface $facet) { return $this->processBuild($facet); diff --git a/src/FacetSource/FacetSourcePluginBase.php b/src/FacetSource/FacetSourcePluginBase.php index 9a470f8..4141dd9 100644 --- a/src/FacetSource/FacetSourcePluginBase.php +++ b/src/FacetSource/FacetSourcePluginBase.php @@ -2,6 +2,7 @@ namespace Drupal\facets\FacetSource; +use Drupal\Core\Cache\UncacheableDependencyTrait; use Drupal\Core\Plugin\PluginBase; use Drupal\Core\Plugin\ContainerFactoryPluginInterface; use Symfony\Component\DependencyInjection\ContainerInterface; @@ -24,6 +25,7 @@ use Drupal\facets\QueryType\QueryTypePluginManager; * @see plugin_api */ abstract class FacetSourcePluginBase extends PluginBase implements FacetSourcePluginInterface, ContainerFactoryPluginInterface { + use UncacheableDependencyTrait; /** * The plugin manager. @@ -153,4 +155,10 @@ abstract class FacetSourcePluginBase extends PluginBase implements FacetSourcePl $this->facet->setFieldIdentifier($field_identifier); } + /** + * {@inheritdoc} + */ + public function registerFacet(FacetInterface $facet) { + } + } diff --git a/src/FacetSource/FacetSourcePluginInterface.php b/src/FacetSource/FacetSourcePluginInterface.php index 1b97101..d8eca29 100644 --- a/src/FacetSource/FacetSourcePluginInterface.php +++ b/src/FacetSource/FacetSourcePluginInterface.php @@ -3,6 +3,7 @@ namespace Drupal\facets\FacetSource; use Drupal\Component\Plugin\DependentPluginInterface; +use Drupal\Core\Cache\CacheableDependencyInterface; use Drupal\Core\Plugin\PluginFormInterface; use Drupal\facets\FacetInterface; @@ -15,7 +16,7 @@ use Drupal\facets\FacetInterface; * * @see plugin_api */ -interface FacetSourcePluginInterface extends PluginFormInterface, DependentPluginInterface { +interface FacetSourcePluginInterface extends PluginFormInterface, DependentPluginInterface, CacheableDependencyInterface { /** * Fills the facet entities with results from the facet source. @@ -115,4 +116,15 @@ interface FacetSourcePluginInterface extends PluginFormInterface, DependentPlugi */ public function buildFacet(); + /** + * Register newly added facet within its source. + * + * Add facet cache tags and contexts into the facet source, to make sure that + * it will be invalidated whenever facet preferences will change. + * + * @param \Drupal\facets\FacetInterface $facet + * Facet that being inserted or updated. + */ + public function registerFacet(FacetInterface $facet); + } diff --git a/src/FacetsViewsAjaxTrait.php b/src/FacetsViewsAjaxTrait.php new file mode 100644 index 0000000..d27f100 --- /dev/null +++ b/src/FacetsViewsAjaxTrait.php @@ -0,0 +1,75 @@ +getMethod() === Request::METHOD_POST) { + foreach (['f', 'page'] as $key) { + if ($request->query->has($key)) { + $request->query->remove($key); + } + } + } + } + + /** + * Adds the exposed form to the response if necessary. + * + * @param mixed $response + * The AJAX response. This is not type hinted because this can either be + * a ViewsAjaxResponse or a CacheableViewsAjaxResponse. + * + * @return mixed + * An empty array, the output from Views, or the response object. + */ + public function addExposedBlockToResponse($response) { + $view = $response->getView(); + $display = $view->getDisplay(); + if ($display->getOption('exposed_block') && $display->usesExposedFormInBlock()) { + + $context = new RenderContext(); + $exposed_block = $this->renderer->executeInRenderContext($context, function () use ($view) { + $output = $view->display_handler->viewExposedFormBlocks(); + if (is_array($output) && !empty($output)) { + return $output; + } + + return []; + }); + + if (!$context->isEmpty() && !empty($exposed_block)) { + $bubbleable_metadata = $context->pop(); + BubbleableMetadata::createFromRenderArray($exposed_block) + ->merge($bubbleable_metadata) + ->applyTo($exposed_block); + } + + // Replace exposed block. + $selector = 'views-exposed-form-' . strtr($view->id(), '_', '-') . '-' . strtr($view->current_display, '_', '-'); + $response->addCommand(new ReplaceCommand("#" . $selector, $exposed_block)); + } + + return $response; + } + +} diff --git a/src/Form/FacetSettingsForm.php b/src/Form/FacetSettingsForm.php index 3000147..4632292 100644 --- a/src/Form/FacetSettingsForm.php +++ b/src/Form/FacetSettingsForm.php @@ -305,9 +305,10 @@ class FacetSettingsForm extends EntityForm { if ($view->display_handler instanceof Block) { $facet->setOnlyVisibleWhenFacetSourceIsVisible(FALSE); } - $view->display_handler->overrideOption('cache', ['type' => 'none']); - $view->save(); - $this->messenger()->addMessage($this->t('Caching of view %view has been disabled.', ['%view' => $view->storage->label()])); + $views_cache_type = $view->display_handler->getOption('cache')['type']; + if ($views_cache_type !== 'none') { + $this->messenger()->addMessage($this->t('You may experience issues, because %view use cache. In case you will try to turn set cache plugin to none.', ['%view' => $view->storage->label()])); + } } } diff --git a/src/Plugin/Block/FacetBlock.php b/src/Plugin/Block/FacetBlock.php index ac3b076..5ad94d4 100644 --- a/src/Plugin/Block/FacetBlock.php +++ b/src/Plugin/Block/FacetBlock.php @@ -3,9 +3,10 @@ namespace Drupal\facets\Plugin\Block; use Drupal\Core\Block\BlockBase; +use Drupal\Core\Cache\CacheableDependencyInterface; +use Drupal\Core\Cache\CacheableMetadata; use Drupal\Core\Entity\EntityStorageInterface; use Drupal\Core\Plugin\ContainerFactoryPluginInterface; -use Drupal\Core\Form\FormStateInterface; use Drupal\facets\FacetManager\DefaultFacetManager; use Symfony\Component\DependencyInjection\ContainerInterface; @@ -105,54 +106,85 @@ class FacetBlock extends BlockBase implements ContainerFactoryPluginInterface { else { $build['#attributes']['class'][] = 'facet-inactive'; } - - // Add classes needed for ajax. - if (!empty($build['#use_ajax'])) { - $build['#attributes']['class'][] = 'block-facets-ajax'; - // The configuration block id isn't always set in the configuration. - if (isset($this->configuration['block_id'])) { - $build['#attributes']['class'][] = 'js-facet-block-id-' . $this->configuration['block_id']; - } - else { - $build['#attributes']['class'][] = 'js-facet-block-id-' . $this->pluginId; - } - } } return $build; } + /** + * Get cache metadata from the facet source plugin. + * + * @return \Drupal\Core\Cache\CacheableMetadata + * Facet block cache metadata. + */ + protected function getCacheMetadata() { + $facet = $this->facetStorage->load($this->getDerivativeId()); + $metadata = (new CacheableMetadata()) + ->setCacheTags($facet->getCacheTags()) + ->setCacheContexts($facet->getCacheContexts()); + if ( + ($source = $facet->getFacetSource()) + && $source instanceof CacheableDependencyInterface + ) { + $metadata = $metadata->merge( + (new CacheableMetadata()) + ->setCacheTags($source->getCacheTags()) + ->setCacheContexts($source->getCacheContexts()) + )->setCacheMaxAge($source->getCacheMaxAge()); + } + else { + // A facet block cannot be cached, because it must always match the + // current search results, and Search API gets those search results from a + // data source that can be external to Drupal. Therefore it is impossible + // to guarantee that the search results are in sync with the data managed + // by Drupal. Consequently, it is not possible to cache the search results + // at all. If the search results cannot be cached, then neither can the + // facets, because they must always match. + // Fortunately, facet blocks are rendered using a lazy builder (like all + // blocks in Drupal), which means their rendering can be deferred (unlike + // the search results, which are the main content of the page, and + // deferring their rendering would mean sending an empty page to the + // user). This means that facet blocks can be rendered and sent *after* + // the initial page was loaded, by installing the BigPipe (big_pipe) + // module. + // + // When BigPipe is enabled, the search results will appear first, and then + // each facet block will appear one-by-one, in DOM order. + // See https://www.drupal.org/project/big_pipe. + // + // In a future version of Facet API, this could be refined, but due to the + // reliance on external data sources, it will be very difficult if not + // impossible to improve this significantly. + // + // Note: when using Drupal core's Search module instead of the contributed + // Search API module, the above limitations do not apply, but for now it's + // not considered worth the effort to optimize this just for Drupal core's + // Search. + $metadata->setCacheMaxAge(0); + } + + return $metadata; + } + /** * {@inheritdoc} */ public function getCacheMaxAge() { - // A facet block cannot be cached, because it must always match the current - // search results, and Search API gets those search results from a data - // source that can be external to Drupal. Therefore it is impossible to - // guarantee that the search results are in sync with the data managed by - // Drupal. Consequently, it is not possible to cache the search results at - // all. If the search results cannot be cached, then neither can the facets, - // because they must always match. - // Fortunately, facet blocks are rendered using a lazy builder (like all - // blocks in Drupal), which means their rendering can be deferred (unlike - // the search results, which are the main content of the page, and deferring - // their rendering would mean sending an empty page to the user). This means - // that facet blocks can be rendered and sent *after* the initial page was - // loaded, by installing the BigPipe (big_pipe) module. - // - // When BigPipe is enabled, the search results will appear first, and then - // each facet block will appear one-by-one, in DOM order. - // See https://www.drupal.org/project/big_pipe. - // - // In a future version of Facet API, this could be refined, but due to the - // reliance on external data sources, it will be very difficult if not - // impossible to improve this significantly. - // - // Note: when using Drupal core's Search module instead of the contributed - // Search API module, the above limitations do not apply, but for now it is - // not considered worth the effort to optimize this just for Drupal core's - // Search. - return 0; + return $this->getCacheMetadata()->getCacheMaxAge(); + } + + /** + * {@inheritdoc} + */ + public function getCacheTags() { + return $this->getCacheMetadata()->getCacheTags(); + } + + /** + * {@inheritdoc} + */ + public function getCacheContexts() { + return $this->getCacheMetadata()->getCacheContexts(); } /** diff --git a/src/Plugin/facets/facet_source/SearchApiDisplay.php b/src/Plugin/facets/facet_source/SearchApiDisplay.php index 57911d5..4cd3a30 100644 --- a/src/Plugin/facets/facet_source/SearchApiDisplay.php +++ b/src/Plugin/facets/facet_source/SearchApiDisplay.php @@ -3,6 +3,7 @@ namespace Drupal\facets\Plugin\facets\facet_source; use Drupal\Component\Plugin\DependentPluginInterface; +use Drupal\Core\Cache\Cache; use Drupal\Core\Extension\ModuleHandler; use Drupal\Core\Form\FormStateInterface; use Drupal\Core\Url; @@ -146,6 +147,45 @@ class SearchApiDisplay extends FacetSourcePluginBase implements SearchApiFacetSo return $this->getDisplay()->getPath(); } + /** + * Helper function to get arguments for views contextual filters. + * + * @return array + * Values of contextual filters. + */ + private function extractArgumentsForViewDisplay(): array { + $argumentValues = []; + // For AJAX requests we cannot take the value the same way as for non-AJAX + // requests because route is identified as Drupal AJAX and views arguments + // are removed by Views. + if ($this->request->isXmlHttpRequest()) { + $argumentValues = explode('/', $_REQUEST['view_args']); + } + else { + $display = $this->getViewsDisplay()->getDisplay(); + + // Display plugin which have a path, i.e. pages. + // @see \Drupal\views\Plugin\views\display\PathPluginBase + if ($display->hasPath()) { + $viewUrlParameters = $display->getUrl()->getRouteParameters(); + if (!empty($viewUrlParameters)) { + $parameters = []; + foreach ($viewUrlParameters as $viewUrlParameter => $validator) { + $parameters[] = $this->request->attributes->has($viewUrlParameter) ? $this->request->attributes->get($viewUrlParameter) : NULL; + } + + // Add view parameters as arguments only if at least one of them + // resolved to a value, otherwise let views handle the defaults. + if (!empty(array_filter($parameters))) { + $argumentValues = array_merge($argumentValues, $parameters); + } + } + } + // @todo Support other plugin types. + } + return $argumentValues; + } + /** * {@inheritdoc} */ @@ -164,6 +204,8 @@ class SearchApiDisplay extends FacetSourcePluginBase implements SearchApiFacetSo if ($results === NULL && isset($display_definition['view_id'])) { $view = Views::getView($display_definition['view_id']); $view->setDisplay($display_definition['view_display']); + $view->setArguments($this->extractArgumentsForViewDisplay()); + $view->preExecute(); $view->execute(); $results = $this->searchApiQueryHelper->getResults($search_id); } @@ -387,21 +429,6 @@ class SearchApiDisplay extends FacetSourcePluginBase implements SearchApiFacetSo if ($view === NULL) { return $build; } - - // Add JS for Views with Ajax Enabled. - if ($view->display_handler->ajaxEnabled()) { - $js_settings = [ - 'view_id' => $view->id(), - 'current_display_id' => $view->current_display, - 'view_base_path' => ltrim($view->getPath() ?? '', '/'), - 'ajax_path' => Url::fromRoute('views.ajax')->toString(), - ]; - $build['#attached']['library'][] = 'facets/drupal.facets.views-ajax'; - $build['#attached']['drupalSettings']['facets_views_ajax'] = [ - $this->facet->id() => $js_settings, - ]; - $build['#use_ajax'] = TRUE; - } return $build; } @@ -418,4 +445,46 @@ class SearchApiDisplay extends FacetSourcePluginBase implements SearchApiFacetSo } } + /** + * {@inheritdoc} + */ + public function getCacheContexts() { + return $this->getViewsDisplay() + ->getDisplay() + ->getCacheMetadata() + ->getCacheContexts(); + } + + /** + * {@inheritdoc} + */ + public function getCacheTags() { + $view_display = $this->getViewsDisplay()->getDisplay(); + return Cache::mergeTags( + $view_display->getCacheMetadata()->getCacheTags(), + $view_display->getPlugin('cache')->getCacheTags() + ); + } + + /** + * {@inheritdoc} + */ + public function getCacheMaxAge() { + $view_display = $this->getViewsDisplay()->getDisplay(); + return Cache::mergeMaxAges( + $view_display->getCacheMetadata()->getCacheMaxAge(), + $view_display->getPlugin('cache')->getCacheMaxAge() + ); + } + + /** + * {@inheritdoc} + */ + public function registerFacet(FacetInterface $facet) { + // Alter views view cache metadata. + // @see \Drupal\search_api\Plugin\views\cache\SearchApiCachePluginTrait::generateResultsKey() + // @see \Drupal\views\Plugin\views\cache\CachePluginBase::alterCacheMetadata() + $this->getViewsDisplay()->save(); + } + } diff --git a/src/Plugin/facets/hierarchy/Taxonomy.php b/src/Plugin/facets/hierarchy/Taxonomy.php index 129c0e0..cc04ee2 100644 --- a/src/Plugin/facets/hierarchy/Taxonomy.php +++ b/src/Plugin/facets/hierarchy/Taxonomy.php @@ -2,6 +2,7 @@ namespace Drupal\facets\Plugin\facets\hierarchy; +use Drupal\Core\Cache\Cache; use Drupal\Core\Entity\EntityTypeManagerInterface; use Drupal\facets\Hierarchy\HierarchyPluginBase; use Symfony\Component\DependencyInjection\ContainerInterface; @@ -197,4 +198,11 @@ class Taxonomy extends HierarchyPluginBase { return $this->termParents[$tid] = reset($parents)->id(); } + /** + * {@inheritdoc} + */ + public function getCacheTags() { + return Cache::mergeTags(parent::getCacheTags(), ['taxonomy_term:list']); + } + } diff --git a/src/Plugin/facets/processor/CombineFacetProcessor.php b/src/Plugin/facets/processor/CombineFacetProcessor.php index 40603eb..a51dbbf 100644 --- a/src/Plugin/facets/processor/CombineFacetProcessor.php +++ b/src/Plugin/facets/processor/CombineFacetProcessor.php @@ -148,7 +148,6 @@ class CombineFacetProcessor extends ProcessorPluginBase implements BuildProcesso /** @var \Drupal\facets\Entity\Facet $current_facet */ $current_facet = $this->facetStorage->load($facet_id); $current_facet = $this->facetsManager->returnBuiltFacet($current_facet); - switch ($settings['mode']) { case 'union': $results = $keyed_results + $current_facet->getResultsKeyedByRawValue(); @@ -162,6 +161,8 @@ class CombineFacetProcessor extends ProcessorPluginBase implements BuildProcesso $results = array_intersect_key($keyed_results, $current_facet->getResultsKeyedByRawValue()); break; } + // Pass build processor information into current facet. + $facet->addCacheableDependency($current_facet); } return $results; diff --git a/src/Plugin/facets/processor/TermWeightWidgetOrderProcessor.php b/src/Plugin/facets/processor/TermWeightWidgetOrderProcessor.php index 2993893..5168ea6 100644 --- a/src/Plugin/facets/processor/TermWeightWidgetOrderProcessor.php +++ b/src/Plugin/facets/processor/TermWeightWidgetOrderProcessor.php @@ -2,6 +2,7 @@ namespace Drupal\facets\Plugin\facets\processor; +use Drupal\Core\Cache\Cache; use Drupal\Core\Entity\EntityTypeManagerInterface; use Drupal\Core\Plugin\ContainerFactoryPluginInterface; use Drupal\Core\TypedData\ComplexDataDefinitionInterface; @@ -126,4 +127,11 @@ class TermWeightWidgetOrderProcessor extends SortProcessorPluginBase implements return FALSE; } + /** + * {@inheritdoc} + */ + public function getCacheTags() { + return Cache::mergeTags(parent::getCacheTags(), ['taxonomy_term:list']); + } + } diff --git a/src/Plugin/facets/processor/TranslateEntityAggregatedFieldProcessor.php b/src/Plugin/facets/processor/TranslateEntityAggregatedFieldProcessor.php index efc4c18..09cbd6c 100644 --- a/src/Plugin/facets/processor/TranslateEntityAggregatedFieldProcessor.php +++ b/src/Plugin/facets/processor/TranslateEntityAggregatedFieldProcessor.php @@ -167,7 +167,7 @@ class TranslateEntityAggregatedFieldProcessor extends ProcessorPluginBase implem if ($entity instanceof TranslatableInterface && $entity->hasTranslation($language_interface->getId())) { $entity = $entity->getTranslation($language_interface->getId()); } - + $facet->addCacheableDependency($entity); // Overwrite the result's display value. $results[$i]->setDisplayValue($entity->label()); } diff --git a/src/Plugin/facets/processor/TranslateEntityProcessor.php b/src/Plugin/facets/processor/TranslateEntityProcessor.php index dff609f..5786703 100644 --- a/src/Plugin/facets/processor/TranslateEntityProcessor.php +++ b/src/Plugin/facets/processor/TranslateEntityProcessor.php @@ -128,7 +128,7 @@ class TranslateEntityProcessor extends ProcessorPluginBase implements BuildProce if ($entity instanceof TranslatableInterface && $entity->hasTranslation($language_interface->getId())) { $entity = $entity->getTranslation($language_interface->getId()); } - + $facet->addCacheableDependency($entity); // Overwrite the result's display value. $results[$i]->setDisplayValue($entity->label()); } diff --git a/src/Plugin/facets/processor/UidToUserNameCallbackProcessor.php b/src/Plugin/facets/processor/UidToUserNameCallbackProcessor.php index 254037d..0eaf4c5 100644 --- a/src/Plugin/facets/processor/UidToUserNameCallbackProcessor.php +++ b/src/Plugin/facets/processor/UidToUserNameCallbackProcessor.php @@ -34,6 +34,7 @@ class UidToUserNameCallbackProcessor extends ProcessorPluginBase implements Buil /** @var \Drupal\user\Entity\User $user */ if (($user = User::load($result->getRawValue())) !== NULL) { $result->setDisplayValue($user->getDisplayName()); + $facet->addCacheableDependency($user); $usernames[] = $result; } } diff --git a/src/Plugin/facets/processor/UrlProcessorHandler.php b/src/Plugin/facets/processor/UrlProcessorHandler.php index 92f8655..54aceda 100644 --- a/src/Plugin/facets/processor/UrlProcessorHandler.php +++ b/src/Plugin/facets/processor/UrlProcessorHandler.php @@ -2,6 +2,7 @@ namespace Drupal\facets\Plugin\facets\processor; +use Drupal\Core\Cache\Cache; use Drupal\facets\Exception\InvalidProcessorException; use Drupal\facets\FacetInterface; use Drupal\facets\Processor\BuildProcessorInterface; @@ -82,4 +83,14 @@ class UrlProcessorHandler extends ProcessorPluginBase implements BuildProcessorI $this->processor->setActiveItems($facet); } + /** + * {@inheritdoc} + */ + public function getCacheContexts() { + return Cache::mergeContexts( + parent::getCacheContexts(), + ['url.path', 'url.query_args'] + ); + } + } diff --git a/src/Plugin/views/FacetsViewsPluginTrait.php b/src/Plugin/views/FacetsViewsPluginTrait.php new file mode 100644 index 0000000..4566d1b --- /dev/null +++ b/src/Plugin/views/FacetsViewsPluginTrait.php @@ -0,0 +1,96 @@ +facetStorage->loadMultiple(); + + $format = 'search_api:views_%s__%s__%s'; + $source = sprintf($format, $this->view->getDisplay()->getPluginId(), $this->view->id(), $this->view->current_display); + foreach ($facets as $facet) { + if ($facet->getFacetSourceId() === $source) { + $options[$facet->id()] = $facet->label(); + } + } + + $form['facets'] = [ + '#title' => 'Facets', + '#options' => $options, + '#type' => 'checkboxes', + '#required' => TRUE, + '#default_value' => isset($this->options['facets']) ? $this->options['facets'] : [], + ]; + } + + /** + * Gets the facets to render. + * + * @return array + * The facet blocks to be output, in render array format. + */ + public function FacetsViewsGetFacets() { + $build = []; + + /** @var \Drupal\facets\Entity\Facet[] $facets */ + $items = []; + $facets = $this->facetStorage->loadMultiple(array_filter($this->options['facets'])); + foreach ($facets as $facet) { + $facet_build = $this->facetManager->build($facet); + if (!empty($facet_build)) { + $facet_source = $facet->getFacetSource(); + $facet_build += $facet_source->buildFacet(); + $items[] = [ + '#theme' => 'block', + '#configuration' => [ + 'provider' => 'facets', + 'label' => $facet->label(), + 'label_display' => TRUE, + ], + '#id' => $facet->id(), + '#plugin_id' => 'facet_block:' . $facet->id(), + '#base_plugin_id' => 'facet_block', + '#derivative_plugin_id' => $facet->id(), + '#weight' => $facet->getWeight(), + '#cache' => [ + 'contexts' => [], + 'tags' => [], + 'max-age' => 0, + ], + 'content' => $facet_build, + ]; + } + } + + if (!empty($items)) { + $build = [ + '#theme' => 'facets_views_plugin', + '#content' => $items, + ]; + + if ($this->view->getDisplay()->ajaxEnabled()) { + $build['#attached']['library'][] = 'facets/drupal.facets.views-ajax'; + } + } + + return $build; + } + +} diff --git a/src/Plugin/views/area/FacetsArea.php b/src/Plugin/views/area/FacetsArea.php new file mode 100644 index 0000000..026d181 --- /dev/null +++ b/src/Plugin/views/area/FacetsArea.php @@ -0,0 +1,103 @@ +facetManager = $facet_manager; + $this->facetStorage = $facet_storage; + } + + /** + * {@inheritdoc} + */ + public static function create(ContainerInterface $container, array $configuration, $plugin_id, $plugin_definition) { + return new static( + $configuration, + $plugin_id, + $plugin_definition, + $container->get('facets.manager'), + $container->get('entity_type.manager')->getStorage('facets_facet') + ); + } + + /** + * {@inheritdoc} + */ + protected function defineOptions() { + $options = parent::defineOptions(); + // Set the default to TRUE so it shows on empty pages by default. + $options['empty']['default'] = TRUE; + $options['facets'] = ['default' => []]; + return $options; + } + + /** + * {@inheritdoc} + */ + public function buildOptionsForm(&$form, FormStateInterface $form_state) { + $this->FacetsViewsBuildOptionsForm($form, $form_state); + } + + /** + * {@inheritdoc} + */ + public function adminSummary() { + return implode(', ', array_filter($this->options['facets'])); + } + + /** + * {@inheritdoc} + */ + public function render($empty = FALSE) { + return $this->FacetsViewsGetFacets(); + } + +} diff --git a/src/Plugin/views/filter/FacetsFilter.php b/src/Plugin/views/filter/FacetsFilter.php new file mode 100644 index 0000000..4993ec0 --- /dev/null +++ b/src/Plugin/views/filter/FacetsFilter.php @@ -0,0 +1,143 @@ +facetManager = $facet_manager; + $this->facetStorage = $facet_storage; + } + + /** + * {@inheritdoc} + */ + public static function create(ContainerInterface $container, array $configuration, $plugin_id, $plugin_definition) { + return new static( + $configuration, + $plugin_id, + $plugin_definition, + $container->get('facets.manager'), + $container->get('entity_type.manager')->getStorage('facets_facet') + ); + } + + /** + * {@inheritdoc} + */ + protected function defineOptions() { + $random = new Random(); + $options = parent::defineOptions(); + $options['exposed'] = ['default' => TRUE]; + $options['expose']['contains']['identifier'] = ['default' => 'facet_' . $random->name()]; + $options['facets']['default'] = []; + return $options; + } + + /** + * {@inheritdoc} + */ + public function buildOptionsForm(&$form, FormStateInterface $form_state) { + $this->FacetsViewsBuildOptionsForm($form, $form_state); + } + + /** + * {@inheritdoc} + */ + public function adminSummary() { + return implode(', ', array_filter($this->options['facets'])); + } + + /** + * {@inheritdoc} + */ + public function valueForm(&$form, FormStateInterface $form_state) { + static $is_processing = NULL; + + if ($is_processing) { + $form['value'] = []; + return; + } + + $is_processing = TRUE; + $form['value'] = $this->FacetsViewsGetFacets(); + $is_processing = FALSE; + } + + /** + * {@inheritdoc} + */ + public function acceptExposedInput($input) { + return FALSE; + } + + /** + * {@inheritdoc} + */ + public function validateExposeForm($form, FormStateInterface $form_state) {} + + /** + * {@inheritdoc} + */ + public function canGroup() { + return FALSE; + } + + /** + * {@inheritdoc} + */ + public function query() {} + +} diff --git a/src/Processor/ProcessorPluginBase.php b/src/Processor/ProcessorPluginBase.php index 5b3f3e5..1d0727e 100644 --- a/src/Processor/ProcessorPluginBase.php +++ b/src/Processor/ProcessorPluginBase.php @@ -2,6 +2,8 @@ namespace Drupal\facets\Processor; +use Drupal\Core\Cache\Cache; +use Drupal\Core\Cache\CacheableDependencyInterface; use Drupal\Core\Form\FormStateInterface; use Drupal\Core\Entity\DependencyTrait; use Drupal\Core\Plugin\PluginBase; @@ -10,7 +12,7 @@ use Drupal\facets\FacetInterface; /** * A base class for plugins that implements most of the boilerplate. */ -class ProcessorPluginBase extends PluginBase implements ProcessorInterface { +class ProcessorPluginBase extends PluginBase implements ProcessorInterface, CacheableDependencyInterface { use DependencyTrait; @@ -116,4 +118,25 @@ class ProcessorPluginBase extends PluginBase implements ProcessorInterface { return NULL; } + /** + * {@inheritdoc} + */ + public function getCacheContexts() { + return []; + } + + /** + * {@inheritdoc} + */ + public function getCacheTags() { + return []; + } + + /** + * {@inheritdoc} + */ + public function getCacheMaxAge() { + return Cache::PERMANENT; + } + } diff --git a/src/Widget/WidgetPluginBase.php b/src/Widget/WidgetPluginBase.php index 2cbfb1f..7bbda4b 100644 --- a/src/Widget/WidgetPluginBase.php +++ b/src/Widget/WidgetPluginBase.php @@ -81,12 +81,6 @@ abstract class WidgetPluginBase extends PluginBase implements WidgetPluginInterf 'class' => [$facet->getActiveItems() ? 'facet-active' : 'facet-inactive'], ], '#context' => !empty($widget['type']) ? ['list_style' => $widget['type']] : [], - '#cache' => [ - 'contexts' => [ - 'url.path', - 'url.query_args', - ], - ], ]; } diff --git a/templates/facets-views-plugin.html.twig b/templates/facets-views-plugin.html.twig new file mode 100644 index 0000000..ce4f5d0 --- /dev/null +++ b/templates/facets-views-plugin.html.twig @@ -0,0 +1,3 @@ +
+ {{ content }} +
diff --git a/tests/facets_processors_collection/facets_processors_collection.info.yml b/tests/facets_processors_collection/facets_processors_collection.info.yml new file mode 100644 index 0000000..03bf078 --- /dev/null +++ b/tests/facets_processors_collection/facets_processors_collection.info.yml @@ -0,0 +1,8 @@ +name: 'Facets processors collection' +type: module +description: 'Contains collection of test facet processors' +package: 'Testing' +hidden: true +core_version_requirement: ^9.2 || ^10.0 +dependencies: + - facets:facets diff --git a/tests/facets_processors_collection/facets_processors_collection.module b/tests/facets_processors_collection/facets_processors_collection.module new file mode 100644 index 0000000..1073ff9 --- /dev/null +++ b/tests/facets_processors_collection/facets_processors_collection.module @@ -0,0 +1,20 @@ +get('facets_processors_collection_alter_string_query_handler', FALSE) + ) { + $query_types['string'] = 'search_api_string_cached'; + } +} diff --git a/tests/facets_processors_collection/facets_processors_collection.services.yml b/tests/facets_processors_collection/facets_processors_collection.services.yml new file mode 100644 index 0000000..4e9e133 --- /dev/null +++ b/tests/facets_processors_collection/facets_processors_collection.services.yml @@ -0,0 +1,28 @@ +services: + cache_context.fpc_build: + class: Drupal\facets_processors_collection\Cache\FpcCacheContext + argumets: + type: build + tags: + - { name: cache.context } + + cache_context.fpc_sort: + class: Drupal\facets_processors_collection\Cache\FpcCacheContext + argumets: + type: sort + tags: + - { name: cache.context } + + cache_context.fpc_post_query: + class: Drupal\facets_processors_collection\Cache\FpcCacheContext + argumets: + type: post_query + tags: + - { name: cache.context } + + cache_context.fpc_query_type_plugin: + class: Drupal\facets_processors_collection\Cache\FpcCacheContext + argumets: + type: query_type_plugin + tags: + - { name: cache.context } diff --git a/tests/facets_processors_collection/src/Cache/FpcCacheContext.php b/tests/facets_processors_collection/src/Cache/FpcCacheContext.php new file mode 100644 index 0000000..0076003 --- /dev/null +++ b/tests/facets_processors_collection/src/Cache/FpcCacheContext.php @@ -0,0 +1,86 @@ +type = $type; + } + + /** + * Get all allowed context types. + * + * @return array + * Array of context types: all processor stages + query_type plugin. + */ + protected static function getAllowedTypes() { + return array_merge(static::$processorStages, [static::QUERY_PLUGIN]); + } + + /** + * {@inheritdoc} + */ + public static function getLabel() { + return t( + 'FPC: cache context, cab be one of the following: %stages.', + ['%stages' => implode(', ', static::getAllowedTypes())] + ); + } + + /** + * {@inheritdoc} + */ + public function getContext() { + return 'fpc_' . $this->type; + } + + /** + * {@inheritdoc} + */ + public function getCacheableMetadata() { + return new CacheableMetadata(); + } + +} diff --git a/tests/facets_processors_collection/src/Plugin/facets/processor/FpcBuildProcessor.php b/tests/facets_processors_collection/src/Plugin/facets/processor/FpcBuildProcessor.php new file mode 100644 index 0000000..a7bd65a --- /dev/null +++ b/tests/facets_processors_collection/src/Plugin/facets/processor/FpcBuildProcessor.php @@ -0,0 +1,59 @@ +setDisplayValue('Test ' . $result->getDisplayValue()); + } + // An example cache tag that can be added from the ::build(). + $facet->addCacheTags(['fpc:added_within_build_method']); + + return $results; + } + + /** + * {@inheritdoc} + */ + public function getCacheTags() { + return Cache::mergeTags(parent::getCacheTags(), ['fpc:build_processor']); + } + + /** + * {@inheritdoc} + */ + public function getCacheContexts() { + return Cache::mergeContexts(parent::getCacheContexts(), ['fpc_build']); + } + + /** + * {@inheritdoc} + */ + public function getQueryType() { + return 'search_api_string_cached'; + } + +} diff --git a/tests/facets_processors_collection/src/Plugin/facets/processor/FpcPostQueryProcessor.php b/tests/facets_processors_collection/src/Plugin/facets/processor/FpcPostQueryProcessor.php new file mode 100644 index 0000000..ba2a61c --- /dev/null +++ b/tests/facets_processors_collection/src/Plugin/facets/processor/FpcPostQueryProcessor.php @@ -0,0 +1,45 @@ +addCacheTags(['fpc:added_within_postQuery_method']); + } + +} diff --git a/tests/facets_processors_collection/src/Plugin/facets/processor/FpcSortProcessor.php b/tests/facets_processors_collection/src/Plugin/facets/processor/FpcSortProcessor.php new file mode 100644 index 0000000..45a10cd --- /dev/null +++ b/tests/facets_processors_collection/src/Plugin/facets/processor/FpcSortProcessor.php @@ -0,0 +1,53 @@ +disables cache"), + * stages = { + * "sort" = 50 + * } + * ) + */ +class FpcSortRandomProcessor extends FpcSortProcessor { + + /** + * {@inheritdoc} + */ + public function sortResults(Result $a, Result $b) { + return random_int(-1, 1); + } + + /** + * {@inheritdoc} + */ + public function getCacheMaxAge() { + // As sorting should be random, we can't cache results. + return 0; + } + +} diff --git a/tests/facets_processors_collection/src/Plugin/facets/query_type/CacheableQueryTypePlugin.php b/tests/facets_processors_collection/src/Plugin/facets/query_type/CacheableQueryTypePlugin.php new file mode 100644 index 0000000..c5c948b --- /dev/null +++ b/tests/facets_processors_collection/src/Plugin/facets/query_type/CacheableQueryTypePlugin.php @@ -0,0 +1,27 @@ +query->addCacheTags(['fpc:query_plugin_type_plugin']); + $this->query->addCacheContexts(['fpc_query_type_plugin']); + } + +} diff --git a/tests/facets_search_api_dependency/config/install/views.view.search_api_test_view.yml b/tests/facets_search_api_dependency/config/install/views.view.search_api_test_view.yml index d13feac..0b617b0 100644 --- a/tests/facets_search_api_dependency/config/install/views.view.search_api_test_view.yml +++ b/tests/facets_search_api_dependency/config/install/views.view.search_api_test_view.yml @@ -1,76 +1,34 @@ -base_field: search_api_id -base_table: search_api_index_database_search_index -core: 8.x -description: '' +langcode: en status: true +dependencies: + config: + - search_api.index.database_search_index + module: + - search_api +id: search_api_test_view +label: 'Search API Test Fulltext search view' +module: views +description: '' +tag: '' +base_table: search_api_index_database_search_index +base_field: search_api_id display: default: - display_plugin: default id: default display_title: Master + display_plugin: default position: 0 display_options: - access: - type: none - options: { } - cache: - type: none - options: { } - query: - type: search_api_query - options: - skip_access: true - exposed_form: - type: basic - options: - submit_button: Search - reset_button: false - reset_button_label: Reset - exposed_sorts_label: 'Sort by' - expose_sort_order: true - sort_asc_label: Asc - sort_desc_label: Desc - pager: - type: full - options: - items_per_page: 10 - offset: 0 - id: 0 - total_pages: null - expose: - items_per_page: false - items_per_page_label: 'Items per page' - items_per_page_options: '5, 10, 20, 40, 60' - items_per_page_options_all: false - items_per_page_options_all_label: '- All -' - offset: false - offset_label: Offset - tags: - previous: '‹ previous' - next: 'next ›' - first: '« first' - last: 'last »' - quantity: 9 - style: - type: default - row: - type: search_api - options: - view_modes: - bundle: - 'article': default - 'page': default - datasource: - 'entity:entity_test': default + title: 'Fulltext test index' fields: search_api_id: + id: search_api_id table: search_api_index_database_search_index field: search_api_id - id: search_api_id - plugin_id: numeric relationship: none group_type: group admin_label: '' + plugin_id: numeric label: 'Entity ID' exclude: false alter: @@ -117,9 +75,81 @@ display: decimal: . separator: ',' format_plural: false - format_plural_string: "1\x03@count" + format_plural_string: !!binary MQNAY291bnQ= prefix: '' suffix: '' + pager: + type: full + options: + offset: 0 + items_per_page: 10 + total_pages: null + id: 0 + tags: + next: 'next ›' + previous: '‹ previous' + first: '« first' + last: 'last »' + expose: + items_per_page: false + items_per_page_label: 'Items per page' + items_per_page_options: '5, 10, 20, 40, 60' + items_per_page_options_all: false + items_per_page_options_all_label: '- All -' + offset: false + offset_label: Offset + quantity: 9 + exposed_form: + type: basic + options: + submit_button: Search + reset_button: false + reset_button_label: Reset + exposed_sorts_label: 'Sort by' + expose_sort_order: true + sort_asc_label: Asc + sort_desc_label: Desc + access: + type: none + options: { } + cache: + type: none + options: { } + empty: { } + sorts: + search_api_id: + id: search_api_id + table: search_api_index_database_search_index + field: search_api_id + relationship: none + group_type: group + admin_label: '' + plugin_id: search_api + order: ASC + expose: + label: '' + field_identifier: search_api_id + exposed: false + arguments: + search_api_datasource: + id: search_api_datasource + table: search_api_index_database_search_index + field: search_api_datasource + plugin_id: search_api + break_phrase: true + type: + id: type + table: search_api_index_database_search_index + field: type + plugin_id: search_api + break_phrase: false + not: true + keywords: + id: keywords + table: search_api_index_database_search_index + field: keywords + plugin_id: search_api + break_phrase: true filters: search_api_fulltext: id: search_api_fulltext @@ -128,6 +158,7 @@ display: relationship: none group_type: group admin_label: '' + plugin_id: search_api_fulltext operator: and value: '' group: 1 @@ -138,6 +169,8 @@ display: description: '' use_operator: true operator: search_api_fulltext_op + operator_limit_selection: false + operator_list: { } identifier: search_api_fulltext required: false remember: false @@ -160,14 +193,13 @@ display: group_items: { } min_length: 3 fields: { } - plugin_id: search_api_fulltext id: - plugin_id: search_api_numeric id: id table: search_api_index_database_search_index field: id relationship: none admin_label: '' + plugin_id: search_api_numeric operator: '=' group: 1 exposed: true @@ -177,6 +209,8 @@ display: description: '' use_operator: true operator: id_op + operator_limit_selection: false + operator_list: { } identifier: id required: false remember: false @@ -187,12 +221,12 @@ display: administrator: '0' is_grouped: false created: - plugin_id: search_api_date id: created table: search_api_index_database_search_index field: created relationship: none admin_label: '' + plugin_id: search_api_date operator: '=' group: 1 exposed: true @@ -202,6 +236,8 @@ display: description: '' use_operator: true operator: created_op + operator_limit_selection: false + operator_list: { } identifier: created required: false remember: false @@ -212,12 +248,12 @@ display: administrator: '0' is_grouped: false keywords: - plugin_id: search_api_string id: keywords table: search_api_index_database_search_index field: keywords relationship: none admin_label: '' + plugin_id: search_api_string operator: '=' group: 1 exposed: true @@ -227,6 +263,8 @@ display: description: '' use_operator: true operator: keywords_op + operator_limit_selection: false + operator_list: { } identifier: keywords required: false remember: false @@ -237,13 +275,13 @@ display: administrator: '0' is_grouped: false search_api_language: - plugin_id: search_api_language id: search_api_language table: search_api_index_database_search_index field: search_api_language relationship: none admin_label: '' - operator: 'in' + plugin_id: search_api_language + operator: in group: 1 exposed: true expose: @@ -252,6 +290,8 @@ display: description: '' use_operator: true operator: language_op + operator_limit_selection: false + operator_list: { } identifier: language required: false remember: false @@ -261,20 +301,22 @@ display: anonymous: '0' administrator: '0' is_grouped: false - sorts: - search_api_id: - id: search_api_id - table: search_api_index_database_search_index - field: search_api_id - relationship: none - group_type: group - admin_label: '' - order: ASC - exposed: false - expose: - label: '' - plugin_id: search_api - title: 'Fulltext test index' + style: + type: default + row: + type: search_api + options: + view_modes: + bundle: + article: default + page: default + datasource: + 'entity:entity_test': default + query: + type: search_api_query + options: + skip_access: true + relationships: { } header: result: id: result @@ -283,54 +325,117 @@ display: relationship: none group_type: group admin_label: '' - content: 'Displaying @total search results' plugin_id: result + content: 'Displaying @total search results' footer: { } - empty: { } - relationships: { } - arguments: - search_api_datasource: - plugin_id: search_api - id: search_api_datasource - table: search_api_index_database_search_index - field: search_api_datasource - break_phrase: true - type: - plugin_id: search_api - id: type - table: search_api_index_database_search_index - field: type - break_phrase: false - not: true - keywords: - plugin_id: search_api - id: keywords - table: search_api_index_database_search_index - field: keywords - break_phrase: true + display_extenders: { } + cache_metadata: + max-age: -1 + contexts: + - 'languages:language_interface' + - url + - url.query_args + tags: + - 'config:search_api.index.database_search_index' + block_1: + id: block_1 + display_title: Block + display_plugin: block + position: 3 + display_options: + defaults: + use_ajax: false + use_ajax: true + display_extenders: { } + cache_metadata: + max-age: -1 + contexts: + - 'languages:language_interface' + - url + - url.query_args + tags: + - 'config:search_api.index.database_search_index' + block_1_sapi_tag: + id: block_1_sapi_tag + display_title: 'Block Search API cache tag' + display_plugin: block + position: 4 + display_options: + cache: + type: search_api_tag + options: { } + defaults: + cache: false + use_ajax: false + use_ajax: true + display_extenders: { } + cache_metadata: + max-age: -1 + contexts: + - 'languages:language_interface' + - url + - url.query_args + tags: + - 'config:search_api.index.database_search_index' page_1: - display_plugin: page id: page_1 display_title: Page + display_plugin: page position: 1 display_options: + display_extenders: { } path: search-api-test-fulltext - block_1: - display_plugin: block - id: block_1 - display_title: Block + cache_metadata: + max-age: -1 + contexts: + - 'languages:language_interface' + - url + - url.query_args + tags: + - 'config:search_api.index.database_search_index' + page_2_sapi_tag: + id: page_2_sapi_tag + display_title: 'Page Search API cache tag' + display_plugin: page position: 2 display_options: + cache: + type: search_api_tag + options: { } + defaults: + cache: false display_extenders: { } + path: search-api-test-fulltext-cache-tag + cache_metadata: + max-age: -1 + contexts: + - 'languages:language_interface' + - url + - url.query_args + tags: + - 'config:search_api.index.database_search_index' + page_2_sapi_time: + id: page_2_sapi_time + display_title: 'Page Search API cache time' + display_plugin: page + position: 2 + display_options: + cache: + type: search_api_time + options: + results_lifespan: 21600 + results_lifespan_custom: 0 + output_lifespan: 518400 + output_lifespan_custom: 0 defaults: - use_ajax: false - use_ajax: true -label: 'Search API Test Fulltext search view' -module: views -id: search_api_test_view -tag: '' -langcode: en -dependencies: - module: - - search_api - - facets_search_api_dependency + cache: false + display_extenders: { } + path: search-api-test-fulltext-cache-time + cache_metadata: + max-age: -1 + contexts: + - 'languages:language_interface' + - url + - url.query_args + tags: + - 'config:search_api.index.database_search_index' diff --git a/tests/src/Functional/BreadcrumbIntegrationTest.php b/tests/src/Functional/BreadcrumbIntegrationTest.php index 7aa9b48..47d29c7 100644 --- a/tests/src/Functional/BreadcrumbIntegrationTest.php +++ b/tests/src/Functional/BreadcrumbIntegrationTest.php @@ -121,7 +121,7 @@ class BreadcrumbIntegrationTest extends FacetsTestBase { */ protected function editFacetConfig(array $config = []) { $this->drupalGet('admin/config/search/facets'); - $this->clickLink('Configure', 1); + $this->clickLink('Configure', 2); $default_config = [ 'filter_key' => 'f', 'url_processor' => 'query_string', diff --git a/tests/src/Functional/HierarchicalFacetIntegrationTest.php b/tests/src/Functional/HierarchicalFacetIntegrationTest.php index a3052cd..19536b6 100644 --- a/tests/src/Functional/HierarchicalFacetIntegrationTest.php +++ b/tests/src/Functional/HierarchicalFacetIntegrationTest.php @@ -383,7 +383,7 @@ class HierarchicalFacetIntegrationTest extends FacetsTestBase { */ public function testHierarchyBreadcrumb() { $this->drupalGet('admin/config/search/facets'); - $this->clickLink('Configure', 1); + $this->clickLink('Configure', 2); $default_config = [ 'filter_key' => 'f', 'url_processor' => 'query_string', diff --git a/tests/src/Functional/IntegrationCacheTest.php b/tests/src/Functional/IntegrationCacheTest.php new file mode 100644 index 0000000..2bd61a6 --- /dev/null +++ b/tests/src/Functional/IntegrationCacheTest.php @@ -0,0 +1,766 @@ +enableWebsiteCache(); + $this->setUpExampleStructure(); + $this->insertExampleContent(); + $this->assertEquals(5, $this->indexItems($this->indexId), '5 items were indexed.'); + + $this->facetStorage = $this->container->get('entity_type.manager') + ->getStorage('facets_facet'); + $this->entityTestStorage = \Drupal::entityTypeManager() + ->getStorage('entity_test_mulrev_changed'); + } + + /** + * Tests various operations via the Facets' admin UI. + * + * Cached implementation of testBlockView integration test. + * + * @see \Drupal\Tests\facets\Functional\IntegrationTest::testBlockView() + */ + public function testFramework() { + $facet_id = 'test_facet_name'; + $this->drupalGet(static::VIEW_URL); + // By default, the view should show all entities. + $this->assertSession()->pageTextContains('Displaying 5 search results'); + + $this->createFacet('Test Facet name', $facet_id, 'type', static::VIEW_DISPLAY); + + // Verify that the facet results are correct. + $this->drupalGet(static::VIEW_URL); + $this->assertSession()->pageTextContains('item'); + $this->assertSession()->pageTextContains('article'); + + // Verify that facet blocks appear as expected. + $this->assertFacetBlocksAppear(); + + // Verify that the facet only shows when the facet source is visible, it + // should not show up on the user page. + $this->drupalGet(''); + $this->assertNoFacetBlocksAppear(); + + // Do not show the block on empty behaviors. + $this->clearIndex(); + $this->drupalGet(static::VIEW_URL); + + // Verify that no facet blocks appear. Empty behavior "None" is selected by + // default. + $this->assertNoFacetBlocksAppear(); + + // Verify that the "empty_text" appears as expected. + $settings = [ + 'behavior' => 'text', + 'text' => 'No results found for this block!', + ]; + $facet = $this->getFacetById($facet_id); + $facet->setEmptyBehavior($settings); + $this->facetStorage->save($facet); + + $this->drupalGet(static::VIEW_URL); + $this->assertSession()->responseContains('block-test-facet-name'); + $this->assertSession() + ->responseContains('No results found for this block!'); + } + + /** + * Tests that a block view also works. + * + * Cached implementation of testBlockView integration test. + * + * @see \Drupal\Tests\facets\Functional\IntegrationTest::testBlockView() + */ + public function testBlockView() { + $webAssert = $this->assertSession(); + $this->createFacet( + 'Block view facet', + 'block_view_facet', + 'type', + 'block_1_sapi_tag', + 'views_block__search_api_test_view' + ); + + // Place the views block in the footer of all pages. + $block_settings = [ + 'region' => 'sidebar_first', + 'id' => 'view_block', + ]; + $this->drupalPlaceBlock('views_block:search_api_test_view-block_1_sapi_tag', $block_settings); + + // By default, the view should show all entities. + $this->drupalGet(''); + $webAssert->pageTextContains('Fulltext test index'); + $webAssert->pageTextContains('Displaying 5 search results'); + $webAssert->pageTextContains('item'); + $webAssert->pageTextContains('article'); + + // Click the item link, and test that filtering of results actually works. + $this->clickLink('item'); + $webAssert->pageTextContains('Displaying 3 search results'); + } + + /** + * Tests that an url alias works correctly. + * + * Cached implementation of testUrlAlias integration test. + * + * @see \Drupal\Tests\facets\Functional\IntegrationTest::testUrlAlias() + */ + public function testUrlAlias() { + $facet_id = 'ab_facet'; + $this->createFacet('ab Facet', $facet_id, 'type', static::VIEW_DISPLAY); + + $this->drupalGet(static::VIEW_URL); + $this->assertFacetLabel('item'); + $this->assertFacetLabel('article'); + + $this->clickLink('item'); + $url = Url::fromUserInput('/' . static::VIEW_URL, ['query' => ['f' => ['ab_facet:item']]]); + $this->assertSession()->addressEquals($url); + + $this->updateFacet($facet_id, ['url_alias' => 'llama']); + + $this->drupalGet(static::VIEW_URL); + $this->assertFacetLabel('item'); + $this->assertFacetLabel('article'); + + $this->clickLink('item'); + $url = Url::fromUserInput('/' . static::VIEW_URL, ['query' => ['f' => ['llama:item']]]); + $this->assertSession()->addressEquals($url); + } + + /** + * Tests facet dependencies. + * + * Cached implementation of testFacetDependencies integration test. + * + * @see \Drupal\Tests\facets\Functional\IntegrationTest::testFacetDependencies() + */ + public function testFacetDependencies() { + $facet_name = "DependableFacet"; + $facet_id = 'dependablefacet'; + + $depending_facet_name = "DependingFacet"; + $depending_facet_id = "dependingfacet"; + + $this->createFacet($facet_name, $facet_id, 'type', static::VIEW_DISPLAY); + $this->createFacet($depending_facet_name, $depending_facet_id, 'keywords', static::VIEW_DISPLAY); + + // Go to the view and test that both facets are shown. Item and article + // come from the DependableFacet, orange and grape come from DependingFacet. + $this->drupalGet(static::VIEW_URL); + $this->assertFacetLabel('grape'); + $this->assertFacetLabel('orange'); + $this->assertFacetLabel('item'); + $this->assertFacetLabel('article'); + $this->assertFacetBlocksAppear(); + + // Change the visiblity settings of the DependingFacet. + $facet = $this->getFacetById($depending_facet_id); + $processor = [ + 'processor_id' => 'dependent_processor', + 'weights' => ['build' => 5], + 'settings' => [ + $facet_id => [ + 'enable' => TRUE, + 'condition' => 'values', + 'values' => 'item', + 'negate' => FALSE, + ], + ], + ]; + $facet->addProcessor($processor); + $this->facetStorage->save($facet); + + // Go to the view and test that only the types are shown. + $this->drupalGet(static::VIEW_URL); + $this->assertSession()->linkNotExists('grape'); + $this->assertSession()->linkNotExists('orange'); + $this->assertFacetLabel('item'); + $this->assertFacetLabel('article'); + + // Click on the item, and test that this shows the keywords. + $this->clickLink('item'); + $this->assertFacetLabel('grape'); + $this->assertFacetLabel('orange'); + + // Go back to the view, click on article and test that the keywords are + // hidden. + $this->drupalGet(static::VIEW_URL); + $this->clickLink('article'); + $this->assertSession()->linkNotExists('grape'); + $this->assertSession()->linkNotExists('orange'); + + // Change the visibility settings to negate the previous settings. + $processor['settings'][$facet_id]['negate'] = TRUE; + $facet->addProcessor($processor); + $this->facetStorage->save($facet); + + // Go to the view and test only the type facet is shown. + $this->drupalGet(static::VIEW_URL); + $this->assertFacetLabel('item'); + $this->assertFacetLabel('article'); + $this->assertFacetLabel('grape'); + $this->assertFacetLabel('orange'); + + // Click on the article, and test that this shows the keywords. + $this->clickLink('article'); + $this->assertFacetLabel('grape'); + $this->assertFacetLabel('orange'); + + // Go back to the view, click on item and test that the keywords are + // hidden. + $this->drupalGet(static::VIEW_URL); + $this->clickLink('item'); + $this->assertSession()->linkNotExists('grape'); + $this->assertSession()->linkNotExists('orange'); + + // Disable negation again. + $processor['settings'][$facet_id]['negate'] = FALSE; + $facet->addProcessor($processor); + $this->facetStorage->save($facet); + + $this->drupalGet(static::VIEW_URL); + $this->assertSession()->pageTextContains('Displaying 5 search results'); + $this->assertSession()->linkNotExists('grape'); + $this->clickLink('item'); + $this->assertSession()->pageTextContains('Displaying 3 search results'); + $this->assertSession()->linkExists('grape'); + $this->clickLink('grape'); + $this->assertSession()->pageTextContains('Displaying 1 search results'); + // Disable item again, and the grape should not be reflected in the search + // result anymore. + $this->clickLink('item'); + $this->assertSession()->pageTextContains('Displaying 5 search results'); + } + + /** + * Tests the facet's and/or functionality. + * + * Cached implementation of testAndOrFacet integration test. + * + * @see \Drupal\Tests\facets\Functional\IntegrationTest::testAndOrFacet() + */ + public function testAndOrFacet() { + $facet_id = 'test_facet'; + + $this->createFacet('test & facet', $facet_id, 'type', static::VIEW_DISPLAY); + $this->updateFacet($facet_id, ['query_operator' => 'and']); + + $this->drupalGet(static::VIEW_URL); + $this->assertFacetLabel('item'); + $this->assertFacetLabel('article'); + + $this->clickLink('item'); + $this->checkFacetIsActive('item'); + $this->assertSession()->linkNotExists('article'); + + $this->updateFacet($facet_id, ['query_operator' => 'or']); + + $this->drupalGet(static::VIEW_URL); + $this->assertFacetLabel('item'); + $this->assertFacetLabel('article'); + + $this->clickLink('item (3)'); + $this->checkFacetIsActive('item'); + $this->assertFacetLabel('article (2)'); + } + + /** + * Tests the facet's exclude functionality. + * + * Cached implementation of testExcludeFacet integration test. + * + * @see \Drupal\Tests\facets\Functional\IntegrationTest::testExcludeFacet() + */ + public function testExcludeFacet() { + $facet_id = 'test_facet'; + $this->createFacet('test & facet', $facet_id, 'type', static::VIEW_DISPLAY); + $this->updateFacet($facet_id, ['exclude' => TRUE]); + + $this->drupalGet(static::VIEW_URL); + $this->assertSession()->pageTextContains('foo bar baz'); + $this->assertSession()->pageTextContains('foo baz'); + $this->assertFacetLabel('item'); + + $this->clickLink('item'); + $this->checkFacetIsActive('item'); + $this->assertSession()->pageTextContains('foo baz'); + $this->assertSession()->pageTextContains('bar baz'); + $this->assertSession()->pageTextNotContains('foo bar baz'); + + $this->updateFacet($facet_id, ['exclude' => FALSE]); + + $this->drupalGet(static::VIEW_URL); + $this->assertSession()->pageTextContains('foo bar baz'); + $this->assertSession()->pageTextContains('foo baz'); + $this->assertFacetLabel('item'); + + $this->clickLink('item'); + $this->checkFacetIsActive('item'); + $this->assertSession()->pageTextContains('foo bar baz'); + $this->assertSession()->pageTextContains('foo test'); + $this->assertSession()->pageTextContains('bar'); + $this->assertSession()->pageTextNotContains('foo baz'); + } + + /** + * Tests the facet's exclude functionality for a date field. + * + * Cached implementation of testExcludeFacetDate integration test. + * + * @see \Drupal\Tests\facets\Functional\IntegrationTest::testExcludeFacetDate() + */ + public function testExcludeFacetDate() { + $facet_id = $field_name = 'created'; + + $this->entityTestStorage->create([ + 'name' => 'foo new', + 'body' => 'test test', + 'type' => 'item', + 'keywords' => ['orange'], + 'category' => 'item_category', + $field_name => 1490000000, + ])->save(); + + $this->entityTestStorage->create([ + 'name' => 'foo old', + 'body' => 'test test', + 'type' => 'item', + 'keywords' => ['orange'], + 'category' => 'item_category', + $field_name => 1460000000, + ])->save(); + + $this->assertEquals(2, $this->indexItems($this->indexId), '2 items were indexed.'); + + $this->createFacet('Created', $facet_id, $field_name, static::VIEW_DISPLAY); + $facet = $this->getFacetById($facet_id); + $facet->addProcessor([ + 'processor_id' => 'date_item', + 'weights' => ['build' => 35], + 'settings' => [ + 'date_display' => 'actual_date', + 'granularity' => SearchApiDate::FACETAPI_DATE_MONTH, + 'hierarchy' => FALSE, + 'date_format' => '', + ], + ]); + $this->facetStorage->save($facet); + + $this->drupalGet(static::VIEW_URL); + $this->assertSession()->pageTextContains('foo old'); + $this->assertSession()->pageTextContains('foo new'); + $this->clickLink('March 2017'); + $this->checkFacetIsActive('March 2017'); + $this->assertSession()->pageTextContains('foo new'); + $this->assertSession()->pageTextNotContains('foo old'); + + $this->updateFacet($facet->id(), ['exclude' => TRUE]); + + $this->drupalGet(static::VIEW_URL); + $this->clickLink('March 2017'); + $this->checkFacetIsActive('March 2017'); + $this->assertSession()->pageTextContains('foo old'); + $this->assertSession()->pageTextNotContains('foo new'); + } + + /** + * Tests allow only one active item. + * + * Cached implementation of testAllowOneActiveItem integration test. + * + * @see \Drupal\Tests\facets\Functional\IntegrationTest::testAllowOneActiveItem() + */ + public function testAllowOneActiveItem() { + $this->createFacet('Spotted wood owl', 'spotted_wood_owl', 'keywords', static::VIEW_DISPLAY); + + $facet = $this->getFacetById('spotted_wood_owl'); + $facet->setShowOnlyOneResult(TRUE); + $this->facetStorage->save($facet); + + $this->drupalGet(static::VIEW_URL); + $this->assertSession()->pageTextContains('Displaying 5 search results'); + $this->assertFacetLabel('grape'); + $this->assertFacetLabel('orange'); + + $this->clickLink('grape'); + $this->assertSession()->pageTextContains('Displaying 3 search results'); + $this->checkFacetIsActive('grape'); + $this->assertFacetLabel('orange'); + + $this->clickLink('orange'); + $this->assertSession()->pageTextContains('Displaying 3 search results'); + $this->assertFacetLabel('grape'); + $this->checkFacetIsActive('orange'); + } + + /** + * Tests calculations of facet count. + * + * Cached implementation of testFacetCountCalculations integration test. + * + * @see \Drupal\Tests\facets\Functional\IntegrationTest::testFacetCountCalculations() + */ + public function testFacetCountCalculations() { + $this->createFacet('Type', 'type', 'type', static::VIEW_DISPLAY); + $this->createFacet('Keywords', 'keywords', 'keywords', static::VIEW_DISPLAY); + foreach (['type', 'keywords'] as $facet_id) { + $this->updateFacet($facet_id, ['query_operator' => 'and']); + } + + $this->drupalGet(static::VIEW_URL); + $this->assertSession()->pageTextContains('Displaying 5 search results'); + $this->assertFacetLabel('article (2)'); + $this->assertFacetLabel('grape (3)'); + + // Make sure that after clicking on article, which has only 2 entities, + // there are only 2 items left in the results for other facets as well. + // In this case, that means we can't have 3 entities tagged with grape. Both + // remaining entities are tagged with grape and strawberry. + $this->clickPartialLink('article'); + $this->assertSession()->pageTextNotContains('(3)'); + $this->assertFacetLabel('grape (2)'); + $this->assertFacetLabel('strawberry (2)'); + + $this->drupalGet(static::VIEW_URL); + $this->assertSession()->pageTextContains('Displaying 5 search results'); + $this->assertFacetLabel('article (2)'); + $this->assertFacetLabel('grape (3)'); + + // Make sure that after clicking on grape, which has only 3 entities, there + // are only 3 items left in the results for other facets as well. In this + // case, that means 2 entities of type article and 1 item. + $this->clickPartialLink('grape'); + $this->assertSession()->pageTextContains('Displaying 3 search results'); + $this->assertFacetLabel('article (2)'); + $this->assertFacetLabel('item (1)'); + } + + /** + * Tests the hard limit setting. + * + * Cached implementation of testHardLimit integration test. + * + * @see \Drupal\Tests\facets\Functional\IntegrationTest::testHardLimit() + */ + public function testHardLimit() { + $this->createFacet('Owl', 'owl', 'keywords', static::VIEW_DISPLAY); + $facet = $this->getFacetById('owl'); + $facet->addProcessor([ + 'processor_id' => 'active_widget_order', + 'weights' => ['sort' => 20], + 'settings' => [], + ]); + $facet->addProcessor([ + 'processor_id' => 'display_value_widget_order', + 'weights' => ['build' => 40], + 'settings' => [], + ]); + $this->facetStorage->save($facet); + + $this->drupalGet(static::VIEW_URL); + $this->assertSession()->pageTextContains('Displaying 5 search results'); + $this->assertFacetLabel('grape (3)'); + $this->assertFacetLabel('orange (3)'); + $this->assertFacetLabel('apple (2)'); + $this->assertFacetLabel('banana (1)'); + $this->assertFacetLabel('strawberry (2)'); + + $this->updateFacet($facet->id(), ['hard_limit' => 3]); + + $this->drupalGet(static::VIEW_URL); + // We're still testing for 5 search results here, the hard limit only limits + // the facets, not the search results. + $this->assertSession()->pageTextContains('Displaying 5 search results'); + $this->assertFacetLabel('grape (3)'); + $this->assertFacetLabel('orange (3)'); + $this->assertFacetLabel('apple (2)'); + $this->assertSession()->pageTextNotContains('banana (0)'); + $this->assertSession()->pageTextNotContains('strawberry (0)'); + } + + /** + * Test minimum amount of items. + * + * Cached implementation of testMinimumAmount integration test. + * + * @see \Drupal\Tests\facets\Functional\IntegrationTest::testMinimumAmount() + */ + public function testMinimumAmount() { + $this->createFacet('Elf owl', 'elf_owl', 'type', static::VIEW_DISPLAY); + $this->updateFacet('elf_owl', ['min_count' => 1]); + + // See that both article and item are showing. + $this->drupalGet(static::VIEW_URL); + $this->assertSession()->pageTextContains('Displaying 5 search results'); + $this->assertFacetLabel('article (2)'); + $this->assertFacetLabel('item (3)'); + + $this->updateFacet('elf_owl', ['min_count' => 3]); + + // See that article is now hidden, item should still be showing. + $this->drupalGet(static::VIEW_URL); + $this->assertSession()->pageTextContains('Displaying 5 search results'); + $this->assertSession()->pageTextNotContains('article'); + $this->assertFacetLabel('item (3)'); + } + + /** + * Tests the visibility of facet source. + * + * Cached implementation of testFacetSourceVisibility integration test. + * + * @see \Drupal\Tests\facets\Functional\IntegrationTest::testFacetSourceVisibility() + */ + public function testFacetSourceVisibility() { + $this->createFacet('Vicuña', 'vicuna', 'type', static::VIEW_DISPLAY); + // Facet appears only on the search page for which it was created. + $this->drupalGet(static::VIEW_URL); + $this->assertFacetBlocksAppear(); + $this->drupalGet(''); + $this->assertNoFacetBlocksAppear(); + + $facet = $this->getFacetById('vicuna'); + $facet->setOnlyVisibleWhenFacetSourceIsVisible(FALSE); + $this->facetStorage->save($facet); + + // Test that the facet source is visible on the search page and user/2 page. + $this->drupalGet(static::VIEW_URL); + $this->assertFacetBlocksAppear(); + $this->drupalGet(''); + $this->assertFacetBlocksAppear(); + } + + /** + * Tests behavior with multiple enabled facets and their interaction. + * + * Cached implementation of testMultipleFacets integration test. + * + * @see \Drupal\Tests\facets\Functional\IntegrationTest::testMultipleFacets() + */ + public function testMultipleFacets() { + // Create 2 facets. + $this->createFacet('Snow Owl', 'snow_owl', 'type', static::VIEW_DISPLAY); + $this->createFacet('Forest Owl', 'forest_owl', 'category', static::VIEW_DISPLAY); + + foreach (['snow_owl', 'forest_owl'] as $facet_id) { + $this->updateFacet($facet_id, ['min_count' => 0]); + } + + // Go to the view and check the default behavior. + $this->drupalGet(static::VIEW_URL); + $this->assertSession()->pageTextContains('Displaying 5 search results'); + $this->assertFacetLabel('item (3)'); + $this->assertFacetLabel('article (2)'); + $this->assertFacetLabel('item_category (2)'); + $this->assertFacetLabel('article_category (2)'); + + // Start filtering. + $this->clickPartialLink('item_category'); + $this->assertSession()->pageTextContains('Displaying 2 search results'); + $this->checkFacetIsActive('item_category'); + $this->assertFacetLabel('item (2)'); + + // Go back to the overview and start another filter, from the second facet + // block this time. + $this->drupalGet(static::VIEW_URL); + $this->assertSession()->pageTextContains('Displaying 5 search results'); + $this->clickPartialLink('article (2)'); + $this->assertSession()->pageTextContains('Displaying 2 search results'); + $this->checkFacetIsActive('article'); + $this->assertFacetLabel('article_category (2)'); + $this->assertFacetLabel('item_category (0)'); + } + + /** + * Tests that the configuration for showing a title works. + * + * Cached implementation of testShowTitle integration test. + * + * @see \Drupal\Tests\facets\Functional\IntegrationTest::testShowTitle() + */ + public function testShowTitle() { + $this->createFacet('Llama', 'llama', 'type', static::VIEW_DISPLAY); + $this->drupalGet(static::VIEW_URL); + $this->assertSession()->pageTextNotContains('Llama'); + + $this->updateFacet('llama', ['show_title' => TRUE]); + + $this->drupalGet(static::VIEW_URL); + $this->assertSession()->responseContains('

Llama

'); + $this->assertSession()->pageTextContains('Llama'); + } + + /** + * Test facet blocks cache max ages. + */ + public function testCacheMaxAges() { + $block_view_builder = \Drupal::entityTypeManager()->getViewBuilder('block'); + + $display_map = [ + 'page_1' => 0, + static::VIEW_DISPLAY => Cache::PERMANENT, + 'page_2_sapi_time' => 518400, + ]; + + foreach ($display_map as $display => $expected_block_max_age) { + $facet_id = 'test_facet_' . $display; + $this->createFacet('Test Facet name', $facet_id, 'type', $display); + $build = $block_view_builder->view($this->blocks[$facet_id]); + $this->assertEquals( + $expected_block_max_age, + CacheableMetadata::createFromRenderArray($build)->getCacheMaxAge(), + ); + } + } + + /** + * Test facet blocks cache invalidation. + * + * Test covers search page with a facets and standalone facet block on FP. + */ + public function testFacetBlockCacheNewContentIndexing() { + $this->createFacet('Test Facet name', 'test_facet_name', 'type', static::VIEW_DISPLAY); + + $facet = $this->getFacetById('test_facet_name'); + $facet->setOnlyVisibleWhenFacetSourceIsVisible(FALSE); + $this->facetStorage->save($facet); + + foreach (['', static::VIEW_URL] as $url) { + $this->drupalGet($url); + $this->assertFacetLabel('article (2)'); + $this->assertFacetLabel('item (3)'); + } + + $this->entityTestStorage->create([ + 'name' => 'foo jiz baz', + 'body' => 'test test and a bit more test', + 'type' => 'item', + 'keywords' => ['orange', 'black'], + 'category' => 'item_category', + ])->save(); + + // Entity was added but not indexed yet, so facet state should remain the + // same. + foreach (['', static::VIEW_URL] as $url) { + $this->drupalGet($url); + $this->assertFacetLabel('article (2)'); + $this->assertFacetLabel('item (3)'); + } + + // Index 1 remaining item and check that count has been updated. + $this->assertEquals(1, $this->indexItems($this->indexId), '1 item was indexed.'); + foreach (['', static::VIEW_URL] as $url) { + $this->drupalGet($url); + $this->assertFacetLabel('article (2)'); + $this->assertFacetLabel('item (4)'); + } + } + + /** + * Enable website page caching, set 1 day max age. + */ + protected function enableWebsiteCache() { + $max_age = 86400; + $this->config('system.performance') + ->set('cache.page.max_age', $max_age) + ->save(); + $this->drupalGet(static::VIEW_URL); + $this->assertSession() + ->responseHeaderContains('Cache-Control', 'max-age=' . $max_age); + } + + /** + * Get facet entity by ids. + * + * @param string $id + * Facet id. + * + * @return \Drupal\facets\FacetInterface + * Loaded facet object. + */ + protected function getFacetById(string $id): FacetInterface { + return $this->facetStorage->load($id); + } + + /** + * Update facet tith with given values. + * + * @param string $id + * The facet entity ID. + * @param array $settings + * Array with values keyed by property names. + * + * @return \Drupal\facets\FacetInterface + * An updated facet entity. + */ + protected function updateFacet(string $id, array $settings): FacetInterface { + $facet = $this->getFacetById($id); + foreach ($settings as $name => $value) { + $facet->set($name, $value); + } + $this->facetStorage->save($facet); + + return $facet; + } + +} diff --git a/tests/src/Functional/IntegrationTest.php b/tests/src/Functional/IntegrationTest.php index 5997420..73d9f8a 100644 --- a/tests/src/Functional/IntegrationTest.php +++ b/tests/src/Functional/IntegrationTest.php @@ -202,7 +202,7 @@ class IntegrationTest extends FacetsTestBase { $this->assertFacetLabel('article'); $this->clickLink('item'); - $url = Url::fromUserInput('/search-api-test-fulltext', ['query' => ['f[0]' => 'ab_facet:item']]); + $url = Url::fromUserInput('/search-api-test-fulltext', ['query' => ['f' => ['ab_facet:item']]]); $this->assertSession()->addressEquals($url); $this->drupalGet($facet_edit_page); @@ -213,7 +213,7 @@ class IntegrationTest extends FacetsTestBase { $this->assertFacetLabel('article'); $this->clickLink('item'); - $url = Url::fromUserInput('/search-api-test-fulltext', ['query' => ['f[0]' => 'llama:item']]); + $url = Url::fromUserInput('/search-api-test-fulltext', ['query' => ['f' => ['llama:item']]]); $this->assertSession()->addressEquals($url); } @@ -873,29 +873,34 @@ class IntegrationTest extends FacetsTestBase { * Check that the disabling of the cache works. */ public function testViewsCacheDisable() { - // Load the view, verify cache settings. - $view = Views::getView('search_api_test_view'); - $view->setDisplay('page_1'); - $current_cache = $view->display_handler->getOption('cache'); - $this->assertEquals('none', $current_cache['type']); - $view->display_handler->setOption('cache', ['type' => 'tag']); - $view->save(); - $current_cache = $view->display_handler->getOption('cache'); - $this->assertEquals('tag', $current_cache['type']); - - // Create a facet and check for the cache disabled message. - $id = "western_screech_owl"; - $name = "Western screech owl"; - $this->createFacet($name, $id); - $this->drupalGet('admin/config/search/facets/' . $id . '/settings'); - $this->submitForm([], 'Save'); - $this->assertSession()->pageTextContains('Caching of view Search API Test Fulltext search view has been disabled.'); - - // Check the view's cache settings again to see if they've been updated. - $view = Views::getView('search_api_test_view'); - $view->setDisplay('page_1'); - $current_cache = $view->display_handler->getOption('cache'); - $this->assertEquals('none', $current_cache['type']); + $caches = [ + // Tag cache plugin should be replaced by none, as it's not supported. + 'page_1' => 'none', + // Search API cache plugin shouldn't be changed. + 'page_2_sapi_tag' => 'search_api_tag', + 'page_2_sapi_time' => 'search_api_time', + ]; + foreach ($caches as $display_id => $expected_cache_plugin) { + // Create a facet and check for the cache disabled message. + $id = 'western_screech_owl_' . $display_id; + $name = 'Western screech owl'; + $this->createFacet($name, $id, 'type', $display_id); + $this->drupalGet('admin/config/search/facets/' . $id . '/settings'); + $this->submitForm([], 'Save'); + $warning = 'You may experience issues, because Search API Test Fulltext search view use cache. In case you will try to turn set cache plugin to none.'; + if ($display_id === 'page_1') { + // Make sure that user will get a warning about source cache plugin. + $this->assertSession()->pageTextNotContains($warning); + } + else { + $this->assertSession()->pageTextContains($warning); + } + // Check the view's cache settings again to see if they've been updated. + $view = Views::getView('search_api_test_view'); + $view->setDisplay($display_id); + $current_cache = $view->display_handler->getOption('cache'); + $this->assertEquals($expected_cache_plugin, $current_cache['type']); + } } /** diff --git a/tests/src/Functional/UrlIntegrationTest.php b/tests/src/Functional/UrlIntegrationTest.php index b8afb95..027ae86 100644 --- a/tests/src/Functional/UrlIntegrationTest.php +++ b/tests/src/Functional/UrlIntegrationTest.php @@ -66,7 +66,7 @@ class UrlIntegrationTest extends FacetsTestBase { // Go to the only enabled facet source's config and change the filter key. $this->drupalGet('admin/config/search/facets'); - $this->clickLink('Configure', 1); + $this->clickLink('Configure', 2); $edit = [ 'filter_key' => 'y', @@ -90,7 +90,7 @@ class UrlIntegrationTest extends FacetsTestBase { // Go to the only enabled facet source's config and change the url // processor. $this->drupalGet('admin/config/search/facets'); - $this->clickLink('Configure', 1); + $this->clickLink('Configure', 2); $edit = [ 'filter_key' => 'y', diff --git a/tests/src/Kernel/Entity/FacetFacetSourceTest.php b/tests/src/Kernel/Entity/FacetFacetSourceTest.php index e29daf4..c01a545 100644 --- a/tests/src/Kernel/Entity/FacetFacetSourceTest.php +++ b/tests/src/Kernel/Entity/FacetFacetSourceTest.php @@ -31,7 +31,6 @@ class FacetFacetSourceTest extends EntityKernelTestBase { 'search_api_db', 'search_api_test_db', 'search_api_test_example_content', - 'search_api_test_views', 'views', 'rest', 'serialization', @@ -60,7 +59,7 @@ class FacetFacetSourceTest extends EntityKernelTestBase { 'search_api_test_db', ]); - $this->installConfig('search_api_test_views'); + $this->installConfig('facets_search_api_dependency'); } /** diff --git a/tests/src/Kernel/FacetManager/DefaultFacetManagerTest.php b/tests/src/Kernel/FacetManager/DefaultFacetManagerTest.php index a840b44..6574282 100644 --- a/tests/src/Kernel/FacetManager/DefaultFacetManagerTest.php +++ b/tests/src/Kernel/FacetManager/DefaultFacetManagerTest.php @@ -2,16 +2,17 @@ namespace Drupal\Tests\facets\Kernel\FacetManager; -use Drupal\facets\Entity\Facet; -use Drupal\KernelTests\KernelTestBase; +use Drupal\Core\Cache\Cache; +use Drupal\facets\FacetInterface; +use Drupal\KernelTests\Core\Entity\EntityKernelTestBase; /** * Provides the DefaultFacetManager test. * * @group facets - * @coversDefaultClass Drupal\facets\FacetManager\DefaultFacetManager + * @coversDefaultClass \Drupal\facets\FacetManager\DefaultFacetManager */ -class DefaultFacetManagerTest extends KernelTestBase { +class DefaultFacetManagerTest extends EntityKernelTestBase { /** * {@inheritdoc} @@ -19,16 +20,59 @@ class DefaultFacetManagerTest extends KernelTestBase { protected static $modules = [ 'facets', 'search_api', + 'search_api_db', + 'search_api_test_db', + 'facets_processors_collection', + 'facets_search_api_dependency', 'system', 'user', + 'views', + 'rest', + 'serialization', ]; + /** + * Facets entity storage. + * + * @var \Drupal\Core\Config\Entity\ConfigEntityStorage + */ + protected $facetStorage; + + /** + * An instance of the "facets.manager" service. + * + * @var \Drupal\facets\FacetManager\DefaultFacetManager + */ + protected $facetManager; + /** * {@inheritdoc} */ public function setUp(): void { parent::setUp(); + $this->installEntitySchema('facets_facet'); + $this->installEntitySchema('entity_test_mulrev_changed'); + $this->installEntitySchema('search_api_task'); + + $state_service = \Drupal::state(); + $state_service->set('search_api_use_tracking_batch', FALSE); + // @see facets_processors_collection_facets_search_api_query_type_mapping_alter(). + $state_service->set('facets_processors_collection_alter_string_query_handler', TRUE); + + // Set tracking page size so tracking will work properly. + \Drupal::configFactory() + ->getEditable('search_api.settings') + ->set('tracking_page_size', 100) + ->save(); + + $this->installConfig([ + 'search_api_test_db', + 'facets_search_api_dependency', + ]); + + $this->facetStorage = $this->entityTypeManager->getStorage('facets_facet'); + $this->facetManager = $this->container->get('facets.manager'); } /** @@ -38,16 +82,15 @@ class DefaultFacetManagerTest extends KernelTestBase { */ public function testGetEnabledFacets() { /** @var \Drupal\facets\FacetManager\DefaultFacetManager $dfm */ - $dfm = \Drupal::service('facets.manager'); - $returnValue = $dfm->getEnabledFacets(); + $returnValue = $this->facetManager->getEnabledFacets(); $this->assertEmpty($returnValue); // Create a facet. - $entity = $this->createAndSaveFacet('test_facet'); + $entity = $this->createAndSaveFacet('Mercury', 'planets'); - $returnValue = $dfm->getEnabledFacets(); + $returnValue = $this->facetManager->getEnabledFacets(); $this->assertNotEmpty($returnValue); - $this->assertSame($entity->id(), $returnValue['test_facet']->id()); + $this->assertSame($entity->id(), $returnValue['Mercury']->id()); } /** @@ -56,73 +99,213 @@ class DefaultFacetManagerTest extends KernelTestBase { * @covers ::getFacetsByFacetSourceId */ public function testGetFacetsByFacetSourceId() { - /** @var \Drupal\facets\FacetManager\DefaultFacetManager $dfm */ - $dfm = \Drupal::service('facets.manager'); - $this->assertEmpty($dfm->getFacetsByFacetSourceId('planets')); + $this->assertEmpty($this->facetManager->getFacetsByFacetSourceId('planets')); // Create 2 different facets with a unique facet source id. - $entity = $this->createAndSaveFacet('Jupiter'); - $entity->setFacetSourceId('planets'); - $entity->save(); - $entity = $this->createAndSaveFacet('Pluto'); - $entity->setFacetSourceId('former_planets'); - $entity->save(); + $this->createAndSaveFacet('Jupiter', 'planets'); + $this->createAndSaveFacet('Pluto', 'former_planets'); - $planetFacets = $dfm->getFacetsByFacetSourceId('planets'); + $planetFacets = $this->facetManager->getFacetsByFacetSourceId('planets'); $this->assertNotEmpty($planetFacets); $this->assertCount(1, $planetFacets); $this->assertSame('Jupiter', $planetFacets['Jupiter']->id()); - $formerPlanetFacets = $dfm->getFacetsByFacetSourceId('former_planets'); + $formerPlanetFacets = $this->facetManager->getFacetsByFacetSourceId('former_planets'); $this->assertNotEmpty($formerPlanetFacets); $this->assertCount(1, $formerPlanetFacets); $this->assertSame('Pluto', $formerPlanetFacets['Pluto']->id()); // Make pluto a planet again. + $entity = $this->facetStorage->load('Pluto'); $entity->setFacetSourceId('planets'); - $entity->save(); + $this->facetStorage->save($entity); // Test that we now hit the static cache. - $planetFacets = $dfm->getFacetsByFacetSourceId('planets'); + $planetFacets = $this->facetManager->getFacetsByFacetSourceId('planets'); $this->assertNotEmpty($planetFacets); $this->assertCount(1, $planetFacets); // Change the 'facets' property on the manager to public, so we can // overwrite it here. This is because otherwise we run into the static // caches. - $facetsProperty = new \ReflectionProperty($dfm, 'facets'); - $facetsProperty->setAccessible(TRUE); - $facetsProperty->setValue($dfm, []); + $this->resetFacetsManagerStaticCache(); // Now that the static cache is reset, test that we have 2 planets. - $planetFacets = $dfm->getFacetsByFacetSourceId('planets'); + $planetFacets = $this->facetManager->getFacetsByFacetSourceId('planets'); $this->assertNotEmpty($planetFacets); $this->assertCount(2, $planetFacets); $this->assertSame('Jupiter', $planetFacets['Jupiter']->id()); $this->assertSame('Pluto', $planetFacets['Pluto']->id()); } + /** + * Tests the cachebillity data passed into search query. + */ + public function testAlterQueryCacheabilityMetadata() { + $view = $this->entityTypeManager + ->getStorage('view') + ->load('search_api_test_view') + ->getExecutable(); + $view->setDisplay('page_1'); + + $query = $view->getQuery()->getSearchApiQuery(); + + // Create facets for a SAPI view display. + $facet_source = 'search_api:views_page__search_api_test_view__page_1'; + $expected_tags = array_merge( + $query->getCacheTags(), + $this->createAndSaveFacet('Mars', $facet_source)->getCacheTags(), + $this->createAndSaveFacet('Neptune', $facet_source)->getCacheTags(), + ['fpc:query_plugin_type_plugin'] + ); + $this->resetFacetsManagerStaticCache(); + $expected_contexts = array_merge( + $query->getCacheContexts(), + ['url.path', 'url.query_args', 'fpc_query_type_plugin'] + ); + + // Make sure that query cachebillity will include facets cache tags e.g. + // view results will depends on the facet configuration. + $this->facetManager->alterQuery($query, $facet_source); + $this->assertCacheabillityArrays($expected_contexts, $query->getCacheContexts()); + $this->assertCacheabillityArrays($expected_tags, $query->getCacheTags()); + } + + /** + * Tests the cachebillity data passed into search query. + */ + public function testBuildCacheabilityMetadata() { + $expected_metadata = [ + 'contexts' => [ + // Facet API uses Request query params to populate active facets values. + 'url.path', + 'url.query_args', + // Added by build fpc_post_query_processor process plugin. + 'fpc_post_query', + // Added by build fpc_build_processor process plugin. + 'fpc_build', + // Added by build fpc_sort_processor process plugin. + 'fpc_sort', + ], + 'tags' => [ + // Facet controls query and look & feel of the facet results, so it's + // config should be present as a cache dependency. + 'config:facets.facet.mars', + // Added by build fpc_post_query_processor process plugin. + 'fpc:added_within_postQuery_method', + 'fpc:post_query_processor', + // Added by build fpc_build_processor process plugin. + 'fpc:added_within_build_method', + 'fpc:build_processor', + // Added by build fpc_sort_processor process plugin. + 'fpc:sort_processor', + ], + 'max-age' => Cache::PERMANENT, + ]; + + $facet = $this->createAndSaveFacet( + 'mars', + 'search_api:views_page__search_api_test_view__page_1' + ); + + $cacheable_processors = [ + 'fpc_post_query_processor', + 'fpc_build_processor', + 'fpc_sort_processor', + ]; + + foreach ($cacheable_processors as $processor) { + $facet->addProcessor([ + 'processor_id' => $processor, + 'weights' => [], + 'settings' => [], + ]); + } + + $facet->setOnlyVisibleWhenFacetSourceIsVisible(FALSE); + $this->facetStorage->save($facet); + // Make sure that new processor is taken into consideration while facet + // building process. + $this->resetFacetsManagerStaticCache(); + $build = $this->facetManager->build($facet); + $this->assertCacheabillityArrays($expected_metadata, $build[0][0]['#cache']); + + $facet->removeProcessor('fpc_sort_processor'); + // Test that un-cacheable plugin kills the cache. + $facet->addProcessor([ + 'processor_id' => 'fpc_sort_random_processor', + 'settings' => [], + 'weights' => [], + ]); + + $this->facetStorage->save($facet); + $this->resetFacetsManagerStaticCache(); + $build = $this->facetManager->build($facet); + + $expected_metadata['max-age'] = 0; + $this->assertCacheabillityArrays($expected_metadata, $build[0][0]['#cache']); + } + /** * Create and save a facet, for usage in test-scenario's. * * @param string $id * The id. + * @param string $source + * The source id. * * @return \Drupal\facets\FacetInterface * The newly created facet. */ - protected function createAndSaveFacet($id) { - // Create a facet. - $entity = Facet::create([ + protected function createAndSaveFacet(string $id, string $source): FacetInterface { + $facet = $this->facetStorage->create([ 'id' => $id, 'name' => 'Test facet', ]); - $entity->setWidget('links'); - $entity->setEmptyBehavior(['behavior' => 'none']); - $entity->setFacetSourceId('fluffy'); - $entity->save(); + $facet->setWidget('links'); + $facet->setFieldIdentifier('type'); + $facet->setEmptyBehavior(['behavior' => 'none']); + $facet->setFacetSourceId($source); - return $entity; + $facet->addProcessor([ + 'processor_id' => 'url_processor_handler', + 'settings' => [], + 'weights' => [], + ]); + $this->facetStorage->save($facet); + + return $facet; + } + + /** + * Reset static facets.manager static cache. + * + * @todo discuss whether or not this should be done automatically when facet + * gets inserted/updated or deleted. + */ + protected function resetFacetsManagerStaticCache() { + foreach (['builtFacets', 'facets', 'processedFacets'] as $prop) { + $facetsProperty = new \ReflectionProperty($this->facetManager, $prop); + $facetsProperty->setAccessible(TRUE); + $facetsProperty->setValue($this->facetManager, []); + $facetsProperty->setAccessible(FALSE); + } + } + + /** + * Assert that actual cachebillity matches expected one. + */ + public function assertCacheabillityArrays($expected, $actual, string $message = ''): void { + foreach ([&$expected, &$actual] as &$array) { + foreach ($array as &$value) { + if (is_array($value)) { + sort($value); + } + } + } + sort($expected); + sort($actual); + $this->assertEquals($expected, $actual, $message); } }