diff --git a/README.md b/README.md index 70868a5..19b10ad 100644 --- a/README.md +++ b/README.md @@ -1,9 +1,54 @@ -## Solr Facet Field Formatter +# Solr Facet Field Formatter -## Dependency +Formats a field as links into a [Search API](https://www.drupal.org/project/search_api) +search page, pre-filtered by a [Facets](https://www.drupal.org/project/facets) +facet whose value is set to the field value. -facets -search_api_solr +For example, a "Genus" field rendered with this formatter becomes a link to your +search page with the *Genus* facet already filtered to that value. +## Supported field types +The formatter is available for every field type. The value used in the facet +query is resolved per field type: +- **Reference fields** (`entity_reference`, `typed_relation`, …): the referenced + entity's **label** is used, so the facet is filtered by the readable value + (e.g. the taxonomy term name) rather than the internal entity id. +- **All other fields** (text, string, list, numeric, boolean, datetime, …): the + field's main property value is used. + +This matches how such fields are normally indexed and faceted in Solr — make +sure the facet you select indexes the same readable value. + +## Dependencies + +- `search_api_solr` +- `facets` + +## Configuration + +The formatter is configured per field, under **Manage display** for the entity +(e.g. `/admin/structure/types/manage//display`). Choose the +*Solr Facet Field Formatter* format for a text/string field and set: + +- **Search page (facet source):** the search results page the link points to. + This determines both the URL path and the facet filter key, read from the + selected facet source. +- **Facet:** the facet to filter on. Pick the facet configured for this field + on the chosen search page. Its URL alias is used as the facet key in the + generated query. +- **Reset fulltext search** (optional): adds an empty fulltext query parameter + so following the link starts a fresh, facet-only search. The parameter name + (default `search_api_fulltext`) is configurable to match your view's exposed + fulltext filter. + +The generated link matches the query string that Facets itself produces, e.g. +`/?f[0]=:`. + +## Upgrading + +Earlier versions hardcoded a specific view (`herbarium_search`). After +upgrading, each field using this formatter must be reconfigured on its +**Manage display** page to select a facet source and facet. Until configured, +the field renders as plain text. diff --git a/config/schema/solr_facet_field_formatter.schema.yml b/config/schema/solr_facet_field_formatter.schema.yml new file mode 100644 index 0000000..e22323f --- /dev/null +++ b/config/schema/solr_facet_field_formatter.schema.yml @@ -0,0 +1,16 @@ +field.formatter.settings.solr_facet_text_field_formatter: + type: mapping + label: 'Solr Facet Field Formatter settings' + mapping: + facet_source: + type: string + label: 'Facet source (search page)' + facet_id: + type: string + label: 'Facet' + reset_fulltext: + type: boolean + label: 'Reset fulltext search' + fulltext_key: + type: string + label: 'Fulltext parameter' diff --git a/solr_facet_field_formatter.info.yml b/solr_facet_field_formatter.info.yml index 2900afc..fc06cad 100644 --- a/solr_facet_field_formatter.info.yml +++ b/solr_facet_field_formatter.info.yml @@ -1,6 +1,6 @@ name: 'Solr Facet Field Formatter' type: module -description: 'Format text and string fields to link to solr search api facet searches' +description: 'Format any field to link to a Search API / Facets facet search, using the field value (or referenced entity label).' core_version_requirement: ^9.5 || ^10 package: 'Search' dependencies: diff --git a/solr_facet_field_formatter.module b/solr_facet_field_formatter.module index c840811..a7300ef 100644 --- a/solr_facet_field_formatter.module +++ b/solr_facet_field_formatter.module @@ -12,8 +12,8 @@ use Drupal\Core\Routing\RouteMatchInterface; */ function solr_facet_field_formatter_help($route_name, RouteMatchInterface $route_match) { switch ($route_name) { - // Main module help for the facet_field_formatter module. - case 'help.page.facet_field_formatter': + // Main module help for the solr_facet_field_formatter module. + case 'help.page.solr_facet_field_formatter': $output = ''; $output .= '

' . t('About') . '

'; $output .= '

' . t('Format string and text fields to link to Solr facet searches') . '

