Browse Source

generalized the formatter so it is not tied to a site and should work with other field types

master
Paul Pound 1 day ago
parent
commit
2818562736
  1. 53
      README.md
  2. 16
      config/schema/solr_facet_field_formatter.schema.yml
  3. 2
      solr_facet_field_formatter.info.yml
  4. 20
      solr_facet_field_formatter.module
  5. 366
      src/Plugin/Field/FieldFormatter/SolrFacetTextFieldFormatter.php

53
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/<bundle>/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.
`/<search-path>?f[0]=<facet-alias>:<field-value>`.
## 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.

16
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'

2
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:

20
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 .= '<h3>' . t('About') . '</h3>';
$output .= '<p>' . t('Format string and text fields to link to Solr facet searches') . '</p>';
@ -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;
}
}

366
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';
}
}

Loading…
Cancel
Save