diff --git a/README.md b/README.md index b36ae2268..47d5b6231 100644 --- a/README.md +++ b/README.md @@ -10,7 +10,7 @@ Primary features of the module include: * Indexing of multiple, independent Drupal sites into a single index * Optional filtering of search results by site * Standard presentation of search results on all sites - * A standard search block for use on all sites + * A standard search block for use on all sites with optional configurable type-ahead search results * Customizable presentation using a single CSS file ### How does it work? @@ -61,27 +61,34 @@ CONFIGURATION On each site included in the federated search, you will need to: - 1. Install this module and its dependencies - 2. Configure a Search API server to connect to the shared Solr index - 3. Configure a Search API index according to the [required schema documentation](docs/federated_schema.md) - 4. Index the content for the site using Search API +1. Install this module and its dependencies +1. Configure a Search API server to connect to the shared Solr index +1. Configure that Search API server to set default query fields for your default [Request Handler](https://lucene.apache.org/solr/guide/6_6/requesthandlers-and-searchcomponents-in-solrconfig.html#RequestHandlersandSearchComponentsinSolrConfig-SearchHandlers). (See [example](https://github.com/palantirnet/federated-search-demo/blob/root-198-search-terms-field/conf/solr/drupal8/custom/solr-conf/4.x/solrconfig.xml#L868) in Federated Search Demo site Solr server config) +1. Configure a Search API index according to the [required schema documentation](https://www.drupal.org/docs/8/modules/search-api-federated-solr/federated-search-schema) + 1. Optional: To help facilitate autocomplete term partial queries, consider adding a Fulltext [Ngram](https://lucene.apache.org/solr/guide/6_6/tokenizers.html) version of your title field to the index (See [example]() in the Federated Search Demo site Solr index config). Also consider adding that field as a default query field for your Solr server's default Request Handler. + 1. Optional: If your site uses a "search terms" or similar field to trigger a boost for items based on given search terms, consider adding a Fulltext [Ngram](https://lucene.apache.org/solr/guide/6_6/tokenizers.html) version of that field to the index. Also consider adding that field as a default query field for your Solr server's default Request Handler. +1. Index the content for the site using Search API Once each site is configured, you may begin to index content. In order to display results from the Solr index: - 1. Configure the application route and settings at `/admin/config/search-api-federated-solr/search-app/settings` - 2. Set permissions for `Use Federated Search` and `Administer Federated Search` for the proper roles. - 3. Optional: [Theme the ReactJS search app](docs/theme.md) - 4. Optional: Add the federated search page form block to your site theme - +1. Configure the application route and settings at `/admin/config/search-api-federated-solr/search-app/settings` +1. Set permissions for `Use Federated Search` and `Administer Federated Search` for the proper roles. +1. Optional: [Theme the ReactJS search app](docs/theme.md) +1. Optional: Add the federated search page form block to your site theme + configure the block settings +1. Optional: If you want autocomplete functionality and would prefer that results come from a view, [create a Search API search view with a rest export](https://www.drupal.org/docs/8/modules/search-api/getting-started/search-forms-and-results-pages/searching-with-views-0) or create a content view with a rest export (see the "Search API Federated Solr Block Form Autocomplete" view added as optional config for this module in `config/optional`) and use that url as your autocomplete endpoint. + 1. Under format, choose Solr Serializer as the format (this wraps the view results with the same response object as Solr so they can be rendered) + 1. Under format, choose fields. Add the title (for Search views, we recommend adding a full text version of your title to the index and adding that instead) and link to content (for Search views, url) fields. + 1. Under format, configure settings for the fields. Use the alias `ss_federated_title` for your title field and `ss_url` for your url field. + 1. Under Filter Criteria, add those fields you would like to query for the search term as an exposed filter with the "contains any word" operator (for Search views use full text field searches). For each filter, assign a filter identifier. These will be used in your autocomplete url as querystring params: `&filter1_identifier_value=[val]&filter2_identifier_value=[val]`. ### Updating the bundled React application When changes to [federated-search-react](https://github.com/palantirnet/federated-search-react/) are made they'll need to be pulled into this module. To do so: - 1. [Publish a release](https://github.com/palantirnet/federated-search-react#publishing-releases) of Federated Search React. - 2. Update `search_api_federated_solr.libraries.yml` to reference the new release. Note: You'll need to edit the version number and the hash of both the CSS and JS files. +1. [Publish a release](https://github.com/palantirnet/federated-search-react#publishing-releases) of Federated Search React. +1. Update `search_api_federated_solr.libraries.yml` to reference the new release. Note: You'll need to edit the version number and the hash of both the CSS and JS files. ### More information diff --git a/config/install/search_api_federated_solr.search_app.settings.yml b/config/install/search_api_federated_solr.search_app.settings.yml index 9c1c11c91..908e1e76e 100644 --- a/config/install/search_api_federated_solr.search_app.settings.yml +++ b/config/install/search_api_federated_solr.search_app.settings.yml @@ -1,8 +1,29 @@ +autocomplete: + isEnabled: 0 + url: '' + appendWildcard: 0 + suggestionRows: '' + numChars: '' + mode: result + result: + titleText: '' + hideDirectionsText: 0 facet: site_name: set_default: false + is_hidden: false + federated_type: + is_hidden: false + federated_terms: + is_hidden: false +filter: + federated_date: + is_hidden: false index: id: '' + has_federated_date_property: false + has_federated_terms_property: false + has_federated_type_property: false has_site_name_property: false server_url: '' username: '' diff --git a/config/optional/views.view.search_api_federated_solr_block_form_autocomplete.yml b/config/optional/views.view.search_api_federated_solr_block_form_autocomplete.yml new file mode 100644 index 000000000..bd9bd5654 --- /dev/null +++ b/config/optional/views.view.search_api_federated_solr_block_form_autocomplete.yml @@ -0,0 +1,348 @@ +langcode: en +status: true +dependencies: + module: + - node + - rest + - search_api_federated_solr + - serialization + - user +id: search_api_federated_solr_block_form_autocomplete +label: 'Search API Federated Solr Block Form Autocomplete' +module: views +description: '' +tag: '' +base_table: node_field_data +base_field: nid +core: 8.x +display: + default: + display_plugin: default + id: default + display_title: Master + position: 0 + display_options: + access: + type: perm + options: + perm: 'access content' + cache: + type: tag + options: { } + query: + type: views_query + options: + disable_sql_rewrite: false + distinct: false + replica: false + query_comment: '' + query_tags: { } + exposed_form: + type: basic + options: + submit_button: Search + reset_button: false + reset_button_label: Reset + exposed_sorts_label: 'Sort by' + expose_sort_order: false + sort_asc_label: Asc + sort_desc_label: Desc + pager: + type: some + options: + items_per_page: 10 + offset: 0 + style: + type: default + row: + type: fields + fields: + title: + id: title + table: node_field_data + field: title + 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 + plugin_id: field + view_node: + id: view_node + table: node + field: view_node + 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 + text: view + output_url_as_text: true + absolute: false + entity_type: node + plugin_id: entity_link + filters: + status: + value: '1' + table: node_field_data + field: status + plugin_id: boolean + entity_type: node + entity_field: status + id: status + expose: + operator: '' + group: 1 + title: + id: title + table: node_field_data + field: title + relationship: none + group_type: group + admin_label: '' + operator: word + value: '' + group: 1 + exposed: true + expose: + operator_id: title_op + label: Title + description: '' + use_operator: false + operator: title_op + identifier: term + required: true + remember: false + multiple: false + remember_roles: + authenticated: authenticated + anonymous: '0' + administrator: '0' + author: '0' + editor: '0' + placeholder: '' + is_grouped: false + group_info: + label: '' + description: '' + identifier: '' + optional: true + widget: select + multiple: false + remember: false + default_group: All + default_group_multiple: { } + group_items: { } + entity_type: node + entity_field: title + plugin_id: string + sorts: { } + title: 'Search API Federated Solr Block Form Autocomplete' + header: { } + footer: { } + empty: { } + relationships: { } + arguments: { } + display_extenders: { } + use_ajax: true + filter_groups: + operator: AND + groups: + 1: AND + cache_metadata: + max-age: -1 + contexts: + - 'languages:language_content' + - 'languages:language_interface' + - url + - 'user.node_grants:view' + - user.permissions + tags: { } + rest_export_1: + display_plugin: rest_export + id: rest_export_1 + display_title: 'REST export - exposed' + position: 2 + display_options: + display_extenders: { } + path: search-api-federated-solr-block-form-autocomplete/exposed-filter + style: + type: solr_serializer + options: + formats: + json: json + row: + type: data_field + options: + field_options: + title: + alias: ss_federated_title + raw_output: false + view_node: + alias: ss_url + raw_output: false + display_description: '' + filters: + status: + value: '1' + table: node_field_data + field: status + plugin_id: boolean + entity_type: node + entity_field: status + id: status + expose: + operator: '' + group: 1 + title: + id: title + table: node_field_data + field: title + relationship: none + group_type: group + admin_label: '' + operator: word + value: '' + group: 1 + exposed: true + expose: + operator_id: title_op + label: Title + description: '' + use_operator: false + operator: title_op + identifier: title + required: true + remember: false + multiple: false + remember_roles: + authenticated: authenticated + anonymous: '0' + administrator: '0' + author: '0' + editor: '0' + placeholder: '' + is_grouped: false + group_info: + label: '' + description: '' + identifier: '' + optional: true + widget: select + multiple: false + remember: false + default_group: All + default_group_multiple: { } + group_items: { } + entity_type: node + entity_field: title + plugin_id: string + defaults: + filters: false + filter_groups: false + filter_groups: + operator: AND + groups: + 1: AND + cache_metadata: + max-age: -1 + contexts: + - 'languages:language_content' + - 'languages:language_interface' + - request_format + - url + - 'user.node_grants:view' + - user.permissions + tags: { } diff --git a/config/schema/search_api_federated_solr.schema.yml b/config/schema/search_api_federated_solr.schema.yml index 905e7ca55..244a9c3ea 100644 --- a/config/schema/search_api_federated_solr.schema.yml +++ b/config/schema/search_api_federated_solr.schema.yml @@ -16,12 +16,45 @@ search_api_federated_solr.search_app.settings: set_default: type: boolean label: 'When true, only search results from this site will be shown.' + is_hidden: + type: boolean + label: 'When true, this facet option is hidden in the UI.' + federated_type: + type: mapping + mapping: + is_hidden: + type: boolean + label: 'When true, this facet option is hidden in the UI.' + federated_terms: + type: mapping + mapping: + is_hidden: + type: boolean + label: 'When true, this facet option is hidden in the UI.' + filter: + type: mapping + mapping: + federated_date: + type: mapping + mapping: + is_hidden: + type: boolean + label: 'When true, this filter option is hidden in the UI.' index: type: mapping mapping: id: type: string label: 'Defines which search_api index and server the search app should use.' + has_federated_date_property: + type: boolean + label: 'Has federated date flag.' + has_federated_terms_property: + type: boolean + label: 'Has federated terms flag.' + has_federated_type_property: + type: boolean + label: 'Has federated type flag.' has_site_name_property: type: boolean label: 'Has site name flag.' @@ -55,3 +88,33 @@ search_api_federated_solr.search_app.settings: buttons: type: integer label: 'The max number of numbered pagination buttons to show at a given time.' + autocomplete: + type: mapping + mapping: + isEnabled: + type: integer + label: 'When true, autocomplete is enabled for the search app.' + url: + type: string + label: 'The autocomplete endpoint url to be used.' + appendWildcard: + type: integer + label: 'When checked, the query will have a wildcard appended.' + suggestionRows: + type: string + label: 'The number of results to return from the autocomplete request.' + numChars: + type: string + label: 'The number of characters, after which the autocomplete request should be made.' + mode: + type: string + label: 'The type of results returned by the autocomplete request: results or terms.' + result: + type: mapping + mapping: + titleText: + type: string + label: 'The title text for the autocomplete results.' + hideDirectionsText: + type: integer + label: 'When true, the keyboard directions text will be hidden from autocomplete dropdown.' diff --git a/css/search_api_federated_solr_autocomplete.css b/css/search_api_federated_solr_autocomplete.css new file mode 100644 index 000000000..fffa8a2de --- /dev/null +++ b/css/search_api_federated_solr_autocomplete.css @@ -0,0 +1,83 @@ +#autocomplete-wrapper { + position: relative; +} + +#federated-search-page-block-form .search-autocomplete-container { + position: absolute; + border: 1px solid #999; + background: #fff; + z-index: 1000; + width: 100%; + display: block; +} + +#federated-search-page-block-form .search-autocomplete-container:not(.visually-hidden) .search-autocomplete-container__title { + font-size:.8em; + line-height:1.46667em; + position: relative; + font-weight: bold; + padding: 10px 20px; + border-bottom: 1px dashed #ccc; + display: block; +} +.search-autocomplete-container:not(.visually-hidden) .search-autocomplete-container__close-button { + font-size:.8em; + line-height:1.46667em; + font-style:normal; + padding:3px 7px; + border-color:#ccc; + color:#333; + cursor:pointer; + position:absolute; + right:5px +} + +.search-autocomplete-container:not(.visually-hidden) .search-autocomplete-container__close-button:hover { + border-color:#b5b5b5; + background-color:#f6f6f6 +} + +#federated-search-page-block-form .search-autocomplete-container:not(.visually-hidden) .search-autocomplete-container__directions { + display: block; + padding:10px 20px; +} + +#federated-search-page-block-form .search-autocomplete-container:not(.visually-hidden) .search-autocomplete-container__directions-item { + display: block; + font-size:.8em; + line-height:1.46667em; +} + +.search-autocomplete-container:not(.visually-hidden) .autocomplete-suggestion { + cursor:pointer; + background-color:#fff; + white-space: nowrap; +} + +.search-autocomplete-container:not(.visually-hidden) .autocomplete-suggestion__link { + display: block; + color:#737373; + text-decoration: none; + font-size:.8em; + line-height:1.46667em; + padding:15px 20px; + border:1px solid #fff; + border-bottom-color:#ccc; +} + +.search-autocomplete-container:not(.visually-hidden) .highlight .autocomplete-suggestion__link, +.search-autocomplete-container:not(.visually-hidden) .autocomplete-suggestion__link:hover { + text-decoration: underline; + background-color:#f6f6f6; + border:1px solid #f6f6f6; + border-bottom-color:#ccc; +} + +.autocomplete-selected { + background: #f0f0f0; +} + +.visually-hidden { + position: absolute !important; + clip: rect(1px 1px 1px 1px); +} diff --git a/js/search_api_federated_solr_autocomplete.js b/js/search_api_federated_solr_autocomplete.js new file mode 100644 index 000000000..ea29b3a1e --- /dev/null +++ b/js/search_api_federated_solr_autocomplete.js @@ -0,0 +1,348 @@ +/** + * @file + * Adds autocomplete functionality to search_api_solr_federated block form. + */ + +(function ($, Drupal, drupalSettings) { + + 'use strict'; + + var autocomplete = {}; + + /** + * Attaches our custom autocomplete settings to the search_api_federated_solr block search form field. + * + * @type {Drupal~behavior} + * + * @prop {Drupal~behaviorAttach} attach + * Attaches the autocomplete behaviors. + */ + Drupal.behaviors.searchApiFederatedSolrAutocomplete = { + attach: function (context, settings) { + // Find our fields with autocomplete settings + $(context) + .find('.js-search-api-federated-solr-block-form-autocomplete #edit-search') + .once('search-api-federated-solr-autocomplete-search') + .each(function () { + // Halt execution if we don't have the required config. + if (!Object.hasOwnProperty.call(drupalSettings, 'searchApiFederatedSolr') + || !Object.hasOwnProperty.call(drupalSettings.searchApiFederatedSolr, 'block') + || !Object.hasOwnProperty.call(drupalSettings.searchApiFederatedSolr.block, 'autocomplete') + || !Object.hasOwnProperty.call(drupalSettings.searchApiFederatedSolr.block.autocomplete, 'url')) { + return; + } + + // Set default settings. + var defaultSettings = { + isEnabled: false, + appendWildcard: false, + userpass: '', + numChars: 2, + suggestionRows: 5, + mode: 'result', + result: { + titleText: "What are you looking for?", + hideDirectionsText: 0 + } + }; + // Get passed in config from block config. + var config = drupalSettings.searchApiFederatedSolr.block.autocomplete; + // Merge defaults with passed in config. + var options = Object.assign({}, defaultSettings, config); + + // Set scaffolding markup for suggestions container + var suggestionsContainerScaffoldingMarkup = '
' + options[options.mode].titleText + '
'; + + if (!options[options.mode].hideDirectionsText) { + suggestionsContainerScaffoldingMarkup += '
Press ENTER to search for your current term or ESC to close.Press ↑ and ↓ to highlight a suggestion then ENTER to be redirected to that suggestion.
'; + } + + suggestionsContainerScaffoldingMarkup += '
'; + + // Cache selectors. + var $input = $(this); + var $form = $('#federated-search-page-block-form'); + // Set up input with attributes, suggestions scaffolding. + $input.attr("role","combobox") + .attr("aria-owns","res") + .attr("aria-autocomplete","list") + .attr("aria-expanded","false"); + $(suggestionsContainerScaffoldingMarkup).insertAfter($input); + // Cache inserted selectors. + var $results = $('#res'); + var $autocompleteContainer = $('.search-autocomplete-container'); + var $closeButton = $('.search-autocomplete-container__close-button'); + + // Initiate helper vars. + var current; + var counter = 1; + var keys = { + ESC: 27, + TAB: 9, + RETURN: 13, + UP: 38, + DOWN: 40 + }; + + // Determine param values for any set default filters/facets. + var defaultParams = ''; + $('input[type="hidden"]', $form).each(function(index, input) { + defaultParams += '&' + $(input).attr('name') + '=' + encodeURI($(input).val()); + }); + var urlWithDefaultParams = options.url + defaultParams; + + + // Bind events to input. + $input.on("input", function(event) { + doSearch(options.suggestionRows); + }); + + $input.on("keydown", function(event) { + doKeypress(keys, event); + }); + + // Define event handlers. + function doSearch(suggestionRows) { + $input.removeAttr("aria-activedescendant"); + var value = $input.val(); + // Remove spaces on either end of the value. + var trimmed = value.trim(); + // Default to the trimmed value. + var query = trimmed; + // If the current value has more than the configured number of characters. + if (query.length > options.numChars) { + // Append wildcard to the query if configured to do so. + if (options.appendWildcard) { + // One method of supporting search-as-you-type is to append a wildcard '*' + // to match zero or more additional characters at the end of the users search term. + // @see: https://lucene.apache.org/solr/guide/6_6/the-standard-query-parser.html#TheStandardQueryParser-WildcardSearches + // @see: https://opensourceconnections.com/blog/2013/06/07/search-as-you-type-with-solr/ + // Split into word chunks. + const words = trimmed.split(" "); + // If there are multiple chunks, join them with "+", repeat the last word + append "*". + if (words.length > 1) { + query = words.join("+") + words.pop() + '*'; + } + else { + // If there is only 1 word, repeat it an append "*". + query = words + '+' + words + '*'; + } + } + + // Replace the placeholder with the query value. + var pattern = new RegExp(/(\[val\])/, "gi"); + var url = urlWithDefaultParams.replace(pattern, query); + + // Set up basic auth if we need it. + var xhrFields = {}; + var headers = {}; + if (options.userpass) { + xhrFields = { + withCredentials: true + }; + headers = { + 'Authorization': 'Basic ' + options.userpass + }; + } + + // Make the ajax request + $.ajax({ + xhrFields: xhrFields, + headers: headers, + url: url, + dataType: 'json', + }) + // Currently we only support the response structure from Solr: + // { + // response: { + // docs: [ + // { + // ss_federated_title: , + // ss_url: , + // } + // ] + // } + // } + + // @todo provide hook for transform function to be passed in + // via Drupal.settings then all it here. + .done(function( results ) { + if (results.response.docs.length >= 1) { + // Remove all suggestions + $('.autocomplete-suggestion').remove(); + $autocompleteContainer.removeClass('visually-hidden'); + $("#search-autocomplete").append(''); + $input.attr("aria-expanded", "true"); + counter = 1; + + // Bind click event for close button + $closeButton.on("click", function (event) { + event.preventDefault(); + event.stopPropagation(); + $input.removeAttr("aria-activedescendant"); + // Remove all suggestions + $('.autocomplete-suggestion').remove(); + $autocompleteContainer.addClass('visually-hidden'); + $input.attr("aria-expanded", "false"); + $input.focus(); + }); + + // Get first [suggestionRows] results + var limitedResults = results.response.docs.slice(0, suggestionRows); + limitedResults.forEach(function(item) { + // Highlight query chars in returned title + var pattern = new RegExp(trimmed, "gi"); + var highlighted = item.ss_federated_title.replace(pattern, function(string) { + return "" + string + "" + }); + + //Add results to the list + $results.append("
" + highlighted + "(" + counter + " of " + limitedResults.length + ")
"); + counter = counter + 1; + }); + + // On link click, emit an event whose data can be used to write to analytics, etc. + $('.autocomplete-suggestion__link').on('click', function (e) { + $(document).trigger("SearchApiFederatedSolr::block::autocomplete::selection", [{ + referrer: $(location).attr('href'), + target: $(this).attr('href'), + term: $input.val() + }]); + }); + + // Announce the number of suggestions. + var number = $results.children('[role="option"]').length; + if (number >= 1) { + Drupal.announce(Drupal.t(number + " suggestions displayed. To navigate use up and down arrow keys.")); + } + } else { + // No results, remove suggestions and hide container + $('.autocomplete-suggestion').remove(); + $autocompleteContainer.addClass('visually-hidden'); + $input.attr("aria-expanded","false"); + } + }); + } + else { + // Remove suggestions and hide container + $('.autocomplete-suggestion').remove(); + $autocompleteContainer.addClass('visually-hidden'); + $input.attr("aria-expanded","false"); + } + } + + function doKeypress(keys, event) { + var $suggestions = $('.autocomplete-suggestion'); + var highlighted = false; + highlighted = $results.children('div').hasClass('highlight'); + + switch (event.which) { + case keys.ESC: + event.preventDefault(); + event.stopPropagation(); + $input.removeAttr("aria-activedescendant"); + $suggestions.remove(); + $autocompleteContainer.addClass('visually-hidden'); + $input.attr("aria-expanded","false"); + break; + + case keys.TAB: + $input.removeAttr("aria-activedescendant"); + $suggestions.remove(); + $autocompleteContainer.addClass('visually-hidden'); + $input.attr("aria-expanded","false"); + break; + + case keys.RETURN: + if (highlighted) { + event.preventDefault(); + event.stopPropagation(); + return selectOption(highlighted, $('.highlight').find('a').attr('href')); + } + else { + $form.submit(); + return false; + } + break; + + case keys.UP: + event.preventDefault(); + event.stopPropagation(); + return moveUp(highlighted); + break; + + case keys.DOWN: + event.preventDefault(); + event.stopPropagation(); + return moveDown(highlighted); + break; + + default: + return; + } + } + + function moveUp(highlighted) { + $input.removeAttr("aria-activedescendant"); + + // if highlighted exists and if the highlighted item is not the first option + if (highlighted && !$results.children().first('div').hasClass('highlight')) { + removeCurrent(); + current.prev('div').addClass('highlight').attr('aria-selected', true); + $input.attr("aria-activedescendant", current.prev('div').attr('id')); + } + else { + // Go to bottom of list + removeCurrent(); + current = $results.children().last('div'); + current.addClass('highlight').attr('aria-selected', true); + $input.attr("aria-activedescendant", current.attr('id')); + } + } + + function moveDown(highlighted) { + $input.removeAttr("aria-activedescendant"); + + // if highlighted exists and if the highlighted item is not the last option + if (highlighted && !$results.children().last('div').hasClass('highlight')) { + removeCurrent(); + current.next('div').addClass('highlight').attr('aria-selected', true); + $input.attr("aria-activedescendant", current.next('div').attr('id')); + } + else { + // Go to top of list + removeCurrent(); + current = $results.children().first('div'); + current.addClass('highlight').attr('aria-selected', true); + $input.attr("aria-activedescendant", current.attr('id')); + } + } + + function removeCurrent() { + current = $results.find('.highlight'); + current.attr('aria-selected', false); + current.removeClass('highlight'); + } + + function selectOption(highlighted, href) { + if (highlighted && href) { // @todo add logic for non-link suggestions + // Emit an event whose data can be used to write to analytics, etc. + $(document).trigger("SearchApiFederatedSolr::block::autocomplete::selection", [{ + referrer: $(location).attr('href'), + target: href, + term: $input.val() + }]); + // Redirect to the selected link. + $(location).attr("href", href); + } + else { + return; + } + } + }); + } + }; + + Drupal.SearchApiFederatedSolrAutocomplete = autocomplete; + +})(jQuery, Drupal, drupalSettings); diff --git a/search_api_federated_solr.install b/search_api_federated_solr.install new file mode 100644 index 000000000..46d991ac0 --- /dev/null +++ b/search_api_federated_solr.install @@ -0,0 +1,34 @@ +getEditable('search_api_federated_solr.search_app.settings'); + + // Set the autocomplete config defaults. + $config->set('autocomplete.isEnabled', 0); + $config->set('autocomplete.url', ''); + $config->set('autocomplete.appendWildcard', 0); + $config->set('autocomplete.suggestionRows', ''); + $config->set('autocomplete.mode', 'result'); + $config->set('autocomplete.result.titleText', ''); + $config->set('autocomplete.result.hideDirectionsText', 0); + + // Set the hidden facet/filter defaults. + $config->set('facet.site_name.is_hidden', false); + $config->set('facet.federated_terms.is_hidden', false); + $config->set('facet.federated_type.is_hidden', false); + $config->set('filter.federated_date.is_hidden', false); + + // Set the index defaults for property flags. + $config->set('index.has_federated_date_property', false); + $config->set('index.has_federated_term_property', false); + $config->set('index.has_federated_type_property', false); + + $config->save(TRUE); +} diff --git a/search_api_federated_solr.libraries.yml b/search_api_federated_solr.libraries.yml index ee8157fbf..a3d5ec0a8 100644 --- a/search_api_federated_solr.libraries.yml +++ b/search_api_federated_solr.libraries.yml @@ -2,10 +2,23 @@ search: version: 1.x css: theme: - https://cdn.jsdelivr.net/gh/palantirnet/federated-search-react@v1.0.10/css/main.cf6a58ce.css: + https://cdn.jsdelivr.net/gh/palantirnet/federated-search-react@v2.0/css/main.ec684809.css: type: external minified: true js: - https://cdn.jsdelivr.net/gh/palantirnet/federated-search-react@v1.0.10/js/main.d41fc3fe.js: + https://cdn.jsdelivr.net/gh/palantirnet/federated-search-react@v2.0/js/main.6a547bbe.js: preprocess: false minified: true + +search_form_autocomplete: + css: + theme: + css/search_api_federated_solr_autocomplete.css: {} + js: + js/search_api_federated_solr_autocomplete.js: {} + dependencies: + - core/jquery + - core/drupal + - core/drupalSettings + - core/drupal.ajax + - core/drupal.announce diff --git a/search_api_federated_solr.module b/search_api_federated_solr.module index 6d56afdac..c71d26452 100644 --- a/search_api_federated_solr.module +++ b/search_api_federated_solr.module @@ -144,37 +144,3 @@ function search_api_federated_solr_form_federated_search_page_block_form_alter(& $form['form_token']['#access'] = FALSE; $form['form_id']['#access'] = FALSE; } - -/** - * Implements hook_form_FORM_ID_alter() for search_api_federated_solr_search_settings_form. - * - * Validates whether or not the search app's chosen index has a site_name - * property and alters the search app settings form accordingly. - * - * @see \Drupal\search_api_federated_solr\Form\SearchApiFederatedSolrSearchAppSettingsForm - */ -function search_api_federated_solr_form_search_api_federated_solr_search_app_settings_alter(&$form, FormStateInterface $form_state) { - if ($search_index_id = $form['search_index']['#default_value']) { - $config = \Drupal::configFactory()->getEditable('search_api_federated_solr.search_app.settings'); - $index_config = \Drupal::config('search_api.index.' . $search_index_id); - // Determine if the index has a site name property, which could have been - // added / removed since last form load. - $site_name_property = $index_config->get('field_settings.site_name.configuration.site_name'); - $config->set('index.has_site_name_property', $site_name_property ? TRUE : FALSE); - - // If the index doesn't have a site name property. - if (!$site_name_property) { - // Reset the search app config options. - $form['site_name_property']['#value'] = ''; - $form['set_search_site']['#default_value'] = 0; - $config->set('facet.site_name.set_default', 0); - } - else { - // Ensure the hidden form field reflects lack of site name property. - $form['site_name_property']['#value'] = 'true'; - } - - $config->save(); - } - -} diff --git a/src/Controller/SearchController.php b/src/Controller/SearchController.php index ddced35a1..aa960d85e 100644 --- a/src/Controller/SearchController.php +++ b/src/Controller/SearchController.php @@ -34,7 +34,9 @@ public function searchPage() { * The username and password will be * combined and base64 encoded as per the application. */ - $federated_search_app_config['userpass'] = base64_encode($config->get('index.username') . ':' . $config->get('index.password')); + $username = $config->get('index.username'); + $pass = $config->get('index.password'); + $federated_search_app_config['userpass'] = $username && $pass ? base64_encode($config->get('index.username') . ':' . $config->get('index.password')) : ''; // Validate that there is still a site name property set for this index. $site_name_property = $index_config->get('field_settings.site_name.configuration.site_name'); @@ -52,6 +54,36 @@ public function searchPage() { $config->set('facet.site_name.set_default', 0); } + // Create an index property field map array to determine which fields + // exist on the index and should be hidden in the app UI. + $search_fields = [ + "sm_site_name" => [ + "property" => $site_name_property, + "is_hidden" => $config->get('facet.site_name.is_hidden'), + ], + "ss_federated_type" => [ + "property" => $config->get('index.has_federated_type_property'), + "is_hidden" => $config->get('facet.federated_type.is_hidden'), + ], + "ds_federated_date" => [ + "property" => $config->get('index.has_federated_date_property'), + "is_hidden" => $config->get('filter.federated_date.is_hidden'), + ], + "sm_federated_terms" => [ + "property" => $config->get('index.has_federated_terms_property'), + "is_hidden" => $config->get('facet.federated_terms.is_hidden'), + ], + ]; + + // Set hiddenSearchFields to an array of keys of those $search_fields items + // which both exist as an index property and are set to be hidden. + + // OPTIONAL: Machine name of those search fields whose facets/filter and + // current values should be hidden in UI. + $federated_search_app_config['hiddenSearchFields'] = array_keys(array_filter($search_fields, function ($value) { + return $value['property'] && $value['is_hidden']; + })); + // OPTIONAL: The text to display when the app loads with no search term. if ($search_prompt = $config->get('content.search_prompt')) { $federated_search_app_config['searchPrompt'] = $search_prompt; @@ -82,6 +114,38 @@ public function searchPage() { $federated_search_app_config['pageTitle'] = $page_title; } + $federated_search_app_config['autocomplete'] = FALSE; + if ($autocomplete_is_enabled = $config->get('autocomplete.isEnabled')) { + // REQUIRED: Autocomplete endpoint, defaults to main search url + if ($autocomplete_url = $config->get('autocomplete.url')) { + $federated_search_app_config['autocomplete']['url'] = $autocomplete_url; + } + // OPTIONAL: defaults to false, whether or not to append wildcard to query term + if ($autocomplete_append_wildcard = $config->get('autocomplete.appendWildcard')) { + $federated_search_app_config['autocomplete']['appendWildcard'] = $autocomplete_append_wildcard; + } + // OPTIONAL: defaults to 5, max number of autocomplete results to return + if ($autocomplete_suggestion_rows = $config->get('autocomplete.suggestionRows')) { + $federated_search_app_config['autocomplete']['suggestionRows'] = $autocomplete_suggestion_rows; + } + // OPTIONAL: defaults to 2, number of characters *after* which autocomplete results should appear + if ($autocomplete_num_chars = $config->get('autocomplete.numChars')) { + $federated_search_app_config['autocomplete']['numChars'] = $autocomplete_num_chars; + } + // REQUIRED: show search-as-you-type results ('result', default) or search term ('term') suggestions + if ($autocomplete_mode = $config->get('autocomplete.mode')) { + $federated_search_app_config['autocomplete']['mode'] = $autocomplete_mode; + // OPTIONAL: default set, title to render above autocomplete results + if ($autocomplete_mode_title_text = $config->get('autocomplete.' . $autocomplete_mode . '.titleText')) { + $federated_search_app_config['autocomplete'][$autocomplete_mode]['titleText'] = $autocomplete_mode_title_text; + } + // OPTIONAL: defaults to false, whether or not to hide the keyboard usage directions text + if ($autocomplete_mode_hide_directions = $config->get('autocomplete.' . $autocomplete_mode . '.hideDirectionsText')) { + $federated_search_app_config['autocomplete'][$autocomplete_mode]['showDirectionsText'] = FALSE; + } + } + } + $element = [ '#theme' => 'search_app', '#federated_search_app_config' => $federated_search_app_config, diff --git a/src/Form/FederatedSearchPageBlockForm.php b/src/Form/FederatedSearchPageBlockForm.php index c687a006f..4bd25617a 100644 --- a/src/Form/FederatedSearchPageBlockForm.php +++ b/src/Form/FederatedSearchPageBlockForm.php @@ -42,6 +42,7 @@ public function buildForm(array $form, FormStateInterface $form_state) { '#attributes' => [ 'title' => $this->t('Enter the terms you wish to search for.'), 'placeholder' => 'Search', + 'autocomplete' => "off", // refers to html attribute, not our custom autocomplete. ], '#provider' => 'search_api_federated_solr', ]; diff --git a/src/Form/SearchApiFederatedSolrSearchAppSettingsForm.php b/src/Form/SearchApiFederatedSolrSearchAppSettingsForm.php index e99a83fb1..e531804cf 100644 --- a/src/Form/SearchApiFederatedSolrSearchAppSettingsForm.php +++ b/src/Form/SearchApiFederatedSolrSearchAppSettingsForm.php @@ -48,15 +48,71 @@ public function buildForm(array $form, FormStateInterface $form_state) { $index_options[$search_api_index->id()] = $search_api_index->label(); } - $form['path'] = [ + /** + * Set index config values to indicate which properties + */ + $site_name_property_value = ''; + $site_name_property_default_value = ''; + // Validates whether or not the search app's chosen index has a site_name, + // federated_date, federated_type, and federated_terms properties + // and alters the search app settings form accordingly. + if ($search_index_id = $config->get('index.id')) { + $index_config = \Drupal::config('search_api.index.' . $search_index_id); + // Determine if the index has a site name property, which could have been + // added / removed since last form load. + $site_name_property = $index_config->get('field_settings.site_name.configuration.site_name'); + $config->set('index.has_site_name_property', $site_name_property ? TRUE : FALSE); + + // If the index does have a site name property, ensure the hidden form field reflects that. + if ($site_name_property) { + $site_name_property_value = 'true'; + $site_name_property_default_value = 'true'; + } + else { + // Assume properties are not present, set defaults. + $site_name_property_value = ''; + $site_name_property_default_value = FALSE; + $config->set('facet.site_name.set_default', FALSE); + } + + // Save config indicating which index field properties that + // correspond to facets and filters are present on the index. + $type_property = $index_config->get('field_settings.federated_type'); + $config->set('index.has_federated_type_property', $type_property ? TRUE : FALSE); + + $date_property = $index_config->get('field_settings.federated_date'); + $config->set('index.has_federated_date_property', $date_property ? TRUE : FALSE); + + $terms_property = $index_config->get('field_settings.federated_terms'); + $config->set('index.has_federated_terms_property', $terms_property ? TRUE : FALSE); + + $config->save(); + } + + /** + * Basic set up: + * - search results page path + * - search results page title + * - autocomplete enable triggers display of autocopmlete config fieldset + * - serach index to use as datasource, + * - basic auth credentials for index + */ + + $form['setup'] = [ + '#type' => 'details', + '#title' => 'Search Results Page > Set Up', + '#open' => TRUE, + ]; + + $form['setup']['path'] = [ '#type' => 'textfield', - '#title' => $this->t('Search app path'), + '#title' => $this->t('Search results page path'), '#default_value' => $config->get('path'), '#description' => $this ->t('The path for the search app (Default: "/search-app").'), ]; - $form['page_title'] = [ + $form['setup']['page_title'] = [ '#type' => 'textfield', '#title' => $this->t('Search results page title'), '#default_value' => $config->get('page_title'), @@ -64,10 +120,10 @@ public function buildForm(array $form, FormStateInterface $form_state) { ->t('The title that will live in the header tag of the search results page (leave empty to hide completely).'), ]; - $form['search_index'] = [ + $form['setup']['search_index'] = [ '#type' => 'select', '#title' => $this->t('Search API index'), - '#description' => $this->t('Defines which search_api index and server the search app should use.'), + '#description' => $this->t('Defines which search_api index and server the search app should use as a datasource.'), '#options' => $index_options, '#default_value' => $config->get('index.id'), '#required' => TRUE, @@ -78,56 +134,49 @@ public function buildForm(array $form, FormStateInterface $form_state) { ], ]; - $form['site_name_property'] = [ - '#type' => 'hidden', - '#attributes' => [ - 'id' => ['site-name-property'], - ], - '#value' => $config->get('index.has_site_name_property') ? 'true' : '', - ]; - - $form['search_index_basic_auth'] = [ + $form['setup']['search_index_basic_auth'] = [ '#type' => 'fieldset', '#title' => $this->t('Search Index Basic Authentication'), '#description' => $this->t('If your Solr server is protected by basic HTTP authentication, enter the login data here. This will be accessible to the client in an obscured, but non-secure method. It should, therefore, only provide read access to the index AND be different from that provided when configuring the server in Search API. The Password field is intentionally not obscured to emphasize this distinction.'), ]; - $form['search_index_basic_auth']['username'] = [ + $form['setup']['search_index_basic_auth']['username'] = [ '#type' => 'textfield', '#title' => $this->t('Username'), '#default_value' => $config->get('index.username'), ]; - $form['search_index_basic_auth']['password'] = [ + $form['setup']['search_index_basic_auth']['password'] = [ '#type' => 'textfield', '#title' => $this->t('Password'), '#default_value' => $config->get('index.password'), ]; - $form['set_search_site'] = [ - '#type' => 'checkbox', - '#title' => $this->t('Set the "Site name" facet to this site'), - '#default_value' => $config->get('facet.site_name.set_default'), - '#description' => $this - ->t('When checked, only search results from this site will be shown, by default, until this site\'s checkbox is unchecked in the search app\'s "Site name" facet.'), - '#states' => [ - 'visible' => [ - ':input[name="site_name_property"]' => [ - 'value' => "true", - ], - ], - ], + /** + * Search results page options: + * - show empty search results (i.e. filterable listing page), + * - customize "no results" text + * - custom search prompt + * - renders in result area when show empty results no enabled and no query value + * - max number of search results per page + * - max number of "numbered" pagination buttons to show + */ + + $form['search_page_options'] = [ + '#type' => 'details', + '#title' => 'Search Results Page > Options', + '#open' => FALSE, ]; - $form['show_empty_search_results'] = [ + $form['search_page_options']['show_empty_search_results'] = [ '#type' => 'checkbox', - '#title' => $this->t('Show results for empty search'), + '#title' => '' . $this->t('Show results for empty search') . '', '#default_value' => $config->get('content.show_empty_search_results'), '#description' => $this ->t(' When checked, this option allows users to see all results when no search term is entered. By default, empty searches are disabled and yield no results.'), ]; - $form['no_results_text'] = [ + $form['search_page_options']['no_results_text'] = [ '#type' => 'textfield', '#title' => $this->t('No results text'), '#default_value' => $config->get('content.no_results'), @@ -135,7 +184,7 @@ public function buildForm(array $form, FormStateInterface $form_state) { ->t('This text is shown when a query returns no results. (Default: "Your search yielded no results.")'), ]; - $form['search_prompt_text'] = [ + $form['search_page_options']['search_prompt_text'] = [ '#type' => 'textfield', '#title' => $this->t('Search prompt text'), '#default_value' => $config->get('content.search_prompt'), @@ -143,7 +192,7 @@ public function buildForm(array $form, FormStateInterface $form_state) { ->t('This text is shown when no query term has been entered. (Default: "Please enter a search term.")'), ]; - $form['rows'] = [ + $form['search_page_options']['rows'] = [ '#type' => 'number', '#title' => $this->t('Number of search results per page'), '#default_value' => $config->get('results.rows'), @@ -151,7 +200,7 @@ public function buildForm(array $form, FormStateInterface $form_state) { ->t('The max number of results to render per search results page. (Default: 20)'), ]; - $form['page_buttons'] = [ + $form['search_page_options']['page_buttons'] = [ '#type' => 'number', '#title' => $this->t('Number of pagination buttons'), '#default_value' => $config->get('pagination.buttons'), @@ -159,6 +208,297 @@ public function buildForm(array $form, FormStateInterface $form_state) { ->t('The max number of numbered pagination buttons to show at a given time. (Default: 5)'), ]; + /** + * Settings and values for search facets and filters: + * - set the site name facet to the current site name property + */ + + $form['search_form_values'] = [ + '#type' => 'details', + '#title' => 'Search Results Page > Facets & Filters', + '#open' => FALSE, + ]; + + /** + * Set hidden form element value based on presence of field properties on + * the selected index. This value will determine which inputs are + * visible for setting default facet/filter values and hiding in the UI. + */ + + $form['search_form_values']['site_name_property'] = [ + '#type' => 'hidden', + '#attributes' => [ + 'id' => ['site-name-property'], + ], + '#value' => $site_name_property_value, + '#default_value' => $site_name_property_default_value, + ]; + + $form['search_form_values']['date_property'] = [ + '#type' => 'hidden', + '#attributes' => [ + 'id' => ['date-property'], + ], + '#value' => $config->get('index.has_federated_date_property') ? 'true' : '', + ]; + + $form['search_form_values']['type_property'] = [ + '#type' => 'hidden', + '#attributes' => [ + 'id' => ['type-property'], + ], + '#value' => $config->get('index.has_federated_type_property') ? 'true' : '', + ]; + + $form['search_form_values']['terms_property'] = [ + '#type' => 'hidden', + '#attributes' => [ + 'id' => ['terms-property'], + ], + '#value' => $config->get('index.has_federated_terms_property') ? 'true' : '', + ]; + + /** + * Enable setting of default values for available facets / filter. + * As of now, this includes Site Name only. + */ + + $form['search_form_values']['defaults'] = [ + '#type' => 'fieldset', + '#title' => 'Set facet / filter default values' + ]; + + $form['search_form_values']['defaults']['set_search_site'] = [ + '#type' => 'checkbox', + '#title' => $this->t('Set the "Site name" facet to this site'), + '#default_value' => $config->get('facet.site_name.set_default'), + '#description' => $this + ->t('When checked, only search results from this site will be shown, by default, until this site\'s checkbox is unchecked in the search app\'s "Site name" facet.'), + '#states' => [ + 'visible' => [ + ':input[name="site_name_property"]' => [ + 'value' => "true", + ], + ], + ], + ]; + + /** + * Enable hiding available facets / filters. + * These form elements will only be visible if their corresopnding + * property exists on the index. + */ + $form['search_form_values']['hidden'] = [ + '#type' => 'fieldset', + '#title' => $this->t('Hide facets / filters from sidebar'), + '#description' => $this->t('The checked facets / filters will be hidden from the search app.'), + ]; + + $form['search_form_values']['hidden']['hide_site_name'] = [ + '#type' => 'checkbox', + '#title' => $this->t('Site name facet'), + '#default_value' => $config->get('facet.site_name.is_hidden'), + '#description' => $this + ->t('When checked, the ability to which sites should be included in the results will be hidden.'), + '#states' => [ + 'visible' => [ + ':input[name="site_name_property"]' => [ + 'value' => "true", + ], + ], + ], + ]; + + $form['search_form_values']['hidden']['hide_type'] = [ + '#type' => 'checkbox', + '#title' => $this->t('Type facet'), + '#default_value' => $config->get('facet.federated_type.is_hidden'), + '#description' => $this + ->t('When checked, the ability to select those types (i.e. bundles) which should have results returned will be hidden.'), + '#states' => [ + 'visible' => [ + ':input[name="type_property"]' => [ + 'value' => "true", + ], + ], + ], + ]; + + $form['search_form_values']['hidden']['hide_date'] = [ + '#type' => 'checkbox', + '#title' => $this->t('Date filter'), + '#default_value' => $config->get('filter.federated_date.is_hidden'), + '#description' => $this + ->t('When checked, the ability to filter results by date will be hidden.'), + '#states' => [ + 'visible' => [ + ':input[name="date_property"]' => [ + 'value' => "true", + ], + ], + ], + ]; + + $form['search_form_values']['hidden']['hide_terms'] = [ + '#type' => 'checkbox', + '#title' => $this->t('Terms facet'), + '#default_value' => $config->get('facet.federated_terms.is_hidden'), + '#description' => $this + ->t('When checked, the ability to select those terms which should have results returned will be hidden.'), + '#states' => [ + 'visible' => [ + ':input[name="terms_property"]' => [ + 'value' => "true", + ], + ], + ], + ]; + + /** + * Autocomplete settings: + * - endpoint URL + * - use wildcard to support partial terms + * - customize number of autocomplete results + * - number of characters after which autocomplete query should be executed + * - autocomplete results mode (search results, terms) + * - title for autocomplete results + * - show/hide autocomplete keyboard directions + */ + + $form['autocomplete'] = [ + '#type' => 'details', + '#title' => $this->t('Search Results Page > Search Form > Autocomplete'), + '#description' => $this->t('These options apply to the autocomplete functionality on the search for which appears above the search results on the search results page. Configure your placed Federated Search Page Form block to add autocomplete to that form.'), + '#open' => $config->get('autocomplete.isEnabled'), + ]; + + $form['autocomplete']['autocomplete_is_enabled'] = [ + '#type' => 'checkbox', + '#title' => '' . $this->t('Enable autocomplete for the search results page search form') . '', + '#default_value' => $config->get('autocomplete.isEnabled'), + '#description' => $this + ->t('Check this box to enable autocomplete on the search results page search form and to expose more configuration options below.'), + '#attributes' => [ + 'data-autocomplete-enabler' => TRUE, + ], + ]; + + $form['autocomplete']['autocomplete_is_append_wildcard'] = [ + '#type' => 'checkbox', + '#title' => '' . $this->t('Append a wildcard \'*\' to support partial text search') . '', + '#default_value' => $config->get('autocomplete.appendWildcard'), + '#description' => $this + ->t('Check this box to append a wildcard * to the end of the autocomplete query term (i.e. "car" becomes "car+car*"). This option is recommended if your solr config does not add a field(s) with NGram Tokenizers to your index or if your autocomplete Request Handler is not configured to search those fields.'), + '#states' => [ + 'visible' => [ + ':input[data-autocomplete-enabler]' => [ + 'checked' => TRUE, + ], + ], + ], + ]; + + $form['autocomplete']['autocomplete_url'] = [ + '#type' => 'url', + '#title' => $this->t('Solr Endpoint URL'), + '#default_value' => $config->get('autocomplete.url'), + '#maxlength' => 2048, + '#size' => 50, + '#description' => $this + ->t('The URL where requests for autocomplete queries should be made. (Default: the url of the select Request Handler on the server of the selected Search API index.)