Commit b49d26c4 authored by anon's avatar anon

Issue #2763791 by anon: Refactor how autocomplete results are handled

parent 9e9045d5
......@@ -3,7 +3,7 @@
* Linkit Autocomplete based on jQuery UI.
*/
(function ($, Drupal, _, document) {
(function ($, Drupal, _) {
'use strict';
......@@ -13,7 +13,9 @@
* JQuery UI autocomplete source callback.
*
* @param {object} request
* The request object.
* @param {function} response
* The function to call with the response.
*/
function sourceData(request, response) {
var elementId = this.element.attr('id');
......@@ -22,26 +24,15 @@
autocomplete.cache[elementId] = {};
}
/**
* @param {object} suggestions
*/
function showSuggestions(suggestions) {
if (suggestions.matches.length === 0) {
response([{title: Drupal.t('No results')}]);
}
else {
response(suggestions.matches);
}
}
/**
* Transforms the data object into an array and update autocomplete results.
*
* @param {object} data
* The data sent back from the server.
*/
function sourceCallbackHandler(data) {
autocomplete.cache[elementId][term] = data;
showSuggestions(data);
autocomplete.cache[elementId][term] = data.suggestions;
response(data.suggestions);
}
// Get the desired term and construct the autocomplete URL for it.
......@@ -49,7 +40,7 @@
// Check if the term is already cached.
if (autocomplete.cache[elementId].hasOwnProperty(term)) {
showSuggestions(autocomplete.cache[elementId][term]);
response(autocomplete.cache[elementId][term]);
}
else {
var options = $.extend({success: sourceCallbackHandler, data: {q: term}}, autocomplete.ajax);
......@@ -61,29 +52,31 @@
* Handles an autocomplete select event.
*
* @param {jQuery.Event} event
* The event triggered.
* @param {object} ui
* The jQuery UI settings object.
*
* @return {boolean}
* False to prevent further handlers.
*/
function selectHandler(event, ui) {
if (ui.item.hasOwnProperty('path')) {
event.target.value = ui.item.path;
if (ui.item.hasOwnProperty('title')) {
$('.linkit-link-information > span').text(ui.item.title);
}
}
event.target.value = ui.item.path;
$('.linkit-link-information > span').text(ui.item.label);
return false;
}
/**
* Handles an autocomplete response event.
*
* Updates the link information.
*
* @param {jQuery.Event} event
* The event triggered.
* @param {object} ui
* The jQuery UI settings object.
*/
function response(event, ui) {
if (ui.content.length !== 0) {
if (ui.content.length === 0) {
$('.linkit-link-information > span').text(event.target.value);
}
}
......@@ -94,14 +87,16 @@
* @param {object} ul
* The <ul> element that the newly created <li> element must be appended to.
* @param {object} item
* The list item to append.
*
* @return {object}
* jQuery collection of the ul element.
*/
function renderItem(ul, item) {
var $line = $('<li>').addClass('linkit-result');
$line.append($('<span>').html(item.title).addClass('linkit-result--title'));
$line.append($('<span>').html(item.label).addClass('linkit-result--title'));
if (item.description !== null) {
if (item.hasOwnProperty('description')) {
$line.append($('<span>').html(item.description).addClass('linkit-result--description'));
}
......@@ -138,10 +133,15 @@
* Attaches the autocomplete behavior to all required fields.
*
* @type {Drupal~behavior}
*
* @prop {Drupal~behaviorAttach} attach
* Attaches the autocomplete behaviors.
* @prop {Drupal~behaviorDetach} detach
* Detaches the autocomplete behaviors.
*/
Drupal.behaviors.linkit_autocomplete = {
attach: function (context) {
// Act on textfields with the "form-autocomplete" class.
// Act on textfields with the "form-linkit-autocomplete" class.
var $autocomplete = $(context).find('input.form-linkit-autocomplete').once('linkit-autocomplete');
if ($autocomplete.length) {
$.widget('custom.autocomplete', $.ui.autocomplete, {
......@@ -185,4 +185,4 @@
}
};
})(jQuery, Drupal, _, document);
})(jQuery, Drupal, _);
......@@ -3,5 +3,5 @@ services:
class: Drupal\linkit\MatcherManager
parent: default_plugin_manager
linkit.result_manager:
class: Drupal\linkit\ResultManager
linkit.suggestion_manager:
class: Drupal\linkit\SuggestionManager
......@@ -5,7 +5,7 @@ namespace Drupal\linkit\Controller;
use Drupal\Component\Utility\Unicode;
use Drupal\Core\DependencyInjection\ContainerInjectionInterface;
use Drupal\Core\Entity\EntityStorageInterface;
use Drupal\linkit\ResultManager;
use Drupal\linkit\SuggestionManager;
use Symfony\Component\DependencyInjection\ContainerInterface;
use Symfony\Component\HttpFoundation\JsonResponse;
use Symfony\Component\HttpFoundation\Request;
......@@ -23,11 +23,11 @@ class AutocompleteController implements ContainerInjectionInterface {
protected $linkitProfileStorage;
/**
* The result manager.
* The suggestion manager.
*
* @var \Drupal\linkit\ResultManager
* @var \Drupal\linkit\SuggestionManager
*/
protected $resultManager;
protected $suggestionManager;
/**
* The linkit profile.
......@@ -41,12 +41,12 @@ class AutocompleteController implements ContainerInjectionInterface {
*
* @param \Drupal\Core\Entity\EntityStorageInterface $linkit_profile_storage
* The linkit profile storage service.
* @param ResultManager $resultManager
* The result service.
* @param SuggestionManager $suggestionManager
* The suggestion service.
*/
public function __construct(EntityStorageInterface $linkit_profile_storage, ResultManager $resultManager) {
public function __construct(EntityStorageInterface $linkit_profile_storage, SuggestionManager $suggestionManager) {
$this->linkitProfileStorage = $linkit_profile_storage;
$this->resultManager = $resultManager;
$this->suggestionManager = $suggestionManager;
}
/**
......@@ -55,7 +55,7 @@ class AutocompleteController implements ContainerInjectionInterface {
public static function create(ContainerInterface $container) {
return new static(
$container->get('entity.manager')->getStorage('linkit_profile'),
$container->get('linkit.result_manager')
$container->get('linkit.suggestion_manager')
);
}
......@@ -77,12 +77,9 @@ class AutocompleteController implements ContainerInjectionInterface {
$this->linkitProfile = $this->linkitProfileStorage->load($linkit_profile_id);
$string = Unicode::strtolower($request->query->get('q'));
$matches = $this->resultManager->getResults($this->linkitProfile, $string);
$suggestionCollection = $this->suggestionManager->getSuggestions($this->linkitProfile, $string);
$json_object = new \stdClass();
$json_object->matches = $matches;
return new JsonResponse($json_object);
return new JsonResponse($suggestionCollection);
}
}
......@@ -4,6 +4,7 @@ namespace Drupal\linkit;
use Drupal\Core\Plugin\ContainerFactoryPluginInterface;
use Drupal\Core\Plugin\PluginBase;
use Symfony\Component\DependencyInjection\ContainerInterface;
/**
* Provides a base class for matchers.
......@@ -35,6 +36,17 @@ abstract class MatcherBase extends PluginBase implements MatcherInterface, Conta
$this->setConfiguration($configuration);
}
/**
* {@inheritdoc}
*/
public static function create(ContainerInterface $container, array $configuration, $plugin_id, $plugin_definition) {
return new static(
$configuration,
$plugin_id,
$plugin_definition
);
}
/**
* {@inheritdoc}
*/
......
......@@ -58,22 +58,14 @@ interface MatcherInterface extends PluginInspectionInterface, ConfigurablePlugin
public function setWeight($weight);
/**
* Gets an array with search matches.
*
* The matches will be presented in the autocomplete widget.
* Executes the matcher.
*
* @param string $string
* The string that contains the text to search for.
*
* @return array
* An array whose values are an associative array containing:
* - title: A string to use as the search result label.
* - description: (optional) A string with additional information about the
* result item.
* - path: The URL to the item.
* - group: (optional) A string with the group name for the result item.
* Best practice is to use the plugin name as group name.
* @return \Drupal\linkit\Suggestion\SuggestionCollection
* A suggestion collection.
*/
public function getMatches($string);
public function execute($string);
}
<?php
namespace Drupal\linkit\Plugin\Linkit\Matcher;
use Drupal\Component\Utility\Html;
use Drupal\linkit\MatcherBase;
use Drupal\linkit\Suggestion\DescriptionSuggestion;
use Drupal\linkit\Suggestion\SuggestionCollection;
/**
* Provides specific linkit matchers for emails.
*
* @Matcher(
* id = "email",
* label = @Translation("Email"),
* )
*/
class EmailMatcher extends MatcherBase {
/**
* {@inheritdoc}
*/
public function execute($string) {
$suggestions = new SuggestionCollection();
// Check for an e-mail address then return an e-mail match and create a
// mail-to link if appropriate.
if (filter_var($string, FILTER_VALIDATE_EMAIL)) {
$suggestion = new DescriptionSuggestion();
$suggestion->setLabel($this->t('E-mail @email', ['@email' => $string]))
->setPath('mailto:' . Html::escape($string))
->setGroup($this->t('E-mail'))
->setDescription($this->t('Opens your mail client ready to e-mail @email', ['@email' => $string]));
$suggestions->addSuggestion($suggestion);
}
return $suggestions;
}
}
......@@ -13,6 +13,8 @@ use Drupal\Core\Form\FormStateInterface;
use Drupal\Core\Session\AccountInterface;
use Drupal\linkit\ConfigurableMatcherBase;
use Drupal\linkit\MatcherTokensTrait;
use Drupal\linkit\Suggestion\DescriptionSuggestion;
use Drupal\linkit\Suggestion\SuggestionCollection;
use Drupal\linkit\Utility\LinkitXss;
use Symfony\Component\DependencyInjection\ContainerInterface;
......@@ -232,15 +234,15 @@ class EntityMatcher extends ConfigurableMatcherBase {
/**
* {@inheritdoc}
*/
public function getMatches($string) {
public function execute($string) {
$suggestions = new SuggestionCollection();
$query = $this->buildEntityQuery($string);
$result = $query->execute();
if (empty($result)) {
return [];
return $suggestions;
}
$matches = [];
$entities = $this->entityTypeManager->getStorage($this->targetType)->loadMultiple($result);
foreach ($entities as $entity) {
......@@ -253,15 +255,16 @@ class EntityMatcher extends ConfigurableMatcherBase {
$entity = $this->entityRepository->getTranslationFromContext($entity);
$matches[] = [
'title' => $this->buildLabel($entity),
'description' => $this->buildDescription($entity),
'path' => $this->buildPath($entity),
'group' => $this->buildGroup($entity),
];
$suggestion = new DescriptionSuggestion();
$suggestion->setLabel($this->buildLabel($entity))
->setPath($this->buildPath($entity))
->setGroup($this->buildGroup($entity))
->setDescription($this->buildDescription($entity));
$suggestions->addSuggestion($suggestion);
}
return $matches;
return $suggestions;
}
/**
......
<?php
namespace Drupal\linkit\Plugin\Linkit\Matcher;
use Drupal\Core\Url;
use Drupal\linkit\MatcherBase;
use Drupal\linkit\Suggestion\DescriptionSuggestion;
use Drupal\linkit\Suggestion\SuggestionCollection;
/**
* Provides specific linkit matchers for the front page.
*
* @Matcher(
* id = "front_page",
* label = @Translation("Front page"),
* )
*/
class FrontPageMatcher extends MatcherBase {
/**
* {@inheritdoc}
*/
public function execute($string) {
$suggestions = new SuggestionCollection();
// Special for link to front page.
if (strpos($string, 'front') !== FALSE) {
$suggestion = new DescriptionSuggestion();
$suggestion->setLabel($this->t('Front page'))
->setPath(Url::fromRoute('<front>')->toString())
->setGroup($this->t('System'))
->setDescription($this->t('The front page for this site.'));
$suggestions->addSuggestion($suggestion);
}
return $suggestions;
}
}
<?php
namespace Drupal\linkit;
use Drupal\Component\Utility\Html;
use Drupal\Core\Url;
/**
* Result service to handle autocomplete matcher results.
*/
class ResultManager {
/**
* Gets the results.
*
* @param ProfileInterface $linkitProfile
* The linkit profile.
* @param string $search_string
* The string ro use in the matchers.
*
* @return array
* An array of matches.
*/
public function getResults(ProfileInterface $linkitProfile, $search_string) {
$matches = [];
if (empty(trim($search_string))) {
return [
[
'title' => t('No results'),
],
];
}
// Special for link to front page.
if (strpos($search_string, 'front') !== FALSE) {
$matches[] = [
'title' => t('Front page'),
'description' => 'The front page for this site.',
'path' => Url::fromRoute('<front>')->toString(),
'group' => t('System'),
];
}
foreach ($linkitProfile->getMatchers() as $plugin) {
$matches = array_merge($matches, $plugin->getMatches($search_string));
}
// Check for an e-mail address then return an e-mail match and create a
// mail-to link if appropriate.
if (filter_var($search_string, FILTER_VALIDATE_EMAIL)) {
$matches[] = [
'title' => t('E-mail @email', ['@email' => $search_string]),
'description' => t('Opens your mail client ready to e-mail @email', ['@email' => $search_string]),
'path' => 'mailto:' . Html::escape($search_string),
'group' => t('E-mail'),
];
}
// If there is still no matches, return a "no results" array.
if (empty($matches)) {
return [
[
'title' => t('No results'),
],
];
}
return $matches;
}
}
<?php
namespace Drupal\linkit\Suggestion;
/**
* Defines a linkit suggestion with description.
*/
class DescriptionSuggestion extends SimpleSuggestion implements SuggestionDescriptionInterface {
/**
* The suggestion description.
*
* A string with additional information about the suggestion.
*
* @var string
* The suggestion description.
*/
protected $description;
/**
* {@inheritdoc}
*/
public function getDescription() {
return $this->description;
}
/**
* {@inheritdoc}
*/
public function setDescription($description) {
$this->description = $description;
return $this;
}
/**
* {@inheritdoc}
*/
public function jsonSerialize() {
return parent::jsonSerialize() + [
'description' => $this->getDescription(),
];
}
}
<?php
namespace Drupal\linkit\Suggestion;
/**
* Defines a simple suggestion.
*/
class SimpleSuggestion implements SuggestionInterface {
/**
* The suggestion label.
*
* @var string
* The suggestion label.
*/
protected $label;
/**
* The suggestion path.
*
* @var string
* The suggestion path.
*/
protected $path;
/**
* The suggestion group.
*
* @var string
* The suggestion group.
*/
protected $group;
/**
* {@inheritdoc}
*/
public function getLabel() {
return $this->label;
}
/**
* {@inheritdoc}
*/
public function setLabel($label) {
$this->label = $label;
return $this;
}
/**
* {@inheritdoc}
*/
public function getPath() {
return $this->path;
}
/**
* {@inheritdoc}
*/
public function setPath($path) {
$this->path = $path;
return $this;
}
/**
* {@inheritdoc}
*/
public function getGroup() {
return $this->group;
}
/**
* {@inheritdoc}
*/
public function setGroup($group) {
$this->group = $group;
return $this;
}
/**
* {@inheritdoc}
*/
public function jsonSerialize() {
return [
'label' => $this->getLabel(),
'path' => $this->getPath(),
'group' => $this->getGroup(),
];
}
}
<?php
namespace Drupal\linkit\Suggestion;
/**
* Defines a suggestion collection used to avoid JSON Hijacking.
*/
class SuggestionCollection implements \JsonSerializable {
/**
* An array of suggestions.
*
* @var array
*/
protected $suggestions = [];
/**
* Returns all suggestions in the collection.
*
* @return \Drupal\linkit\Suggestion\SuggestionInterface[]
* All suggestions in the collection.
*/
public function getSuggestions() {
return $this->suggestions;
}
/**
* Adds a suggestion to this collection.
*
* @param \Drupal\linkit\Suggestion\SuggestionInterface $suggestion
* The suggestion to add to the collection.
*/
public function addSuggestion(SuggestionInterface $suggestion) {
$this->suggestions[] = $suggestion;
}
/**
* Adds a collection of suggestions to the this collection.
*
* @param \Drupal\linkit\Suggestion\SuggestionCollection $suggestionCollection
* A collection of suggestions.
*/
public function addSuggestions(SuggestionCollection $suggestionCollection) {
$this->suggestions = array_merge($this->suggestions, $suggestionCollection->getSuggestions());
}
/**
* {@inheritdoc}
*/
public function jsonSerialize() {
return [
'suggestions' => $this->suggestions,
];
}
}
<?php
namespace Drupal\linkit\Suggestion;
/**
* Defines the interface for suggestions that have a description.
*/
interface SuggestionDescriptionInterface {
/**
* Gets the suggestion description.
*
* @return string
* The suggestion description.
*/
public function getDescription();
/**
* Sets the suggestion description.
*
* @param string $description
* The suggestion description.
*
* @return $this
*/
public function setDescription($description);
}
<?php
namespace Drupal\linkit\Suggestion;
/**
* Defines the interface for suggestions.
*/
interface SuggestionInterface extends \JsonSerializable {
/**
* Gets the suggestion label.
*
* @return string
* The suggestion label.
*/
public function getLabel();
/**
* Sets the suggestion label.
*
* @param string $label
* The suggestion label to set.
*
* @return $this
*/
public function setLabel($label);
/**
* Gets the suggestion path.
*
* @return string
* The suggestion path.
*/
public function getPath();
/**
* Sets the suggestion path.
*
* @param string $path
* The suggestion path to set.
*
* @return $this
*/
public function setPath($path);
/**
* Gets the suggestion group.
*
* @return string
* The suggestion group.
*/