Skip to content
Snippets Groups Projects
Commit 780c3cca authored by David Galeano's avatar David Galeano
Browse files

Issue #3403465 by gxleano: Add Tagify Facets submodule with Facets plugin and...

parent b8c734df
No related branches found
No related tags found
1 merge request!71Issue #3403465 by gxleano: Add Tagify Facets submodule with Facets plugin and...
Pipeline #117590 passed
Showing
with 838 additions and 1 deletion
......@@ -18,6 +18,8 @@
"drupal/core": "^8.8 || ^9 || ^10"
},
"require-dev": {
"drupal/better_exposed_filters": "^6.0"
"drupal/better_exposed_filters": "^6.0",
"drupal/facets": "^2.0",
"drupal/search_api": "^1.31"
}
}
/**
* @file
* Init tagify widget.
*/
// eslint-disable-next-line func-names
(function ($, Drupal, once) {
Drupal.facets = Drupal.facets || {};
// eslint-disable-next-line func-names
Drupal.facets.initTagify = function (context, settings) {
const links = $(once('tagify-widget', '.js-facets-tagify'));
if (links.length > 0) {
// eslint-disable-next-line func-names
links.each(function (index, widget) {
const $widget = $(widget);
const $widgetLinks = $widget.find('.facet-item > a');
const $whitelist = [];
const $selected = [];
// eslint-disable-next-line func-names
$widgetLinks.each(function (key, link) {
const $link = $(link);
const value = link.querySelector('.facet-item__value').textContent;
const count = link.querySelector('.facet-item__count').textContent;
// Create whitelist for Tagify suggestions with values coming from links.
$whitelist.push({
value: $link.attr('href'),
text: `${value.trim()} ${count.trim()}`,
});
// If link is active, add to the input (which will be used on Tagify).
if ($link.hasClass('is-active')) {
$selected.push({
value: $link.attr('href'),
text: value,
count,
});
}
});
// Check if an input element with the specified class exists.
let input = document.querySelector('input.js-facets-tagify');
if (!input) {
input = document.createElement('input');
input.setAttribute('class', 'tagify-input');
this.before(input);
}
input.value = JSON.stringify($selected);
this.before(input);
/**
* Highlights matching letters in a given input string by wrapping them in <strong> tags.
* @param {string} inputTerm - The input string for matching letters.
* @param {string} searchTerm - The term to search for within the input string.
* @return {string} The input string with matching letters wrapped in <strong> tags.
*/
function highlightMatchingLetters(inputTerm, searchTerm) {
// Escape special characters in the search term.
const escapedSearchTerm = searchTerm.replace(
/[.*+?^${}()|[\]\\]/g,
'\\$&',
);
// Create a regular expression to match the search term globally and case insensitively.
const regex = new RegExp(`(${escapedSearchTerm})`, 'gi');
// Check if there are any matches.
if (!escapedSearchTerm) {
// If no matches found, return the original input string.
return inputTerm;
}
// Replace matching letters with the same letters wrapped in <strong> tags.
return inputTerm.replace(regex, '<strong>$1</strong>');
}
/**
* Generates HTML markup for a tag based on the provided tagData.
* @param {Object} tagData - Data for the tag, including value, entity_id, class, etc.
* @return {string} - HTML markup for the generated tag.
*/
function tagTemplate(tagData) {
const entityIdDiv =
parseInt(input.dataset.showEntityId, 10) && tagData.entity_id
? `<div id="tagify__tag-items" class="tagify__tag_with-entity-id"><div class='tagify__tag__entity-id-wrap'><span class='tagify__tag-entity-id'>${tagData.entity_id}</span></div><span class='tagify__tag-text'>${tagData.value}</span></div>`
: `<div id="tagify__tag-items"><span class='tagify__tag-facets-text'>${tagData.text}</span><span class="tagify__tag-facets-count"> ${tagData.count}<span></div>`;
return `<tag title="${tagData.text}"
contenteditable='false'
spellcheck='false'
tabIndex="0"
class="tagify__tag ${tagData.class ? tagData.class : ''}"
${this.getAttributes(tagData)}>
<x id="tagify__tag-remove-button" class='tagify__tag__removeBtn' role='button' aria-label='remove tag'></x>
${entityIdDiv}
</tag>`;
}
/**
* Generates the HTML template for a suggestion item in the Tagify dropdown based on the provided tagData.
* @param {Object} tagData - The data representing the suggestion item.
* @return {string} - The HTML template for the suggestion item.
*/
function suggestionItemTemplate(tagData) {
return `<div ${this.getAttributes(
tagData,
)} class='tagify__dropdown__item ${
tagData.class ? tagData.class : ''
}' tabindex="0" role="option">${highlightMatchingLetters(
tagData.text,
this.state.inputText,
)}</div>`;
}
// eslint-disable-next-line no-undef
const tagify = new Tagify(input, {
dropdown: {
enabled: 0,
highlightFirst: true,
searchKeys: ['text'],
fuzzySearch: !!parseInt(
settings.tagify.tagify_facets_widget.match_operator,
10,
),
maxItems:
settings.tagify.tagify_facets_widget.max_items ?? Infinity,
},
templates: {
tag: tagTemplate,
dropdownItem: suggestionItemTemplate,
dropdownFooter() {
return '';
},
},
whitelist: $whitelist,
enforceWhitelist: true,
editTags: false,
placeholder: settings.tagify.tagify_facets_widget.placeholder,
});
/**
* Binds Sortable to Tagify's main element and specifies draggable items.
*/
Sortable.create(tagify.DOM.scope, {
draggable: `.${tagify.settings.classNames.tag}:not(tagify__input)`,
forceFallback: true,
onEnd() {
tagify.updateValueByDOMTags();
},
});
/**
* Listens to add tag event and updates facets values accordingly.
*/
// eslint-disable-next-line func-names
tagify.on('add', function (e) {
const { value } = e.detail.data;
e.preventDefault();
$widget.trigger('facets_filter', [value]);
});
/**
* Listens to remove tag event and updates facets values accordingly.
*/
// eslint-disable-next-line func-names
tagify.on('remove', function (e) {
const { value } = e.detail.data;
e.preventDefault();
$widget.trigger('facets_filter', [value]);
});
});
}
};
/**
* Behavior to register tagify widget to be used for facets.
*/
Drupal.behaviors.facetsTagifyWidget = {
attach(context, settings) {
Drupal.facets.initTagify(context, settings);
},
};
})(jQuery, Drupal, once);
<?php
namespace Drupal\tagify_facets\Plugin\facets\widget;
use Drupal\Core\Form\FormStateInterface;
use Drupal\facets\FacetInterface;
use Drupal\facets\Widget\WidgetPluginBase;
/**
* The Tagify widget.
*
* @FacetsWidget(
* id = "tagify",
* label = @Translation("Tagify"),
* description = @Translation("A configurable widget that shows a tagify component."),
* )
*/
class TagifyWidget extends WidgetPluginBase {
/**
* {@inheritdoc}
*/
public function defaultConfiguration() {
return [
'match_operator' => 'CONTAINS',
'max_items' => 10,
'placeholder' => '',
] + parent::defaultConfiguration();
}
/**
* {@inheritdoc}
*/
public function buildConfigurationForm(array $form, FormStateInterface $form_state, FacetInterface $facet) {
$form = parent::buildConfigurationForm($form, $form_state, $facet);
$form['match_operator'] = [
'#type' => 'radios',
'#title' => $this->t('Autocomplete matching'),
'#default_value' => $this->getConfiguration()['match_operator'],
'#options' => $this->getMatchOperatorOptions(),
'#description' => $this->t('Select the method used to collect autocomplete suggestions. Note that <em>Contains</em> can cause performance issues on sites with thousands of entities.'),
'#states' => [
'visible' => [
':input[name$="widget_config[autocomplete]"]' => ['checked' => TRUE],
],
],
];
$form['max_items'] = [
'#type' => 'number',
'#title' => $this->t('Number of results'),
'#default_value' => $this->getConfiguration()['max_items'],
'#min' => 0,
'#description' => $this->t('The number of suggestions that will be listed. Use <em>0</em> to remove the limit.'),
];
$form['placeholder'] = [
'#type' => 'textfield',
'#title' => $this->t('Placeholder'),
'#default_value' => $this->getConfiguration()['placeholder'],
'#description' => $this->t('Text that will be shown inside the field until a value is entered. This hint is usually a sample value or a brief description of the expected format.'),
];
return $form;
}
/**
* {@inheritdoc}
*/
public function build(FacetInterface $facet) {
$build = parent::build($facet);
$this->appendWidgetLibrary($build);
return $build;
}
/**
* Appends widget library and relevant information for it to build array.
*
* @param array $build
* Reference to build array.
*/
protected function appendWidgetLibrary(array &$build) {
$build['#attributes']['class'][] = 'js-facets-tagify';
$build['#attributes']['class'][] = 'js-facets-widget';
$build['#attributes']['class'][] = 'hidden';
$build['#attached']['library'][] = 'tagify/tagify';
$build['#attached']['library'][] = 'tagify_facets/drupal.tagify_facets.tagify-widget';
$build['#attached']['drupalSettings']['tagify']['tagify_facets_widget']['match_operator'] = $this->getConfiguration()['match_operator'];
$build['#attached']['drupalSettings']['tagify']['tagify_facets_widget']['placeholder'] = $this->getConfiguration()['placeholder'];
$build['#attached']['drupalSettings']['tagify']['tagify_facets_widget']['max_items'] = $this->getConfiguration()['max_items'];
}
/**
* Returns the options for the match operator.
*
* @return array
* List of options.
*/
protected function getMatchOperatorOptions(): array {
return [
'STARTS_WITH' => $this->t('Starts with'),
'CONTAINS' => $this->t('Contains'),
];
}
}
name: Tagify Facets
type: module
description: Provides a Tagify Facet widget
package: Search
core_version_requirement: ^8.8 || ^9 || ^10
dependencies:
- tagify:tagify
- facets:facets
drupal.tagify_facets.tagify-widget:
version: VERSION
js:
js/tagify-widget.js: {}
dependencies:
- facets/widget
langcode: en
status: true
dependencies:
config:
- search_api.index.test_entity
- views.view.test_entity_view
module:
- search_api
id: tags
name: Tags
url_alias: tag
weight: 0
min_count: 1
show_only_one_result: false
field_identifier: field_tagify
facet_source_id: 'search_api:views_page__test_entity_view__page_1'
widget:
type: tagify
config:
show_numbers: true
query_operator: or
use_hierarchy: false
expand_hierarchy: false
enable_parent_when_child_gets_disabled: true
hard_limit: 0
exclude: false
only_visible_when_facet_source_is_visible: true
processor_configs:
active_widget_order:
processor_id: active_widget_order
weights:
sort: 20
settings:
sort: DESC
count_widget_order:
processor_id: count_widget_order
weights:
sort: 30
settings:
sort: DESC
display_value_widget_order:
processor_id: display_value_widget_order
weights:
sort: 40
settings:
sort: ASC
hide_1_result_facet:
processor_id: hide_1_result_facet
weights:
build: 50
settings: {}
translate_entity:
processor_id: translate_entity
weights:
build: 5
settings: {}
url_processor_handler:
processor_id: url_processor_handler
weights:
pre_query: 50
build: 15
settings: {}
empty_behavior:
behavior: none
show_title: false
langcode: en
status: true
dependencies: {}
id: search_api__views_page__test_entity_view__page_1
name: 'search_api:views_page__test_entity_view__page_1'
filter_key: null
url_processor: query_string
breadcrumb: {}
langcode: en
status: true
dependencies:
config:
- field.storage.entity_test_mulrevpub.field_tagify
module:
- entity_test
id: entity_test_mulrevpub.entity_test_mulrevpub.field_tagify
field_name: field_tagify
entity_type: entity_test_mulrevpub
bundle: entity_test_mulrevpub
label: Tags
description: ''
required: false
translatable: true
default_value: {}
default_value_callback: ''
settings:
handler: 'default:entity_test_mulrevpub'
handler_settings:
target_bundles:
entity_test_mulrevpub: entity_test_mulrevpub
auto_create: false
field_type: entity_reference
langcode: en
status: true
dependencies:
module:
- entity_test
id: entity_test_mulrevpub.field_tagify
field_name: field_tagify
entity_type: entity_test_mulrevpub
type: entity_reference
settings:
target_type: entity_test_mulrevpub
module: core
locked: false
cardinality: -1
translatable: true
indexes: {}
persist_with_no_fields: false
custom_storage: false
langcode: en
status: true
dependencies:
module:
- entity_test
- search_api
config:
- field.storage.entity_test_mulrevpub.field_tagify
- search_api.server.database
id: test_entity
name: 'Test entity'
description: ''
read_only: false
field_settings:
field_tagify:
label: Tags
datasource_id: 'entity:entity_test_mulrevpub'
property_path: field_tagify
type: integer
dependencies:
config:
- field.storage.entity_test_mulrevpub.field_tagify
name:
label: Name
datasource_id: 'entity:entity_test_mulrevpub'
property_path: name
type: string
dependencies:
module:
- entity_test
status:
label: Published
datasource_id: 'entity:entity_test_mulrevpub'
property_path: status
type: boolean
dependencies:
module:
- entity_test
datasource_settings:
'entity:entity_test_mulrevpub':
bundles:
default: true
selected: {}
languages:
default: true
selected: {}
processor_settings:
add_url: {}
aggregated_field: {}
rendered_item: {}
tracker_settings:
default:
indexing_order: fifo
options:
index_directly: true
cron_limit: 50
server: database
langcode: en
status: true
dependencies:
module:
- search_api_db
id: database
name: Database
description: ''
backend: search_api_db
backend_config:
database: 'default:default'
min_chars: 1
matching: words
autocomplete:
suggest_suffix: true
suggest_words: true
langcode: en
status: true
dependencies:
config:
- field.storage.entity_test_mulrevpub.field_tagify
- search_api.index.test_entity
module:
- search_api
id: test_entity_view
label: 'Test entity view'
module: views
description: ''
tag: ''
base_table: search_api_index_test_entity
base_field: search_api_id
core: 8.x
display:
default:
display_plugin: default
id: default
display_title: Master
position: 0
display_options:
access:
type: none
options: {}
cache:
type: tag
options: {}
query:
type: search_api_query
options:
bypass_access: false
skip_access: false
exposed_form:
type: basic
options:
submit_button: Apply
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: mini
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, 25, 50'
items_per_page_options_all: false
items_per_page_options_all_label: '- All -'
offset: false
offset_label: Offset
tags:
previous: ‹‹
next: ››
style:
type: default
row:
type: fields
fields:
name:
id: name
table: search_api_datasource_test_entity_entity_entity_test_mulrevpub
field: name
relationship: none
group_type: group
admin_label: ''
label: ''
exclude: false
alter:
alter_text: false
text: ''
make_link: false
path: ''
absolute: false
external: false
replace_spaces: false
path_case: none
trim_whitespace: false
alt: ''
rel: ''
link_class: ''
prefix: ''
suffix: ''
target: ''
nl2br: false
max_length: 0
word_boundary: true
ellipsis: true
more_link: false
more_link_text: ''
more_link_path: ''
strip_tags: false
trim: false
preserve_tags: ''
html: false
element_type: ''
element_class: ''
element_label_type: ''
element_label_class: ''
element_label_colon: false
element_wrapper_type: ''
element_wrapper_class: ''
element_default_classes: true
empty: ''
hide_empty: false
empty_zero: false
hide_alter_empty: true
click_sort_column: value
type: string
settings:
link_to_entity: false
group_column: value
group_columns: {}
group_rows: true
delta_limit: 0
delta_offset: 0
delta_reversed: false
delta_first_last: false
multi_type: separator
separator: ', '
field_api_classes: false
field_rendering: true
fallback_handler: search_api
fallback_options:
link_to_item: false
use_highlighting: false
multi_type: separator
multi_separator: ', '
entity_type: entity_test_mulrevpub
plugin_id: search_api_field
field_tagify:
id: field_tagify
table: search_api_index_test_entity
field: field_tagify
relationship: none
group_type: group
admin_label: ''
label: ''
exclude: false
alter:
alter_text: false
text: ''
make_link: false
path: ''
absolute: false
external: false
replace_spaces: false
path_case: none
trim_whitespace: false
alt: ''
rel: ''
link_class: ''
prefix: ''
suffix: ''
target: ''
nl2br: false
max_length: 0
word_boundary: true
ellipsis: true
more_link: false
more_link_text: ''
more_link_path: ''
strip_tags: false
trim: false
preserve_tags: ''
html: false
element_type: ''
element_class: ''
element_label_type: ''
element_label_class: ''
element_label_colon: false
element_wrapper_type: ''
element_wrapper_class: ''
element_default_classes: true
empty: ''
hide_empty: false
empty_zero: false
hide_alter_empty: true
click_sort_column: target_id
type: entity_reference_label
settings:
link: true
group_column: target_id
group_columns: {}
group_rows: true
delta_limit: 0
delta_offset: 0
delta_reversed: false
delta_first_last: false
multi_type: separator
separator: ', '
field_api_classes: false
field_rendering: true
fallback_handler: search_api_entity
fallback_options:
link_to_item: false
use_highlighting: false
multi_type: separator
multi_separator: ', '
display_methods:
entity_test_mulrevpub:
display_method: label
plugin_id: search_api_field
filters: {}
sorts: {}
title: 'Test entity view'
header: {}
footer: {}
empty: {}
relationships: {}
arguments: {}
display_extenders: {}
cache_metadata:
max-age: -1
contexts:
- 'languages:language_content'
- 'languages:language_interface'
- url.query_args
tags:
- 'config:field.storage.entity_test_mulrevpub.field_tagify'
page_1:
display_plugin: page
id: page_1
display_title: Page
position: 1
display_options:
display_extenders: {}
path: test-entity-view
cache_metadata:
max-age: -1
contexts:
- 'languages:language_content'
- 'languages:language_interface'
- url.query_args
tags:
- 'config:field.storage.entity_test_mulrevpub.field_tagify'
name: 'Tagify facets test'
type: module
description: 'Support module for Tagify facets tests.'
package: Testing
core_version_requirement: ^8.8 || ^9 || ^10
dependencies:
- facets:facets
- search_api:search_api_db
- drupal:entity_test
- tagify:tagify_facets
- drupal:views
- drupal:block
<?php
namespace Drupal\Tests\tagify_facets\FunctionalJavascript\FieldWidget;
use Drupal\entity_test\Entity\EntityTestMulRevPub;
use Drupal\facets\Entity\Facet;
use Drupal\FunctionalJavascriptTests\WebDriverTestBase;
/**
* Tests tagify facets widget.
*
* @group tagify
*/
class TagifyWidgetTest extends WebDriverTestBase {
/**
* {@inheritdoc}
*/
protected $defaultTheme = 'olivero';
/**
* {@inheritdoc}
*/
protected static $modules = [
'tagify_facets_test',
// Prevent tests from failing due to 'RuntimeException' with AJAX request.
'js_testing_ajax_request_test',
];
/**
* {@inheritdoc}
*/
protected function setUp(): void {
parent::setUp();
// Create reference items.
$tag_1 = EntityTestMulRevPub::create(['name' => 'Tag 1']);
$tag_1->save();
$tag_2 = EntityTestMulRevPub::create(['name' => 'Tag 2']);
$tag_2->save();
$tag_3 = EntityTestMulRevPub::create(['name' => 'Tag 3']);
$tag_3->save();
// Create Nodes with references.
EntityTestMulRevPub::create([
'name' => 'Test Node 1',
'field_tagify' => [$tag_1, $tag_2],
])->save();
// Create Node 2 with field_tagify (entity reference field).
EntityTestMulRevPub::create([
'name' => 'Test Node 2',
'field_tagify' => [$tag_1, $tag_3],
])->save();
$account = $this->createUser(['view test entity']);
$this->drupalLogin($account);
search_api_cron();
$this->drupalPlaceBlock('facet_block:tags');
}
/**
* Tests tagify facets widget functionality.
*/
public function testTagifyFacets(): void {
$facet = Facet::load('tags');
$facet->setWidget('tagify', [
'show_numbers' => TRUE,
]);
$facet->save();
$this->drupalGet('/test-entity-view');
$page = $this->getSession()->getPage();
$assert_session = $this->assertSession();
$page->find('css', '.tagify__input')->setValue('Tag');
$assert_session->waitForElement('css', '.tagify__dropdown__item');
$assert_session->waitForElementVisible('css', '.tagify__dropdown__item--active');
// Output the new HTML.
$this->htmlOutput($page->getHtml());
$page->find('css', '.tagify__dropdown__item--active')->doubleClick();
$current_url = $this->getSession()->getCurrentUrl();
$this->assertStringContainsString('f%5B0%5D=tag%3A1', $current_url);
}
}
0% Loading or .
You are about to add 0 people to the discussion. Proceed with caution.
Finish editing this message first!
Please register or to comment