From aa8d093a13a40f6304b107b582e4e2356e4859f9 Mon Sep 17 00:00:00 2001 From: Christopher Timm Date: Fri, 28 Jan 2022 14:57:47 +0100 Subject: [PATCH 1/5] Add jquery.tokeninput to use tokens as search entities --- Classes/Ajax/Autocomplete.php | 130 +++ Classes/Ajax/EidSettings.php | 5 + Classes/Ajax/GetEntity.php | 57 ++ Configuration/RequestMiddlewares.php | 18 + Configuration/TypoScript/setup.txt | 13 + README.md | 18 +- .../Private/Partials/Form/Fields/Text.html | 5 +- .../Private/Partials/Page/JavaScript.html | 4 +- Resources/Public/CSS/find.css | 4 + Resources/Public/CSS/token-input.css | 146 +++ Resources/Public/JavaScript/autocomplete.js | 81 ++ .../Public/JavaScript/jquery.tokeninput.js | 906 ++++++++++++++++++ 12 files changed, 1379 insertions(+), 8 deletions(-) create mode 100644 Classes/Ajax/Autocomplete.php create mode 100644 Classes/Ajax/EidSettings.php create mode 100644 Classes/Ajax/GetEntity.php create mode 100644 Configuration/RequestMiddlewares.php create mode 100644 Resources/Public/CSS/token-input.css create mode 100644 Resources/Public/JavaScript/autocomplete.js create mode 100644 Resources/Public/JavaScript/jquery.tokeninput.js diff --git a/Classes/Ajax/Autocomplete.php b/Classes/Ajax/Autocomplete.php new file mode 100644 index 00000000..80502c50 --- /dev/null +++ b/Classes/Ajax/Autocomplete.php @@ -0,0 +1,130 @@ +handle($request); + if (!isset($request->getQueryParams()['q']) && + (!isset($request->getQueryParams()['autocomplete']) || !isset($request->getQueryParams()['entity'])) + ) { + return $response; + } + + include_once 'EidSettings.php'; + + // Configuration options + $solrSuggestUrl = $HOST . $CORE . '/suggest'; + + // Autocomplete dictionary + $solrAutocompleteDictionary = 'autocompleteSuggester'; + + // Entity dictionary + $solrEntityDictionary = 'entitySuggester'; + + // Entity replacement +// $entityReplacement = '(entity_ids:%s OR entity_ids_from:%s OR entity_ids_to:%s)'; + if (isset($request->getQueryParams()['replacement'])) { + $entityReplacement = $request->getQueryParams()['replacement']; + } else { + $entityReplacement = 'id:%s'; + } + + // Array of suggestions + $suggests = []; + + // Get query string + $query = $request->getQueryParams()['q']; + $entity = $request->getQueryParams()['entity']; + $autocomplete = $request->getQueryParams()['autocomplete']; + + $dictionaryUrlParams = ''; + + if ($entity) { + $dictionaryUrlParams .= '&suggest.dictionary=' . $solrEntityDictionary; + } + if ($autocomplete) { + $dictionaryUrlParams .= '&suggest.dictionary=' . $solrAutocompleteDictionary; + } + + if (!$entity && !$autocomplete) { + return $response; + } + + + // Get Solr suggestions + $response = file_get_contents( + $solrSuggestUrl . '?suggest=true' . $dictionaryUrlParams . '&suggest.q=' . urlencode($query), + FALSE, + stream_context_create([ + 'http' => [ + 'method' => 'GET', + 'follow_location' => 0, + 'timeout' => 1.0 + ] + ]) + ); + + // Parse JSON response + if ($response !== FALSE) { + $json = json_decode($response, TRUE); + + if ($autocomplete) { + // get autocomplete + foreach ($json['suggest'][$solrAutocompleteDictionary][$query]['suggestions'] as $suggestion) { + list ($id, $normalized) = explode('␝', $suggestion['payload']); + $suggests[] = [ + 'id' => htmlspecialchars($suggestion['term']), + 'term' => htmlspecialchars($suggestion['term']), + 'normalized' => htmlspecialchars($suggestion['term']), + 'autocomplete' => '1' + ]; + } + } + + if ($entity && $autocomplete) { + // Add break between autocomplete and entity list + $suggests[] = [ + 'id' => 'br' + ]; + } + + if ($entity) { + $idDeduping = []; + + // get entities + foreach ($json['suggest'][$solrEntityDictionary][$query]['suggestions'] as $suggestion) { + list ($id, $normalized) = explode('␝', $suggestion['payload']); + if (!in_array($id, $idDeduping)) { + if (empty($normalized)) { + $normalized = $suggestion['term']; + } + $suggests[] = [ + 'id' => str_replace('%s', $id, $entityReplacement), + 'term' => htmlspecialchars($suggestion['term']), + 'normalized' => htmlspecialchars($normalized), + 'autocomplete' => '0' + ]; + $idDeduping[] = $id; + } + } + } + } + + // Return results + if (!empty($suggests)) { + // Return result + return new JsonResponse($suggests); + } + } +} diff --git a/Classes/Ajax/EidSettings.php b/Classes/Ajax/EidSettings.php new file mode 100644 index 00000000..4d1d22a7 --- /dev/null +++ b/Classes/Ajax/EidSettings.php @@ -0,0 +1,5 @@ +handle($request); + if (!isset($request->getQueryParams()['q'], $request->getQueryParams()['getEntity'])) { + return $response; + } + + include_once 'EidSettings.php'; + + // Configuration options + $solr_select_url = $HOST . $CORE . '/select'; + + // Array of entity facts + $entity = []; + + // Get query string + $query = $request->getQueryParams()['q']; + + // Get Solr record + $response = file_get_contents( + $solr_select_url . '?q=' . urlencode('id:(' . $query . ')') . '&rows=1', + FALSE, + stream_context_create([ + 'http' => [ + 'method' => 'GET', + 'follow_location' => 0, + 'timeout' => 1.0 + ] + ]) + ); + + // Parse JSON response + if ($response !== FALSE) { + $json = json_decode($response, TRUE); + $entity = $json['response']['docs'][0]; + } + + // Return result + return new JsonResponse($entity); + } + +} diff --git a/Configuration/RequestMiddlewares.php b/Configuration/RequestMiddlewares.php new file mode 100644 index 00000000..9df27089 --- /dev/null +++ b/Configuration/RequestMiddlewares.php @@ -0,0 +1,18 @@ + [ + 'Subugoe/Find/ajax/getentity' => [ + 'target' => \Subugoe\Find\Ajax\GetEntity::class, + 'after' => [ + 'typo3/cms-frontend/prepare-tsfe-rendering' + ], + ], + 'Subugoe/Find/ajax/autocomplete' => [ + 'target' => \Subugoe\Find\Ajax\Autocomplete::class, + 'after' => [ + 'typo3/cms-frontend/prepare-tsfe-rendering' + ], + ], + ], +]; diff --git a/Configuration/TypoScript/setup.txt b/Configuration/TypoScript/setup.txt index d0785a09..0deb3bf2 100644 --- a/Configuration/TypoScript/setup.txt +++ b/Configuration/TypoScript/setup.txt @@ -23,6 +23,16 @@ plugin.tx_find { type = Text # autocomplete = 1 # autocompleteDictionary = + # The following configuration is for a new autocomplete/entity function + # noescape = 2 + # replaceAfterEscape { + # 1 { + # id\: = id: + # } + # } + # entityAutocomplete = 1 + # entity = 1 + # entityReplacement = id:%s } #10 { @@ -225,7 +235,10 @@ plugin.tx_find { CSSPaths.10 = {$plugin.tx_find.settings.CSSPath} CSSPaths.20 = EXT:find/Resources/Public/CSS/fontello/css/fontello.css + CSSPaths.30 = EXT:find/Resources/Public/CSS/token-input.css JSPaths.10 = {$plugin.tx_find.settings.JSPath} + JSPaths.20 = EXT:find/Resources/Public/JavaScript/jquery.tokeninput.js + JSPaths.30 = EXT:find/Resources/Public/JavaScript/autocomplete.js languageRootPath = {$plugin.tx_find.settings.languageRootPath} } diff --git a/README.md b/README.md index 7d5dd8b5..e9da5a00 100644 --- a/README.md +++ b/README.md @@ -259,18 +259,21 @@ The Text field can be the simplest field available. It also allows advanced behaviour by adding autocomplete or a checkbox to select an alternate query style. -- `queryAlternate`: an array of alternative queries that can be +- `queryAlternate`: an array of alternative queries that can be configured for the Text type; it creates a checkbox next to the input field which toggles between the provided `query` and the first `queryAlternate` -- `autocomplete` \[0\]: if true, a field of Text type will be hooked +- `autocomplete` \[0\]: if true, a field of Text type will be hooked up for autocompletion using Solr suggest query -- `autocompleteDictionary`: name of the dictionary the Solr suggest +- `autocompleteDictionary`: name of the dictionary the Solr suggest query should use -- `default`: default values to use in the query if no value is +- `default`: default values to use in the query if no value is provided by the user (yet); may be a single value string (e.g. for the default state of checkboxes) or an array (especially useful for range queries) +- `entityAutocomplete`[0]: if true, activates the autocomplete +- `entity`[0]: if true, activates the entity search +- `entityReplacement`: the string that should be replaced for an entity (e.g. id:%s) Examples: @@ -293,6 +296,13 @@ plugin.tx_find.settings.queryFields { autocomplete = 1 autocompleteDictionary = name } + 13 { + id = entity + type = Text + entityAutocomplete = 1 + entity = 1 + entityReplacement = id:%s + } } ``` diff --git a/Resources/Private/Partials/Form/Fields/Text.html b/Resources/Private/Partials/Form/Fields/Text.html index 2bc198df..7c68c89f 100644 --- a/Resources/Private/Partials/Form/Fields/Text.html +++ b/Resources/Private/Partials/Form/Fields/Text.html @@ -12,7 +12,7 @@ - \ No newline at end of file + diff --git a/Resources/Private/Partials/Page/JavaScript.html b/Resources/Private/Partials/Page/JavaScript.html index 974b922e..a1e4e0c0 100644 --- a/Resources/Private/Partials/Page/JavaScript.html +++ b/Resources/Private/Partials/Page/JavaScript.html @@ -4,6 +4,6 @@ Adds the JavaScript files configured in the CSSPaths settings to the page’s head. - - + + diff --git a/Resources/Public/CSS/find.css b/Resources/Public/CSS/find.css index 05a3cefe..0af1f587 100644 --- a/Resources/Public/CSS/find.css +++ b/Resources/Public/CSS/find.css @@ -352,3 +352,7 @@ position: absolute; background: #fff; } + +.token-input-dropdown li:hover { + background-color: #b9b9b9; +} diff --git a/Resources/Public/CSS/token-input.css b/Resources/Public/CSS/token-input.css new file mode 100644 index 00000000..3af4a8a9 --- /dev/null +++ b/Resources/Public/CSS/token-input.css @@ -0,0 +1,146 @@ +/* Example tokeninput style #1: Token vertical list*/ +ul.token-input-list { + overflow: hidden; + height: auto !important; + cursor: text; + z-index: 999; + margin: 0; + padding: 0 !important; + list-style-type: none; + text-align: left !important; + display: block; +} + +ul.token-input-list li { + list-style-type: none; +} + +li.token-input-token { + vertical-align: middle; + display: inline-block !important; + margin: 5px 0 5px 5px; + padding: 3px 5px 0 5px !important; + background: #dadada; + border-radius: 5px; + color: #000; + border: 1px solid #999; + text-decoration: none; + transition: all 300ms ease-out; +} + +li.token-input-token p { + float: left; + padding: 0; + margin: 0; +} + +li.token-input-token span { + float: right; + color: #000; + cursor: pointer; + font-weight: bold; + padding-left: 5px; + line-height: 18px; +} + +li.token-input-token span:hover { + color: #A31A21; +} + +li.token-input-input-token { + display: block !important; + padding: 0; +} + +li.token-input-input-token input { + display: block; + width: 100% !important; + border-top: 1px solid #aeaeae; +} + +li.token-input-selected-token { + background-color: #08844e; + color: #fff; +} + +li.token-input-selected-token span { + color: #bbb; +} + +div.token-input-dropdown { + position: absolute; + /*width: 400px;*/ + width: calc(70% - 180px); + background-color: #fff; + overflow: hidden; + border-left: 1px solid #ccc; + border-right: 1px solid #ccc; + border-bottom: 1px solid #ccc; + cursor: default; + font-size: 12px; + font-family: Verdana; + z-index: 9; +} + +div.token-input-dropdown p { + margin: 0; + padding: 5px; + font-weight: bold; + color: #777; +} + +div.token-input-dropdown ul { + margin: 0; + padding: 0; +} + +div.token-input-dropdown ul li { + background-color: #fff; + padding: 3px; + list-style-type: none; +} + +div.token-input-dropdown ul li.token-input-dropdown-item { + background-color: #fafafa; +} + +div.token-input-dropdown ul li.token-input-dropdown-item2 { + background-color: #fff; +} + +div.token-input-dropdown ul li em { + font-weight: bold; + font-style: normal; +} + +div.token-input-dropdown ul li.token-input-selected-dropdown-item { + /*background-color: #dadada;*/ + background-color: #dadada; +} + +div.token-input-dropdown ul li.normdata-autocomplete { + /*background-color: #dadada;*/ + background-color: #dadada; +} + +div.token-input-dropdown ul li.normdata-autocomplete:hover { + /*background-color: #dadada;*/ + background-color: #b4b4b4; +} + +@media only screen and (max-width: 1000px) { + div.token-input-dropdown { + position: absolute; + /*width: 400px;*/ + width: calc(80% - 180px); + } +} + +@media only screen and (max-width: 650px) { + div.token-input-dropdown { + position: absolute; + /*width: 400px;*/ + width: calc(90% - 20px); + } +} + diff --git a/Resources/Public/JavaScript/autocomplete.js b/Resources/Public/JavaScript/autocomplete.js new file mode 100644 index 00000000..f51e3f09 --- /dev/null +++ b/Resources/Public/JavaScript/autocomplete.js @@ -0,0 +1,81 @@ + + +$( document ).ready(function() { + activateNormdataAutocomplete(); +}); + +function activateNormdataAutocomplete() { + $.getScript( window.location.origin + '/typo3conf/ext/find/Resources/Public/JavaScript/jquery.tokeninput.js'). + + done(function( script, textStatus ) { + + var inputTextToken = $('[id*=entity-input-]'); + inputTextToken.each(function() { + var entity = $(this).data('entity'), + entityAutocomplete = $(this).data('entityautocomplete'), + entityReplacement = $(this).data('entityreplacement') + urlParams = ''; + if (entity && entityAutocomplete) { + urlParams = 'entity=1&autocomplete=1'; + if (entityReplacement) { + urlParams += '&entityReplacement=' + entityReplacement; + } + } else if (entity) { + urlParams = 'entity=1'; + if (entityReplacement) { + urlParams += '&entityReplacement=' + entityReplacement; + } + } else if (entityAutocomplete) { + urlParams = 'autocomplete=1'; + } + // .attr('id', 'find-input') + $(this).tokenInput('/?' + urlParams, { + propertyToSearch: 'term', + resultsFormatter: function(item){ + if (item.autocomplete == '1') { + var output = '
  • ' + '
    ' + item.normalized + '
    '; + } else { + var output = '
  • ' + '
    ' + item.normalized + '
    '; + } + + output += '
  • '; + return output; + }, + tokenFormatter: function(item) { + return '
  • ' + item.normalized + '

  • ' + } + }); + + var url = window.location.origin + '/?getEntity=1&q='; + // original input + var value = $(this).val(); + + if (value != "") { + // new token input + $('#token-input-find-input').val(value); + } + + // get entity for tokenizer + var regEx = new RegExp(entityReplacement.replaceAll('%s', '(\\w*)'), 'g') + // var regEx = new RegExp(/id:(\w*)/g); + var valueWithoutToken = value; + + do { + match = regEx.exec(value); + if (match) { + valueWithoutToken = valueWithoutToken.replace(match[0], ''); + valueWithoutToken = valueWithoutToken.replace(',', ''); + $.getJSON(url + match[1], function(result) { + + var entityQuery = entityReplacement.replaceAll('%s', result.id); + + inputTextToken.tokenInput('add', {id: entityQuery, term: "", normalized: result.title}); + $('#token-input-find-input').val(valueWithoutToken); + }); + } + } while (match); + + }); + + }); +} diff --git a/Resources/Public/JavaScript/jquery.tokeninput.js b/Resources/Public/JavaScript/jquery.tokeninput.js new file mode 100644 index 00000000..1c224831 --- /dev/null +++ b/Resources/Public/JavaScript/jquery.tokeninput.js @@ -0,0 +1,906 @@ +/* + * jQuery Plugin: Tokenizing Autocomplete Text Entry + * Version 1.6.0 + * + * Copyright (c) 2009 James Smith (http://loopj.com) + * Licensed jointly under the GPL and MIT licenses, + * choose which one suits your project best! + * + */ + +(function ($) { +// Default settings + var DEFAULT_SETTINGS = { + // Search settings + method: "GET", + contentType: "json", + queryParam: "q", + searchDelay: 300, + minChars: 1, + propertyToSearch: "name", + jsonContainer: null, + + // Display settings + hintText: "", + noResultsText: "No results", + searchingText: "", + deleteText: "×", + animateDropdown: true, + + // Tokenization settings + tokenLimit: null, + tokenDelimiter: " ", + preventDuplicates: false, + + // Output settings + tokenValue: "id", + + // Prepopulation settings + prePopulate: null, + processPrePopulate: false, + + // Manipulation settings + idPrefix: "token-input-", + + // Formatters + resultsFormatter: function(item){ return "
  • " + item[this.propertyToSearch]+ "
  • " }, + tokenFormatter: function(item) { return "
  • " + item[this.propertyToSearch] + "

  • " }, + + // Callbacks + onResult: null, + onAdd: null, + onDelete: null, + onReady: null + }; + +// Default classes to use when theming + var DEFAULT_CLASSES = { + tokenList: "token-input-list", + token: "token-input-token", + tokenDelete: "token-input-delete-token", + selectedToken: "token-input-selected-token", + highlightedToken: "token-input-highlighted-token", + dropdown: "token-input-dropdown", + dropdownItem: "token-input-dropdown-item", + dropdownItem2: "token-input-dropdown-item2", + selectedDropdownItem: "token-input-selected-dropdown-item", + inputToken: "token-input-input-token" + }; + +// Input box position "enum" + var POSITION = { + BEFORE: 0, + AFTER: 1, + END: 2 + }; + +// Keys "enum" + var KEY = { + BACKSPACE: 8, + TAB: 9, + ENTER: 13, + ESCAPE: 27, + SPACE: 32, + PAGE_UP: 33, + PAGE_DOWN: 34, + END: 35, + HOME: 36, + LEFT: 37, + UP: 38, + RIGHT: 39, + DOWN: 40, + NUMPAD_ENTER: 108, + COMMA: 188 + }; + +// Additional public (exposed) methods + var methods = { + init: function(url_or_data_or_function, options) { + var settings = $.extend({}, DEFAULT_SETTINGS, options || {}); + + return this.each(function () { + $(this).data("tokenInputObject", new $.TokenList(this, url_or_data_or_function, settings)); + }); + }, + clear: function() { + this.data("tokenInputObject").clear(); + return this; + }, + add: function(item) { + this.data("tokenInputObject").add(item); + return this; + }, + remove: function(item) { + this.data("tokenInputObject").remove(item); + return this; + }, + get: function() { + return this.data("tokenInputObject").getTokens(); + } + } + +// Expose the .tokenInput function to jQuery as a plugin + $.fn.tokenInput = function (method) { + // Method calling and initialization logic + if(methods[method]) { + return methods[method].apply(this, Array.prototype.slice.call(arguments, 1)); + } else { + return methods.init.apply(this, arguments); + } + }; + +// TokenList class for each input + $.TokenList = function (input, url_or_data, settings) { + // + // Initialization + // + + // Configure the data source + if($.type(url_or_data) === "string" || $.type(url_or_data) === "function") { + // Set the url to query against + settings.url = url_or_data; + + // If the URL is a function, evaluate it here to do our initalization work + var url = computeURL(); + + // Make a smart guess about cross-domain if it wasn't explicitly specified + if(settings.crossDomain === undefined) { + if(url.indexOf("://") === -1) { + settings.crossDomain = false; + } else { + settings.crossDomain = (location.href.split(/\/+/g)[1] !== url.split(/\/+/g)[1]); + } + } + } else if(typeof(url_or_data) === "object") { + // Set the local data to search through + settings.local_data = url_or_data; + } + + // Build class names + if(settings.classes) { + // Use custom class names + settings.classes = $.extend({}, DEFAULT_CLASSES, settings.classes); + } else if(settings.theme) { + // Use theme-suffixed default class names + settings.classes = {}; + $.each(DEFAULT_CLASSES, function(key, value) { + settings.classes[key] = value + "-" + settings.theme; + }); + } else { + settings.classes = DEFAULT_CLASSES; + } + + + // Save the tokens + var saved_tokens = []; + + // Keep track of the number of tokens in the list + var token_count = 0; + + // Basic cache to save on db hits + var cache = new $.TokenList.Cache(); + + // Keep track of the timeout, old vals + var timeout; + var input_val; + + // Create a new text input an attach keyup events + var input_box = $("") + .css({ + outline: "none" + }) + .attr("id", settings.idPrefix + input.id) + .focus(function () { + if (settings.tokenLimit === null || settings.tokenLimit !== token_count) { + show_dropdown_hint(); + } + }) + .blur(function () { + hide_dropdown(); + // $(this).val(""); + }) + // .bind("keyup keydown blur update", resize_input) + .keydown(function (event) { + var previous_token; + var next_token; + + switch(event.keyCode) { + // case KEY.LEFT: + // case KEY.RIGHT: + case KEY.UP: + case KEY.DOWN: + if(!$(this).val()) { + previous_token = input_token.prev(); + next_token = input_token.next(); + + if((previous_token.length && previous_token.get(0) === selected_token) || (next_token.length && next_token.get(0) === selected_token)) { + // Check if there is a previous/next token and it is selected + if(event.keyCode === KEY.LEFT || event.keyCode === KEY.UP) { + deselect_token($(selected_token), POSITION.BEFORE); + } else { + deselect_token($(selected_token), POSITION.AFTER); + } + } else if((event.keyCode === KEY.LEFT || event.keyCode === KEY.UP) && previous_token.length) { + // We are moving left, select the previous token if it exists + select_token($(previous_token.get(0))); + } else if((event.keyCode === KEY.RIGHT || event.keyCode === KEY.DOWN) && next_token.length) { + // We are moving right, select the next token if it exists + select_token($(next_token.get(0))); + } + } else { + var dropdown_item = null; + + if(event.keyCode === KEY.DOWN || event.keyCode === KEY.RIGHT) { + dropdown_item = $(selected_dropdown_item).next(); + } else { + dropdown_item = $(selected_dropdown_item).prev(); + } + + if(dropdown_item.length) { + select_dropdown_item(dropdown_item); + } + + if(dropdown_item.length === 0) { + dropdown_item = $($(".autocomplete-list-li")[0]); + select_dropdown_item(dropdown_item); + } + + return false; + } + break; + + case KEY.BACKSPACE: + previous_token = input_token.prev(); + + if(!$(this).val().length) { + if(selected_token) { + delete_token($(selected_token)); + hidden_input.change(); + } else if(previous_token.length) { + select_token($(previous_token.get(0))); + } + + return false; + } else if($(this).val().length === 1) { + hide_dropdown(); + } else { + // set a timeout just long enough to let this function finish. + setTimeout(function(){do_search();}, 5); + } + break; + + // case KEY.TAB: + case KEY.ENTER: + // case KEY.NUMPAD_ENTER: + // case KEY.COMMA: + if(selected_dropdown_item) { + add_token($(selected_dropdown_item).data("tokeninput")); + hidden_input.change(); + return false; + } else { + // hidden_input.val(hidden_input.val() + " " + input_box.val()); + } + break; + + case KEY.ESCAPE: + hide_dropdown(); + return true; + + default: + if(String.fromCharCode(event.which)) { + // set a timeout just long enough to let this function finish. + setTimeout(function(){do_search();}, 5); + } + break; + } + }); + + // form + var form = $(".searchForm").submit(function (event) { + if (input_box.val() != "" && hidden_input.val() != "") { + hidden_input.val(hidden_input.val().trim() + " " + input_box.val().trim()); + } else if (input_box.val() != "") { + hidden_input.val(input_box.val().trim()); + } + }); + + // Keep a reference to the original input box + var hidden_input = $(input) + .hide() + // .val("") + .focus(function () { + input_box.focus(); + }) + .blur(function () { + input_box.blur(); + }); + + // Keep a reference to the selected token and dropdown item + var selected_token = null; + var selected_token_index = 0; + var selected_dropdown_item = null; + + // The list to store the token items in + var token_list = $("
      ") + .addClass(settings.classes.tokenList) + .click(function (event) { + var li = $(event.target).closest("li"); + if(li && li.get(0) && $.data(li.get(0), "tokeninput")) { + toggle_select_token(li); + } else { + // Deselect selected token + if(selected_token) { + deselect_token($(selected_token), POSITION.END); + } + + // Focus input box + input_box.focus(); + } + }) + .mouseover(function (event) { + var li = $(event.target).closest("li"); + if(li && selected_token !== this) { + li.addClass(settings.classes.highlightedToken); + } + }) + .mouseout(function (event) { + var li = $(event.target).closest("li"); + if(li && selected_token !== this) { + li.removeClass(settings.classes.highlightedToken); + } + }) + .insertBefore(hidden_input); + + // The token holding the input box + var input_token = $("
    • ") + .addClass(settings.classes.inputToken) + .appendTo(token_list) + .append(input_box); + + // The list to store the dropdown items in + var dropdown = $("
      ") + .addClass(settings.classes.dropdown) + .appendTo("body") + .hide(); + + // Magic element to help us resize the text input + var input_resizer = $("") + .insertAfter(input_box) + .css({ + position: "absolute", + top: -9999, + left: -9999, + width: "auto", + fontSize: input_box.css("fontSize"), + fontFamily: input_box.css("fontFamily"), + fontWeight: input_box.css("fontWeight"), + letterSpacing: input_box.css("letterSpacing"), + whiteSpace: "nowrap" + }); + + // Pre-populate list if items exist + // hidden_input.val(""); + var li_data = settings.prePopulate || hidden_input.data("pre"); + if(settings.processPrePopulate && $.isFunction(settings.onResult)) { + li_data = settings.onResult.call(hidden_input, li_data); + } + if(li_data && li_data.length) { + $.each(li_data, function (index, value) { + insert_token(value); + checkTokenLimit(); + }); + } + + // Initialization is done + if($.isFunction(settings.onReady)) { + settings.onReady.call(); + } + + // + // Public functions + // + + this.clear = function() { + token_list.children("li").each(function() { + if ($(this).children("input").length === 0) { + delete_token($(this)); + } + }); + } + + this.add = function(item) { + add_token(item); + } + + this.remove = function(item) { + token_list.children("li").each(function() { + if ($(this).children("input").length === 0) { + var currToken = $(this).data("tokeninput"); + var match = true; + for (var prop in item) { + if (item[prop] !== currToken[prop]) { + match = false; + break; + } + } + if (match) { + delete_token($(this)); + } + } + }); + } + + this.getTokens = function() { + return saved_tokens; + } + + // + // Private functions + // + + function checkTokenLimit() { + if(settings.tokenLimit !== null && token_count >= settings.tokenLimit) { + input_box.hide(); + hide_dropdown(); + return; + } + } + + function resize_input() { + if(input_val === (input_val = input_box.val())) {return;} + + // Enter new content into resizer and resize input accordingly + var escaped = input_val.replace(/&/g, '&').replace(/\s/g,' ').replace(//g, '>'); + input_resizer.html(escaped); + input_box.width(input_resizer.width() + 30); + } + + function is_printable_character(keycode) { + return ((keycode >= 48 && keycode <= 90) || // 0-1a-z + (keycode >= 96 && keycode <= 111) || // numpad 0-9 + - / * . + (keycode >= 186 && keycode <= 192) || // ; = , - . / ^ + (keycode >= 219 && keycode <= 222)); // ( \ ) ' + } + + // Inner function to a token to the list + function insert_token(item) { + var this_token = settings.tokenFormatter(item); + this_token = $(this_token) + .addClass(settings.classes.token) + .insertBefore(input_token); + + // The 'delete token' button + $("" + settings.deleteText + "") + .addClass(settings.classes.tokenDelete) + .appendTo(this_token) + .click(function () { + delete_token($(this).parent()); + hidden_input.change(); + return false; + }); + + // Store data on the token + var token_data = {"id": item.id}; + token_data[settings.propertyToSearch] = item[settings.propertyToSearch]; + $.data(this_token.get(0), "tokeninput", item); + + // Save this token for duplicate checking + saved_tokens = saved_tokens.slice(0,selected_token_index).concat([token_data]).concat(saved_tokens.slice(selected_token_index)); + selected_token_index++; + + // Update the hidden input + update_hidden_input(saved_tokens, hidden_input); + + token_count += 1; + + // Check the token limit + if(settings.tokenLimit !== null && token_count >= settings.tokenLimit) { + input_box.hide(); + hide_dropdown(); + } + + return this_token; + } + + // Add a token to the token list based on user input + function add_token (item) { + var callback = settings.onAdd; + + // See if the token already exists and select it if we don't want duplicates + if(token_count > 0 && settings.preventDuplicates) { + var found_existing_token = null; + token_list.children().each(function () { + var existing_token = $(this); + var existing_data = $.data(existing_token.get(0), "tokeninput"); + if(existing_data && existing_data.id === item.id) { + found_existing_token = existing_token; + return false; + } + }); + + if(found_existing_token) { + select_token(found_existing_token); + input_token.insertAfter(found_existing_token); + input_box.focus(); + return; + } + } + + // Insert the new tokens + if(settings.tokenLimit == null || token_count < settings.tokenLimit) { + insert_token(item); + checkTokenLimit(); + } + + // Clear input box + input_box.val(""); + + // Don't show the help dropdown, they've got the idea + hide_dropdown(); + + // Execute the onAdd callback if defined + if($.isFunction(callback)) { + callback.call(hidden_input,item); + } + } + + // Select a token in the token list + function select_token (token) { + token.addClass(settings.classes.selectedToken); + selected_token = token.get(0); + + // Hide input box + input_box.val(""); + + // Hide dropdown if it is visible (eg if we clicked to select token) + hide_dropdown(); + } + + // Deselect a token in the token list + function deselect_token (token, position) { + token.removeClass(settings.classes.selectedToken); + selected_token = null; + + if(position === POSITION.BEFORE) { + input_token.insertBefore(token); + selected_token_index--; + } else if(position === POSITION.AFTER) { + input_token.insertAfter(token); + selected_token_index++; + } else { + input_token.appendTo(token_list); + selected_token_index = token_count; + } + + // Show the input box and give it focus again + input_box.focus(); + } + + // Toggle selection of a token in the token list + function toggle_select_token(token) { + var previous_selected_token = selected_token; + + if(selected_token) { + deselect_token($(selected_token), POSITION.END); + } + + if(previous_selected_token === token.get(0)) { + deselect_token(token, POSITION.END); + } else { + select_token(token); + } + } + + // Delete a token from the token list + function delete_token (token) { + // Remove the id from the saved list + var token_data = $.data(token.get(0), "tokeninput"); + var callback = settings.onDelete; + + var index = token.prevAll().length; + if(index > selected_token_index) index--; + + // Delete the token + token.remove(); + selected_token = null; + + // Show the input box and give it focus again + input_box.focus(); + + // Remove this token from the saved list + saved_tokens = saved_tokens.slice(0,index).concat(saved_tokens.slice(index+1)); + if(index < selected_token_index) selected_token_index--; + + // Update the hidden input + update_hidden_input(saved_tokens, hidden_input); + + token_count -= 1; + + if(settings.tokenLimit !== null) { + input_box + .show() + .val("") + .focus(); + } + + // Execute the onDelete callback if defined + if($.isFunction(callback)) { + callback.call(hidden_input,token_data); + } + } + + // Update the hidden input box value + function update_hidden_input(saved_tokens, hidden_input) { + var token_values = $.map(saved_tokens, function (el) { + return el[settings.tokenValue]; + }); + hidden_input.val(token_values.join(settings.tokenDelimiter)); + + } + + // Hide and clear the results dropdown + function hide_dropdown () { + dropdown.hide().empty(); + selected_dropdown_item = null; + } + + function show_dropdown() { + dropdown + .css({ + position: "absolute", + top: $(token_list).offset().top + $(token_list).outerHeight(), + left: $(token_list).offset().left, + zindex: 999 + }) + .show(); + } + + function show_dropdown_searching () { + if(settings.searchingText) { + dropdown.html("

      "+settings.searchingText+"

      "); + show_dropdown(); + } + } + + function show_dropdown_hint () { + if(settings.hintText) { + dropdown.html("

      "+settings.hintText+"

      "); + show_dropdown(); + } + } + + // Highlight the query part of the search term + function highlight_term(value, term) { + return value.replace(new RegExp("(?![^&;]+;)(?!<[^<>]*)(" + term + ")(?![^<>]*>)(?![^&;]+;)", "gi"), "$1"); + } + + function find_value_and_highlight_term(template, value, term) { + regExValue = escapeRegExp(value); + return template.replace(new RegExp("(?![^&;]+;)(?!<[^<>]*)(" + regExValue + ")(?![^<>]*>)(?![^&;]+;)", "g"), highlight_term(value, term)); + } + + function escapeRegExp(string) { + return string.replace(/[.*+?^${}()|[\]\\]/g, '\\$&'); // $& means the whole matched string + } + + // Populate the results dropdown with some results + function populate_dropdown (query, results) { + if(results && results.length) { + dropdown.empty(); + var dropdown_ul = $("
        ") + .appendTo(dropdown) + .mouseover(function (event) { + select_dropdown_item($(event.target).closest("li")); + }) + .mousedown(function (event) { + + if($(event.target).closest("li").data("tokeninput").id == $(event.target).closest("li").data("tokeninput").term) { + input_box.val($(event.target).closest("li").data("tokeninput").id); + hide_dropdown(); + return false; + } else { + add_token($(event.target).closest("li").data("tokeninput")); + hidden_input.change(); + return false; + } + }) + .hide(); + var i = 0; + $.each(results, function(index, value) { + if (i === 0 && value.id == "br") { + return true; + } + myHeight = window.innerHeight; + maxItemPerRow = Math.round( (myHeight / 100) + 1); + i++; + if (value.id == "br") { + i = 0; + } + if (i >= maxItemPerRow) { + return true; + } + if (value.id != "br") { + var this_li = settings.resultsFormatter(value); + + this_li = find_value_and_highlight_term(this_li, value[settings.propertyToSearch], query); + + this_li = $(this_li).appendTo(dropdown_ul); + + if (index % 2) { + this_li.addClass(settings.classes.dropdownItem); + } else { + this_li.addClass(settings.classes.dropdownItem2); + } + + if (index === 0) { + // select_dropdown_item(this_li); + } + + $.data(this_li.get(0), "tokeninput", value); + } else { + this_li = '

      • '; + this_li = $(this_li).appendTo(dropdown_ul); + } + }); + + show_dropdown(); + + if(settings.animateDropdown) { + dropdown_ul.slideDown("fast"); + } else { + dropdown_ul.show(); + } + } else { + if(settings.noResultsText) { + dropdown.html("

        "+settings.noResultsText+"

        "); + show_dropdown(); + } + } + } + + // Highlight an item in the results dropdown + function select_dropdown_item (item) { + if(item) { + if(selected_dropdown_item) { + deselect_dropdown_item($(selected_dropdown_item)); + } + + item.addClass(settings.classes.selectedDropdownItem); + selected_dropdown_item = item.get(0); + } + } + + // Remove highlighting from an item in the results dropdown + function deselect_dropdown_item (item) { + item.removeClass(settings.classes.selectedDropdownItem); + selected_dropdown_item = null; + } + + // Do a search and show the "searching" dropdown if the input is longer + // than settings.minChars + function do_search() { + var query = input_box.val(); + + if(query && query.length) { + if(selected_token) { + deselect_token($(selected_token), POSITION.AFTER); + } + + if(query.length >= settings.minChars) { + show_dropdown_searching(); + clearTimeout(timeout); + + timeout = setTimeout(function(){ + run_search(query); + }, settings.searchDelay); + } else { + hide_dropdown(); + } + } + } + + // Do the actual search + function run_search(query) { + var cache_key = query + computeURL(); + var cached_results = cache.get(cache_key); + if(cached_results) { + populate_dropdown(query, cached_results); + } else { + // Are we doing an ajax search or local data search? + if(settings.url) { + var url = computeURL(); + // Extract exisiting get params + var ajax_params = {}; + ajax_params.data = {}; + if(url.indexOf("?") > -1) { + var parts = url.split("?"); + ajax_params.url = parts[0]; + + var param_array = parts[1].split("&"); + $.each(param_array, function (index, value) { + var kv = value.split("="); + ajax_params.data[kv[0]] = kv[1]; + }); + } else { + ajax_params.url = url; + } + + // Prepare the request + ajax_params.data[settings.queryParam] = query; + ajax_params.type = settings.method; + ajax_params.dataType = settings.contentType; + if(settings.crossDomain) { + ajax_params.dataType = "jsonp"; + } + + // Attach the success callback + ajax_params.success = function(results) { + if($.isFunction(settings.onResult)) { + results = settings.onResult.call(hidden_input, results); + } + cache.add(cache_key, settings.jsonContainer ? results[settings.jsonContainer] : results); + + // only populate the dropdown if the results are associated with the active search query + if(input_box.val() === query) { + populate_dropdown(query, settings.jsonContainer ? results[settings.jsonContainer] : results); + } + }; + + // Make the request + $.ajax(ajax_params); + } else if(settings.local_data) { + // Do the search through local data + var results = $.grep(settings.local_data, function (row) { + return row[settings.propertyToSearch].toLowerCase().indexOf(query.toLowerCase()) > -1; + }); + + if($.isFunction(settings.onResult)) { + results = settings.onResult.call(hidden_input, results); + } + cache.add(cache_key, results); + populate_dropdown(query, results); + } + } + } + + // compute the dynamic URL + function computeURL() { + var url = settings.url; + if(typeof settings.url == 'function') { + url = settings.url.call(); + } + return url; + } + }; + +// Really basic cache for the results + $.TokenList.Cache = function (options) { + var settings = $.extend({ + max_size: 500 + }, options); + + var data = {}; + var size = 0; + + var flush = function () { + data = {}; + size = 0; + }; + + this.add = function (query, results) { + if(size > settings.max_size) { + flush(); + } + + if(!data[query]) { + size += 1; + } + + data[query] = results; + }; + + this.get = function (query) { + return data[query]; + }; + }; +}(jQuery)); From e9cc166ed8781547fa3077062e6af2616a30e949 Mon Sep 17 00:00:00 2001 From: Christopher Timm Date: Fri, 28 Jan 2022 15:07:02 +0100 Subject: [PATCH 2/5] Add used solr suggester for each function to the documentation --- README.md | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/README.md b/README.md index e9da5a00..6e3d4555 100644 --- a/README.md +++ b/README.md @@ -271,8 +271,8 @@ alternate query style. provided by the user (yet); may be a single value string (e.g. for the default state of checkboxes) or an array (especially useful for range queries) -- `entityAutocomplete`[0]: if true, activates the autocomplete -- `entity`[0]: if true, activates the entity search +- `entityAutocomplete`[0]: if true, activates the autocomplete (solr dictionary: autocompleteSuggester) +- `entity`[0]: if true, activates the entity search (solr dictionary: entitySuggester) - `entityReplacement`: the string that should be replaced for an entity (e.g. id:%s) Examples: From 5eb6eb78e95534c4f5d146cd8f9a236d7ec6d861 Mon Sep 17 00:00:00 2001 From: Christopher Timm Date: Tue, 6 Sep 2022 09:37:29 +0200 Subject: [PATCH 3/5] Fix styling with rector and php-cs-fixer --- Classes/Ajax/EidSettings.php | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/Classes/Ajax/EidSettings.php b/Classes/Ajax/EidSettings.php index 4d1d22a7..08d976d7 100644 --- a/Classes/Ajax/EidSettings.php +++ b/Classes/Ajax/EidSettings.php @@ -1,5 +1,6 @@ Date: Tue, 6 Sep 2022 09:37:29 +0200 Subject: [PATCH 4/5] Fix styling with rector and php-cs-fixer --- Classes/Ajax/Autocomplete.php | 36 ++++++++++++------------- Classes/Ajax/GetEntity.php | 21 +++++++-------- Classes/Service/SolrServiceProvider.php | 6 ++--- 3 files changed, 29 insertions(+), 34 deletions(-) diff --git a/Classes/Ajax/Autocomplete.php b/Classes/Ajax/Autocomplete.php index 80502c50..0e0db2db 100644 --- a/Classes/Ajax/Autocomplete.php +++ b/Classes/Ajax/Autocomplete.php @@ -6,12 +6,11 @@ use Psr\Http\Message\ServerRequestInterface; use Psr\Http\Server\MiddlewareInterface; use Psr\Http\Server\RequestHandlerInterface; -use TYPO3\CMS\Core\Http\Response; use TYPO3\CMS\Core\Http\JsonResponse; +use TYPO3\CMS\Core\Http\Response; class Autocomplete implements MiddlewareInterface { - public function process(ServerRequestInterface $request, RequestHandlerInterface $handler): ResponseInterface { $response = $handler->handle($request); @@ -21,10 +20,10 @@ public function process(ServerRequestInterface $request, RequestHandlerInterface return $response; } - include_once 'EidSettings.php'; + include_once __DIR__.'/EidSettings.php'; // Configuration options - $solrSuggestUrl = $HOST . $CORE . '/suggest'; + $solrSuggestUrl = $HOST.$CORE.'/suggest'; // Autocomplete dictionary $solrAutocompleteDictionary = 'autocompleteSuggester'; @@ -51,43 +50,43 @@ public function process(ServerRequestInterface $request, RequestHandlerInterface $dictionaryUrlParams = ''; if ($entity) { - $dictionaryUrlParams .= '&suggest.dictionary=' . $solrEntityDictionary; + $dictionaryUrlParams .= '&suggest.dictionary='.$solrEntityDictionary; } + if ($autocomplete) { - $dictionaryUrlParams .= '&suggest.dictionary=' . $solrAutocompleteDictionary; + $dictionaryUrlParams .= '&suggest.dictionary='.$solrAutocompleteDictionary; } if (!$entity && !$autocomplete) { return $response; } - // Get Solr suggestions $response = file_get_contents( - $solrSuggestUrl . '?suggest=true' . $dictionaryUrlParams . '&suggest.q=' . urlencode($query), - FALSE, + $solrSuggestUrl.'?suggest=true'.$dictionaryUrlParams.'&suggest.q='.urlencode($query), + false, stream_context_create([ 'http' => [ 'method' => 'GET', 'follow_location' => 0, - 'timeout' => 1.0 - ] + 'timeout' => 1.0, + ], ]) ); // Parse JSON response - if ($response !== FALSE) { - $json = json_decode($response, TRUE); + if (false !== $response) { + $json = json_decode($response, true); if ($autocomplete) { // get autocomplete foreach ($json['suggest'][$solrAutocompleteDictionary][$query]['suggestions'] as $suggestion) { - list ($id, $normalized) = explode('␝', $suggestion['payload']); + list($id, $normalized) = explode('␝', $suggestion['payload']); $suggests[] = [ 'id' => htmlspecialchars($suggestion['term']), 'term' => htmlspecialchars($suggestion['term']), 'normalized' => htmlspecialchars($suggestion['term']), - 'autocomplete' => '1' + 'autocomplete' => '1', ]; } } @@ -95,7 +94,7 @@ public function process(ServerRequestInterface $request, RequestHandlerInterface if ($entity && $autocomplete) { // Add break between autocomplete and entity list $suggests[] = [ - 'id' => 'br' + 'id' => 'br', ]; } @@ -104,16 +103,17 @@ public function process(ServerRequestInterface $request, RequestHandlerInterface // get entities foreach ($json['suggest'][$solrEntityDictionary][$query]['suggestions'] as $suggestion) { - list ($id, $normalized) = explode('␝', $suggestion['payload']); + list($id, $normalized) = explode('␝', $suggestion['payload']); if (!in_array($id, $idDeduping)) { if (empty($normalized)) { $normalized = $suggestion['term']; } + $suggests[] = [ 'id' => str_replace('%s', $id, $entityReplacement), 'term' => htmlspecialchars($suggestion['term']), 'normalized' => htmlspecialchars($normalized), - 'autocomplete' => '0' + 'autocomplete' => '0', ]; $idDeduping[] = $id; } diff --git a/Classes/Ajax/GetEntity.php b/Classes/Ajax/GetEntity.php index f93144fe..bc6775bb 100644 --- a/Classes/Ajax/GetEntity.php +++ b/Classes/Ajax/GetEntity.php @@ -2,17 +2,15 @@ namespace Subugoe\Find\Ajax; - use Psr\Http\Message\ResponseInterface; use Psr\Http\Message\ServerRequestInterface; use Psr\Http\Server\MiddlewareInterface; use Psr\Http\Server\RequestHandlerInterface; -use TYPO3\CMS\Core\Http\Response; use TYPO3\CMS\Core\Http\JsonResponse; +use TYPO3\CMS\Core\Http\Response; class GetEntity implements MiddlewareInterface { - public function process(ServerRequestInterface $request, RequestHandlerInterface $handler): ResponseInterface { $response = $handler->handle($request); @@ -20,10 +18,10 @@ public function process(ServerRequestInterface $request, RequestHandlerInterface return $response; } - include_once 'EidSettings.php'; + include_once __DIR__.'/EidSettings.php'; // Configuration options - $solr_select_url = $HOST . $CORE . '/select'; + $solr_select_url = $HOST.$CORE.'/select'; // Array of entity facts $entity = []; @@ -33,25 +31,24 @@ public function process(ServerRequestInterface $request, RequestHandlerInterface // Get Solr record $response = file_get_contents( - $solr_select_url . '?q=' . urlencode('id:(' . $query . ')') . '&rows=1', - FALSE, + $solr_select_url.'?q='.urlencode('id:('.$query.')').'&rows=1', + false, stream_context_create([ 'http' => [ 'method' => 'GET', 'follow_location' => 0, - 'timeout' => 1.0 - ] + 'timeout' => 1.0, + ], ]) ); // Parse JSON response - if ($response !== FALSE) { - $json = json_decode($response, TRUE); + if (false !== $response) { + $json = json_decode($response, true); $entity = $json['response']['docs'][0]; } // Return result return new JsonResponse($entity); } - } diff --git a/Classes/Service/SolrServiceProvider.php b/Classes/Service/SolrServiceProvider.php index 16fa810d..0ba41373 100644 --- a/Classes/Service/SolrServiceProvider.php +++ b/Classes/Service/SolrServiceProvider.php @@ -415,10 +415,8 @@ protected function addHighlighting(array $arguments): void if ($fieldID && $queryParameters[$fieldID]) { $queryArguments = $queryParameters[$fieldID]; $queryTerms = null; - if (is_array($queryArguments) && array_key_exists( - 'alternate', - $queryArguments - ) && array_key_exists('queryAlternate', $fieldInfo) + if (is_array($queryArguments) && array_key_exists('alternate', + $queryArguments) && array_key_exists('queryAlternate', $fieldInfo) ) { if (array_key_exists('term', $queryArguments)) { $queryTerms = $queryArguments['term']; From 0ab99076ffb75b4561c356cc3125f7a1328edda6 Mon Sep 17 00:00:00 2001 From: Christopher Timm Date: Tue, 6 Sep 2022 09:39:44 +0200 Subject: [PATCH 5/5] Fix RequestMiddlewares.php --- Configuration/RequestMiddlewares.php | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/Configuration/RequestMiddlewares.php b/Configuration/RequestMiddlewares.php index 9df27089..fc679519 100644 --- a/Configuration/RequestMiddlewares.php +++ b/Configuration/RequestMiddlewares.php @@ -5,13 +5,13 @@ 'Subugoe/Find/ajax/getentity' => [ 'target' => \Subugoe\Find\Ajax\GetEntity::class, 'after' => [ - 'typo3/cms-frontend/prepare-tsfe-rendering' + 'typo3/cms-frontend/prepare-tsfe-rendering', ], ], 'Subugoe/Find/ajax/autocomplete' => [ 'target' => \Subugoe\Find\Ajax\Autocomplete::class, 'after' => [ - 'typo3/cms-frontend/prepare-tsfe-rendering' + 'typo3/cms-frontend/prepare-tsfe-rendering', ], ], ],