'; @@ -22,3 +22,19 @@ function solr_facet_field_formatter_help($route_name, RouteMatchInterface $route default: } } + +/** + * Implements hook_field_formatter_info_alter(). + * + * Makes the Solr facet formatter available for every field type. The value + * sent to the facet search is resolved per field type inside the formatter + * (the referenced entity's label for reference fields such as entity_reference + * and typed_relation, the field's main property value otherwise), so the facet + * is filtered by the human readable value rather than an internal id. + */ +function solr_facet_field_formatter_field_formatter_info_alter(array &$info) { + if (isset($info['solr_facet_text_field_formatter'])) { + $field_types = array_keys(\Drupal::service('plugin.manager.field.field_type')->getDefinitions()); + $info['solr_facet_text_field_formatter']['field_types'] = $field_types; + } +} diff --git a/src/Plugin/Field/FieldFormatter/SolrFacetTextFieldFormatter.php b/src/Plugin/Field/FieldFormatter/SolrFacetTextFieldFormatter.php index 71c4a04..8f5afe7 100644 --- a/src/Plugin/Field/FieldFormatter/SolrFacetTextFieldFormatter.php +++ b/src/Plugin/Field/FieldFormatter/SolrFacetTextFieldFormatter.php @@ -2,15 +2,27 @@ namespace Drupal\solr_facet_field_formatter\Plugin\Field\FieldFormatter; -use Drupal\Core\Field\FormatterBase; +use Drupal\Core\Entity\EntityInterface; +use Drupal\Core\Entity\EntityTypeManagerInterface; +use Drupal\Core\Field\FieldItemInterface; use Drupal\Core\Field\FieldItemListInterface; -use Drupal\Core\Url; +use Drupal\Core\Field\FormatterBase; +use Drupal\Core\Field\Plugin\Field\FieldType\EntityReferenceItem; +use Drupal\Core\Form\FormStateInterface; use Drupal\Core\Link; -use Drupal\search_api_solr\Utility; +use Drupal\Core\Plugin\ContainerFactoryPluginInterface; +use Drupal\Core\Url; +use Drupal\facets\FacetSource\FacetSourcePluginManager; +use Symfony\Component\DependencyInjection\ContainerInterface; /** * Plugin implementation of the 'Facet_Field_Formatter' formatter. * + * Renders a text or string field value as a link into a Search API / Facets + * search page, pre-filtered by a configured facet. The search page, filter key + * and facet are all configured per field instance so the formatter is not tied + * to any particular site structure. + * * @FieldFormatter( * id = "solr_facet_text_field_formatter", * module = "solr_facet_field_formatter", @@ -24,14 +36,133 @@ use Drupal\search_api_solr\Utility; * } * ) */ -class SolrFacetTextFieldFormatter extends FormatterBase { +class SolrFacetTextFieldFormatter extends FormatterBase implements ContainerFactoryPluginInterface { + + /** + * The entity type manager. + * + * @var \Drupal\Core\Entity\EntityTypeManagerInterface + */ + protected $entityTypeManager; + + /** + * The facet source plugin manager. + * + * @var \Drupal\facets\FacetSource\FacetSourcePluginManager + */ + protected $facetSourceManager; + + /** + * {@inheritdoc} + */ + public function __construct($plugin_id, $plugin_definition, $field_definition, array $settings, $label, $view_mode, array $third_party_settings, EntityTypeManagerInterface $entity_type_manager, FacetSourcePluginManager $facet_source_manager) { + parent::__construct($plugin_id, $plugin_definition, $field_definition, $settings, $label, $view_mode, $third_party_settings); + $this->entityTypeManager = $entity_type_manager; + $this->facetSourceManager = $facet_source_manager; + } + + /** + * {@inheritdoc} + */ + public static function create(ContainerInterface $container, array $configuration, $plugin_id, $plugin_definition) { + return new static( + $plugin_id, + $plugin_definition, + $configuration['field_definition'], + $configuration['settings'], + $configuration['label'], + $configuration['view_mode'], + $configuration['third_party_settings'], + $container->get('entity_type.manager'), + $container->get('plugin.manager.facets.facet_source') + ); + } + + /** + * {@inheritdoc} + */ + public static function defaultSettings() { + return [ + // The facet source plugin id (e.g. the view display) that renders the + // search results page the link points to. + 'facet_source' => '', + // The machine name of the facet to filter on. + 'facet_id' => '', + // Whether to reset an exposed fulltext filter on the target page so the + // link produces a clean, facet-only search. + 'reset_fulltext' => TRUE, + // The query parameter used by the exposed fulltext filter. + 'fulltext_key' => 'search_api_fulltext', + ] + parent::defaultSettings(); + } + + /** + * {@inheritdoc} + */ + public function settingsForm(array $form, FormStateInterface $form_state) { + $form = parent::settingsForm($form, $form_state); + + $form['facet_source'] = [ + '#type' => 'select', + '#title' => $this->t('Search page (facet source)'), + '#description' => $this->t('The search results page the link points to. This determines both the URL path and the facet filter key.'), + '#options' => $this->getFacetSourceOptions(), + '#default_value' => $this->getSetting('facet_source'), + '#empty_option' => $this->t('- Select a facet source -'), + '#required' => TRUE, + ]; + + $form['facet_id'] = [ + '#type' => 'select', + '#title' => $this->t('Facet'), + '#description' => $this->t('The facet whose value should be set to this field value. Choose the facet configured for this field on the selected search page.'), + '#options' => $this->getFacetOptions(), + '#default_value' => $this->getSetting('facet_id'), + '#empty_option' => $this->t('- Select a facet -'), + '#required' => TRUE, + ]; + + $form['reset_fulltext'] = [ + '#type' => 'checkbox', + '#title' => $this->t('Reset fulltext search'), + '#description' => $this->t('Add an empty fulltext query parameter so following the link starts a fresh, facet-only search.'), + '#default_value' => $this->getSetting('reset_fulltext'), + ]; + + $form['fulltext_key'] = [ + '#type' => 'textfield', + '#title' => $this->t('Fulltext parameter'), + '#description' => $this->t('The query parameter used by the exposed fulltext filter on the search page.'), + '#default_value' => $this->getSetting('fulltext_key'), + '#states' => [ + 'visible' => [ + ':input[name$="[settings_edit_form][settings][reset_fulltext]"]' => ['checked' => TRUE], + ], + ], + ]; + + return $form; + } /** * {@inheritdoc} */ public function settingsSummary() { $summary = []; - $summary[] = $this->t('Format string or text fields as links to a facet search.'); + + $facet_id = $this->getSetting('facet_id'); + $facet_source = $this->getSetting('facet_source'); + + if ($facet_id && $facet_source) { + $summary[] = $this->t('Links to facet %facet on %source.', [ + '%facet' => $this->getFacetLabel($facet_id), + '%source' => $this->getFacetSourceLabel($facet_source), + ]); + } + else { + $summary[] = $this->t('Not configured: select a facet source and facet.'); + } + return $summary; } @@ -41,15 +172,230 @@ class SolrFacetTextFieldFormatter extends FormatterBase { public function viewElements(FieldItemListInterface $items, $langcode) { $element = []; + $facet_source = $this->getSetting('facet_source'); + $facet_id = $this->getSetting('facet_id'); + + $path = $facet_source && $facet_id ? $this->getFacetSourcePath($facet_source) : NULL; + + // Without a configured, resolvable target there is nothing to link to; fall + // back to rendering the plain value. + if ($path === NULL) { + foreach ($items as $delta => $item) { + $element[$delta] = ['#plain_text' => $this->getFacetValue($item)]; + } + return $element; + } + + $filter_key = $this->getFilterKey($facet_source); + $url_alias = $this->getFacetUrlAlias($facet_id); + // Facets uses a colon separator between the alias and the raw value. + $separator = ':'; + foreach ($items as $delta => $item) { - $field = $item->getFieldDefinition(); - // TODO: more robust fieldname parsing - $fieldLabel = str_replace(' ', '_', $field->getLabel()); - $link = Link::fromTextAndUrl($item->value, Url::fromroute('view.herbarium_search.page_1', - [], ['query' => ['search_api_fulltext' => '','herbarium-search[0]' => $fieldLabel . ':' . $item->value]])); + $value = $this->getFacetValue($item); + if ($value === NULL || $value === '') { + continue; + } + + $query = []; + if ($this->getSetting('reset_fulltext')) { + $query[$this->getSetting('fulltext_key')] = ''; + } + // Produces filter_key[0]=alias:value, matching the query string built by + // the Facets module itself. + $query[$filter_key] = [$url_alias . $separator . $value]; + + $url = Url::fromUserInput($path, ['query' => $query]); + $link = Link::fromTextAndUrl($value, $url); $element[$delta] = [$link->toRenderable()]; } return $element; } + + /** + * Extracts the value to filter the facet by from a field item. + * + * For reference fields (entity_reference, typed_relation, …) the referenced + * entity's label is used, so the facet is filtered by the readable value + * rather than the internal entity id. For all other field types the field's + * main property value is used. + * + * @param \Drupal\Core\Field\FieldItemInterface $item + * The field item. + * + * @return string|null + * The facet value, or NULL if none could be determined. + */ + protected function getFacetValue(FieldItemInterface $item) { + if ($item instanceof EntityReferenceItem) { + $entity = $item->entity; + if ($entity instanceof EntityInterface) { + return $entity->label(); + } + } + + $main_property = $item->getFieldDefinition() + ->getFieldStorageDefinition() + ->getMainPropertyName(); + + if ($main_property !== NULL && isset($item->{$main_property})) { + return $item->{$main_property}; + } + + // Last resort: the item's string representation. + $string = $item->getString(); + return $string === '' ? NULL : $string; + } + + /** + * Builds the list of facet source options for the settings form. + * + * @return array + * An array of facet source labels keyed by plugin id. + */ + protected function getFacetSourceOptions() { + $options = []; + foreach ($this->facetSourceManager->getDefinitions() as $id => $definition) { + $options[$id] = !empty($definition['label']) ? $definition['label'] : $id; + } + natcasesort($options); + return $options; + } + + /** + * Builds the list of facet options for the settings form. + * + * @return array + * An array of facet labels keyed by facet machine name. + */ + protected function getFacetOptions() { + $options = []; + /** @var \Drupal\facets\FacetInterface $facet */ + foreach ($this->loadFacets() as $facet) { + $options[$facet->id()] = $this->t('@label (@source)', [ + '@label' => $facet->label(), + '@source' => $facet->getFacetSourceId(), + ]); + } + natcasesort($options); + return $options; + } + + /** + * Loads all configured facet entities. + * + * @return \Drupal\facets\FacetInterface[] + * The facet entities, or an empty array if the facets module is not + * available. + */ + protected function loadFacets() { + if (!$this->entityTypeManager->hasDefinition('facets_facet')) { + return []; + } + return $this->entityTypeManager->getStorage('facets_facet')->loadMultiple(); + } + + /** + * Loads a single facet entity. + * + * @param string $facet_id + * The facet machine name. + * + * @return \Drupal\facets\FacetInterface|null + * The facet entity, or NULL if it does not exist. + */ + protected function loadFacet($facet_id) { + if (!$this->entityTypeManager->hasDefinition('facets_facet')) { + return NULL; + } + return $this->entityTypeManager->getStorage('facets_facet')->load($facet_id); + } + + /** + * Returns the human readable label for a facet. + */ + protected function getFacetLabel($facet_id) { + $facet = $this->loadFacet($facet_id); + return $facet ? $facet->label() : $facet_id; + } + + /** + * Returns the human readable label for a facet source. + */ + protected function getFacetSourceLabel($facet_source) { + $definitions = $this->facetSourceManager->getDefinitions(); + if (isset($definitions[$facet_source]['label']) && !empty($definitions[$facet_source]['label'])) { + return $definitions[$facet_source]['label']; + } + return $facet_source; + } + + /** + * Returns the URL alias used in facet queries for a facet. + * + * @param string $facet_id + * The facet machine name. + * + * @return string + * The facet URL alias, falling back to the facet id. + */ + protected function getFacetUrlAlias($facet_id) { + $facet = $this->loadFacet($facet_id); + return $facet ? $facet->getUrlAlias() : $facet_id; + } + + /** + * Resolves the search page path for a facet source. + * + * @param string $facet_source + * The facet source plugin id. + * + * @return string|null + * The internal path (e.g. "/search"), or NULL if it cannot be resolved. + */ + protected function getFacetSourcePath($facet_source) { + if (!$this->facetSourceManager->hasDefinition($facet_source)) { + return NULL; + } + try { + /** @var \Drupal\facets\FacetSource\FacetSourcePluginInterface $plugin */ + $plugin = $this->facetSourceManager->createInstance($facet_source); + $path = $plugin->getPath(); + if ($path === NULL || $path === '') { + return NULL; + } + // Url::fromUserInput() requires a leading slash. + return ($path[0] === '/') ? $path : '/' . $path; + } + catch (\Exception $e) { + return NULL; + } + } + + /** + * Resolves the facet filter key for a facet source. + * + * Mirrors the logic in the Facets module: the facet source config entity id + * is the plugin id with colons replaced by double underscores, and the + * default filter key is "f" when no configuration exists. + * + * @param string $facet_source + * The facet source plugin id. + * + * @return string + * The query string filter key. + */ + protected function getFilterKey($facet_source) { + if ($this->entityTypeManager->hasDefinition('facets_facet_source')) { + $source_id = str_replace(':', '__', $facet_source); + /** @var \Drupal\facets\FacetSourceInterface|null $config */ + $config = $this->entityTypeManager->getStorage('facets_facet_source')->load($source_id); + if ($config && $config->getFilterKey()) { + return $config->getFilterKey(); + } + } + return 'f'; + } + }