diff --git a/Classes/Ajax/Autocomplete.php b/Classes/Ajax/Autocomplete.php new file mode 100644 index 00000000..0e0db2db --- /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 __DIR__.'/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 (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']); + $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..08d976d7 --- /dev/null +++ b/Classes/Ajax/EidSettings.php @@ -0,0 +1,6 @@ +handle($request); + if (!isset($request->getQueryParams()['q'], $request->getQueryParams()['getEntity'])) { + return $response; + } + + include_once __DIR__.'/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 (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']; diff --git a/Configuration/RequestMiddlewares.php b/Configuration/RequestMiddlewares.php new file mode 100644 index 00000000..fc679519 --- /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..6e3d4555 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 (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: @@ -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 = $("