toolshed-8.x-1.x-dev/modules/toolshed_search/src/Plugin/Block/ToolshedSearchBlock.php
modules/toolshed_search/src/Plugin/Block/ToolshedSearchBlock.php
<?php
namespace Drupal\toolshed_search\Plugin\Block;
use Drupal\Component\Plugin\Exception\PluginNotFoundException;
use Drupal\Component\Utility\NestedArray;
use Drupal\Component\Utility\SortArray;
use Drupal\Component\Utility\UrlHelper;
use Drupal\Core\Access\AccessResult;
use Drupal\Core\Access\AccessResultInterface;
use Drupal\Core\Block\BlockBase;
use Drupal\Core\Cache\Cache;
use Drupal\Core\Entity\EntityTypeManagerInterface;
use Drupal\Core\Form\FormBuilderInterface;
use Drupal\Core\Form\FormState;
use Drupal\Core\Form\FormStateInterface;
use Drupal\Core\Form\SubformState;
use Drupal\Core\Plugin\ContainerFactoryPluginInterface;
use Drupal\Core\Session\AccountInterface;
use Drupal\toolshed_search\Form\ToolshedSearchFilterForm;
use Drupal\toolshed_search\Plugin\ToolshedSearchAutocompleteInterface;
use Drupal\toolshed_search\Plugin\ToolshedSearchAutocompletePluginManager;
use Drupal\views\ViewExecutable;
use Drupal\views\ViewExecutableFactory;
use Symfony\Component\DependencyInjection\ContainerInterface;
use Symfony\Component\HttpFoundation\Response;
/**
* Block for creating configurable exposed search blocks.
*
* @Block(
* id = "toolshed_search_form",
* admin_label = @Translation("Toolshed: Search block"),
* category = @Translation("Search API"),
* )
*/
class ToolshedSearchBlock extends BlockBase implements ContainerFactoryPluginInterface {
/**
* The loaded view or FALSE if the view is no longer available.
*
* @var \Drupal\views\ViewExecutable|null|false
*/
protected ViewExecutable|null|false $loadedView;
/**
* An array of configured views filters to exposed in the block form.
*
* @var array
*/
protected ?array $filters;
/**
* Drupal form builder interface for generating form structures.
*
* @var \Drupal\Core\Form\FormBuilderInterface
*/
protected FormBuilderInterface $formBuilder;
/**
* The entity type manager.
*
* @var \Drupal\Core\Entity\EntityTypeManagerInterface
*/
protected EntityTypeManagerInterface $entityTypeManager;
/**
* Creates executatable views from views entity config instances.
*
* @var \Drupal\views\ViewExecutableFactory
*/
protected ViewExecutableFactory $viewFactory;
/**
* The search autocomplete plugin manager.
*
* @var \Drupal\toolshed_search\Plugin\ToolshedSearchAutocompletePluginManager
*/
protected ToolshedSearchAutocompletePluginManager $autocompleteManager;
/**
* Create a new instance of the toolshed search block.
*
* @param array $configuration
* The plugin configurations for the block setup.
* @param string $plugin_id
* The unique ID of the plugin.
* @param mixed $plugin_definition
* The plugin definition from the plugin discovery.
* @param \Drupal\Core\Form\FormBuilderInterface $form_builder
* The Drupal form builder service.
* @param \Drupal\Core\Entity\EntityTypeManagerInterface $entity_type_manager
* The entity type manager.
* @param \Drupal\views\ViewExecutableFactory $view_executable_factory
* Creates executatable views from views entity config instances.
* @param \Drupal\toolshed_search\Plugin\ToolshedSearchAutocompletePluginManager $autocomplete_manager
* The search autocomplete plugin manager.
*/
public function __construct(array $configuration, $plugin_id, $plugin_definition, FormBuilderInterface $form_builder, EntityTypeManagerInterface $entity_type_manager, ViewExecutableFactory $view_executable_factory, ToolshedSearchAutocompletePluginManager $autocomplete_manager) {
parent::__construct($configuration, $plugin_id, $plugin_definition);
$this->formBuilder = $form_builder;
$this->entityTypeManager = $entity_type_manager;
$this->viewFactory = $view_executable_factory;
$this->autocompleteManager = $autocomplete_manager;
}
/**
* {@inheritdoc}
*/
public static function create(ContainerInterface $container, array $configuration, $plugin_id, $plugin_definition): static {
return new static(
$configuration,
$plugin_id,
$plugin_definition,
$container->get('form_builder'),
$container->get('entity_type.manager'),
$container->get('views.executable'),
$container->get('plugin.manager.toolshed_search_autocomplete')
);
}
/**
* Retrieve the view targeted by this block.
*
* @return \Drupal\views\ViewExecutable|false
* The view entity loaded, with the proper display configured. Will return
* NULL if the view or the display are no longer available.
*/
public function getView(): ViewExecutable|false {
if (!isset($this->loadedView)) {
$this->loadedView = $this->loadView($this->configuration['view']) ?: FALSE;
}
return $this->loadedView;
}
/**
* {@inheritdoc}
*/
public function blockAccess(AccountInterface $account): AccessResultInterface {
if ($view = $this->getView()) {
$access = $view->access($view->current_display, $account);
return AccessResult::allowedIf($access);
}
return AccessResult::forbidden();
}
/**
* {@inheritdoc}
*/
public function getCacheTags(): array {
$tags = parent::getCacheTags();
if ($view = $this->getView()) {
$viewTags = $view->display_handler
->getCacheMetadata()
->getCacheTags();
$tags = Cache::mergeTags($viewTags, $tags);
}
return $tags;
}
/**
* {@inheritdoc}
*/
public function getCacheContexts(): array {
$contexts = parent::getCacheContexts();
if ($view = $this->getView()) {
$viewContexts = $view->display_handler
->getCacheMetadata()
->getCacheContexts();
$contexts = Cache::mergeContexts($viewContexts, $contexts);
// Add cache contexts from each of the configured views filters.
foreach ($this->getFilters() as $data) {
/** @var \Drupal\views\Plugin\views\filter\FilterPluginBase $filter */
$filter = $data['filter'];
$contexts = Cache::mergeContexts($filter->getCacheContexts(), $contexts);
}
}
return $contexts;
}
/**
* {@inheritdoc}
*/
public function defaultConfiguration(): array {
return [
'view' => ':',
'autocomplete' => '',
'filters' => [],
'use_exposed_input' => TRUE,
'grouped_filter' => FALSE,
'action' => 'view_url',
'submit_label' => $this->t('Search'),
'submit_class_names' => [],
'reset_link' => FALSE,
'reset_link_text' => $this->t('Reset filters'),
'variant' => NULL,
] + parent::defaultConfiguration();
}
/**
* {@inheritdoc}
*/
public function build(): array {
$build = [];
if (!empty($this->configuration['filters']) && $view = $this->getView()) {
$state = new FormState();
$state
->setMethod('GET')
->setRequestMethod('GET')
->disableCache()
->disableRedirect()
->setAlwaysProcess()
->addBuildInfo('args', [$this])
->setStorage([
'view' => $view,
'display' => &$view->display_handler->display,
'rerender' => TRUE,
]);
$intro = $this->configuration['intro_content'] ?? [];
if (!empty($intro['value'])) {
$build['intro_content'] = [
'#theme_wrappers' => ['container'],
'#type' => 'processed_text',
'#text' => $intro['value'],
'#format' => $intro['format'] ?? 'plain_text',
'#attributes' => [
'class' => ['search-block__intro'],
],
];
}
$build['form'] = $this->formBuilder->buildForm(ToolshedSearchFilterForm::class, $state);
}
return $build;
}
/**
* {@inheritdoc}
*/
public function blockForm($form, FormStateInterface $form_state): array {
$config = $this->getConfiguration();
$parentState = $form_state instanceof SubformState ? $form_state->getCompleteFormState() : $form_state;
$viewId = $parentState->getValue(['settings', 'view']);
$viewId = !isset($viewId) ? $config['view'] : $viewId;
$viewOptions = $this->getViewOptions();
$form['view'] = [
'#type' => 'select',
'#title' => $this->t('View to run search'),
'#required' => TRUE,
'#options' => $viewOptions,
'#default_value' => $viewId,
'#description' => $this->t('Only search_api views with a page display appear in this list.'),
'#ajax' => [
'wrapper' => 'views-display-ajax-wrapper',
'callback' => static::class . '::ajaxUpdateViewDisplays',
],
];
$form['filters'] = [
'#prefix' => '<div id="views-display-ajax-wrapper">',
'#suffix' => '</div>',
];
if (isset($viewOptions[$viewId]) && ($view = $this->loadView($viewId))) {
$form['filters'] = $this->buildFilterFormElements($view);
$form['autocomplete'] = $this->buildAutocompleteElements($view);
}
$form['intro_content'] = [
'#type' => 'text_format',
'#title' => $this->t('Intro content'),
'#rows' => 3,
'#format' => $config['intro_content']['format'] ?? NULL,
'#default_value' => $config['intro_content']['value'] ?? NULL,
];
$form['use_exposed_input'] = [
'#type' => 'checkbox',
'#title' => $this->t('Apply exposed input values'),
'#default_value' => $config['use_exposed_input'] ?? FALSE,
'#description' => $this->t('Checking this applies the current exposed input values from the request to the form elements. Otherwise elements will always be populate by the View defaults.'),
];
$form['grouped_filter'] = [
'#type' => 'checkbox',
'#title' => $this->t('Filter combine with other filter blocks'),
'#default_value' => $config['grouped_filter'] ?? FALSE,
'#description' => $this->t('Filters in this block will combine with other filters that target the same view.'),
];
$form['submit_label'] = [
'#type' => 'textfield',
'#title' => $this->t('Submit button text'),
'#default_value' => $config['submit_label'],
];
$form['submit_class_names'] = [
'#type' => 'css_class',
'#title' => $this->t('Button class names'),
'#default_value' => $config['submit_class_names'],
'#description' => $this->t('Additional CSS class names to apply to the submit button.'),
];
$form['reset_link'] = [
'#type' => 'checkbox',
'#title' => $this->t('Include reset filters'),
'#default_value' => $config['reset_link'],
];
$form['reset_link_text'] = [
'#type' => 'textfield',
'#title' => $this->t('Reset link text'),
'#default_value' => $config['reset_link_text'] ?? $this->t('Reset filters'),
'#states' => [
'visible' => [
'input[name="settings[reset_link]"]' => ['checked' => TRUE],
],
],
];
$form['variant'] = [
'#type' => 'textfield',
'#title' => $this->t("Block variant"),
'#pattern' => '^[\w_ ]*$',
'#default_value' => $this->configuration['variant'],
'#description' => $this->t('Optional extra label to provide info to the ARIA descriptions to provide context if the search block appears more than once on a page. (i.e. "footer" or "header")'),
];
$form['action'] = [
'#type' => 'select',
'#title' => $this->t('Form submit target'),
'#options' => [
'view_url' => $this->t('The view display page'),
'current_page' => $this->t('Submit to same page'),
'custom_url' => $this->t('Custom URL'),
],
'#default_value' => $config['action'],
'#description' => $this->t('Custom URL is useful for page manager, or pages that embed the search view.'),
];
$form['custom_url'] = [
'#type' => 'textfield',
'#title' => $this->t('Custom URL to submit form to'),
'#default_value' => $config['custom_url'] ?? '',
'#states' => [
'visible' => [
'select[name="settings[action]"]' => ['value' => 'custom_url'],
],
'required' => [
'select[name="settings[action]"]' => ['value' => 'custom_url'],
],
],
];
return $form;
}
/**
* {@inheritdoc}
*/
public function blockValidate($form, FormStateInterface $form_state): void {
$action = $form_state->getValue('action');
if ('custom_url' === $action && !UrlHelper::isValid($form_state->getValue('custom_url'))) {
$form_state->setError($form['custom_url'], $this->t('Custom URL path is not a valid.'));
}
}
/**
* {@inheritdoc}
*/
public function blockSubmit($form, FormStateInterface $form_state): void {
$values = $form_state->getValues() + [
'filters' => [],
];
$this->configuration['view'] = $values['view'] ?? ':';
$this->configuration['intro_content'] = $values['intro_content'];
$this->configuration['autocomplete'] = $values['autocomplete'];
$this->configuration['use_exposed_input'] = $values['use_exposed_input'];
$this->configuration['grouped_filter'] = $values['grouped_filter'];
$this->configuration['submit_label'] = trim($values['submit_label']);
$this->configuration['submit_class_names'] = $values['submit_class_names'];
$this->configuration['reset_link'] = $values['reset_link'];
$this->configuration['reset_link_text'] = trim($values['reset_link_text']);
$this->configuration['variant'] = trim($values['variant']);
// Resort the filters by the weight.
uasort($values['filters'], SortArray::sortByWeightElement(...));
$this->configuration['filters'] = [];
foreach ($values['filters'] as $name => $filterData) {
if ($filterData['enabled']) {
$this->configuration['filters'][$name] = [
'label' => $filterData['label'],
'label_display' => $filterData['label_display'],
];
}
}
$this->configuration['action'] = $values['action'];
if ('custom_url' === $values['action']) {
$this->configuration['custom_url'] = $values['custom_url'];
}
}
/**
* Update the block configuration form after the view has been selected.
*
* @param array $form
* Structure of the form.
* @param \Drupal\Core\Form\FormStateInterface $form_state
* The current state of the form and build information.
*
* @return array|\Symfony\Component\HttpFoundation\Response
* Renderable array of content to be replaced using AJAX.
*/
public static function ajaxUpdateViewDisplays(array $form, FormStateInterface $form_state): array|Response {
$trigger = $form_state->getTriggeringElement();
$parents = array_slice($trigger['#array_parents'], 0, -1);
$parents[] = 'filters';
return NestedArray::getValue($form, $parents);
}
/**
* Get the available views which can be selected to create filter blocks for.
*
* @return array
* Array of views that can be the target of this search filters form.
*/
protected function getViewOptions(): array {
/** @var \Drupal\views\ViewEntityInterface[] $views */
$views = $this->entityTypeManager
->getStorage('view')
->loadByProperties(['status' => TRUE]);
$options = ['' => '-- Select a View --'];
foreach ($views as $viewId => $view) {
if ($view->get('base_field') !== 'search_api_id' && !preg_match('/^search_api_index/', $view->get('base_table'))) {
continue;
}
// Only create a blocks that have search API page displays to redirect to.
foreach ($view->get('display') as $displayId => $display) {
if (in_array($display['display_plugin'], ['page', 'block'])) {
$options["$viewId:$displayId"] = $view->label() . " ($display[display_title])";
}
}
}
return $options;
}
/**
* Retrieve a view and display settings based a view and display ID.
*
* @return \Drupal\views\ViewExecutable|null
* The view entity loaded, with the proper display configured. Will return
* NULL if the view or the display are no longer available.
*/
protected function loadView($viewName): ?ViewExecutable {
[$viewId, $display] = explode(':', $viewName);
if ($viewId && $display) {
$entity = $this->entityTypeManager
->getStorage('view')
->load($viewId);
if ($entity) {
$view = $this->viewFactory->get($entity);
return $view->setDisplay($display) ? $view : NULL;
}
}
return NULL;
}
/**
* Create the form elements for configuring the views filters.
*
* @param \Drupal\views\ViewExecutable $view
* The view to get exposed filter information from.
*
* @return array
* Form element definitions for configuring the views filters.
*/
protected function buildFilterFormElements(ViewExecutable $view): array {
$elements = [];
$filterOpts = [];
/** @var \Drupal\views\Plugin\views\filter\FilterPluginBase $handler */
foreach ($view->display_handler->getHandlers('filter') as $fieldName => $handler) {
if ($handler->isExposed()) {
$filterOpts[$fieldName] = [
'title' => $handler->pluginTitle(),
'label' => $handler->exposedInfo()['label'] ?? $handler->pluginTitle(),
];
}
}
$elements = [
'#type' => 'table',
'#caption' => $this->t('Exposed filters'),
'#required' => TRUE,
'#empty' => $this->t('No filters are exposed for this View, go enable some exposed filters to have them appear here.'),
'#header' => [
'filter' => $this->t('Filter'),
'label' => $this->t('Label'),
'display' => $this->t('Label display'),
'weight' => $this->t('Sort order'),
],
'#tabledrag' => [
[
'action' => 'order',
'relationship' => 'sibling',
'group' => 'filter-weight',
],
],
];
$filters = $this->configuration['filters'] + $filterOpts;
foreach (array_intersect_key($filters, $filterOpts) as $name => $option) {
$elements[$name] = [
'#attributes' => [
'class' => ['draggable'],
],
'enabled' => [
'#type' => 'checkbox',
'#title' => $filterOpts[$name]['title'],
'#default_value' => !empty($this->configuration['filters'][$name]),
],
'label' => [
'#type' => 'textfield',
'#placeholder' => $filterOpts[$name]['label'],
'#default_value' => $this->configuration['filters'][$name]['label'] ?? '',
],
'label_display' => [
'#type' => 'select',
'#options' => [
'before' => $this->t('Before'),
'after' => $this->t('After'),
'invisible' => $this->t('Visually hidden'),
],
'#default_value' => $this->configuration['filters'][$name]['label_display'] ?? 'before',
],
'weight' => [
'#type' => 'number',
'#attributes' => [
'class' => ['filter-weight'],
],
],
];
}
return $elements;
}
/**
* Create the form elements for configuring the autocomplete behavior.
*
* @param \Drupal\views\ViewExecutable $view
* The view to get autocomplete compatible functionality for.
*
* @return array
* Form elements for configuring the autocomplete behavior for this block.
*/
protected function buildAutocompleteElements(ViewExecutable $view): array {
$acOptions = [];
foreach ($this->autocompleteManager->getDefinitions() as $pluginId => $value) {
$autocomplete = $this->autocompleteManager->createInstance($pluginId, []);
if ($autocomplete->isApplicable($view)) {
$acOptions[$pluginId] = $value['label'];
}
}
if ($acOptions) {
return [
'#type' => 'fieldset',
'#title' => $this->t('Fulltext Autocomplete'),
'#tree' => TRUE,
'plugin' => [
'#type' => 'select',
'#title' => $this->t('Provider'),
'#options' => ['' => $this->t('- None -')] + $acOptions,
'#default_value' => $this->configuration['autocomplete']['plugin'] ?? '',
'#description' => $this->t('Applies the search autocomplete to all the search_api fulltext filters.'),
],
'config' => [
'#type' => 'value',
'#value' => [],
],
];
}
return [
'#type' => 'value',
'#value' => [
'plugin' => '',
'config' => [],
],
];
}
/**
* Get the views exposed filter plugins configured to be rendred.
*
* @param bool $reset
* If true the method will always generate the list of exposed filters.
*
* @return array
* List of plugin filters which are exposed for the view and configured to
* be built by the block configurations.
*/
public function getFilters($reset = FALSE): array {
if (!isset($this->filters) || $reset) {
$this->filters = [];
if ($view = $this->getView()) {
$config = $this->getConfiguration();
$filters = $view->display_handler->getHandlers('filter');
foreach ($config['filters'] as $name => $info) {
/** @var \Drupal\views\Plugin\views\filter\FilterPluginBase|null $filter */
$filter = $filters[$name] ?? NULL;
if ($filter?->canExpose() && $filter->isExposed()) {
$this->filters[$name] = [
'label' => $info['label'] ?? NULL,
'label_display' => $info['label_display'] ?? 'before',
'filter' => $filter,
];
}
}
}
}
return $this->filters;
}
/**
* Gets a loaded instance of the search autocomplete plugin for this block.
*
* @return \Drupal\toolshed_search\Plugin\ToolshedSearchAutocompleteInterface|null
* The search autocomplete plugin if one was configured for this block.
*/
public function getAutocomplete(): ?ToolshedSearchAutocompleteInterface {
try {
if (!empty($this->configuration['autocomplete']['plugin'])) {
['plugin' => $pluginId, 'config' => $config] = $this->configuration['autocomplete'] + [
'config' => [],
];
return $this->autocompleteManager->createInstance($pluginId, $config);
}
}
catch (PluginNotFoundException) {
// Plugin missing, just treat this as no autocomplete available.
}
return NULL;
}
}
