From e80ae768562043947fceb881436cfe224c0db547 Mon Sep 17 00:00:00 2001 From: Dan Brown Date: Sun, 15 May 2016 20:12:53 +0100 Subject: [PATCH] Added auto-suggestions to tag names and values --- app/Http/Controllers/TagController.php | 10 ++ app/Http/routes.php | 3 +- app/Repos/TagRepo.php | 12 ++ resources/assets/js/directives.js | 179 ++++++++++++++++++- resources/assets/sass/_pages.scss | 23 +++ resources/views/pages/edit.blade.php | 2 +- resources/views/pages/form-toolbox.blade.php | 9 +- tests/Entity/TagTests.php | 26 ++- 8 files changed, 254 insertions(+), 10 deletions(-) diff --git a/app/Http/Controllers/TagController.php b/app/Http/Controllers/TagController.php index e236986b6..1823b0dc8 100644 --- a/app/Http/Controllers/TagController.php +++ b/app/Http/Controllers/TagController.php @@ -60,5 +60,15 @@ class TagController extends Controller return response()->json($suggestions); } + /** + * Get tag value suggestions from a given search term. + * @param Request $request + */ + public function getValueSuggestions(Request $request) + { + $searchTerm = $request->get('search'); + $suggestions = $this->tagRepo->getValueSuggestions($searchTerm); + return response()->json($suggestions); + } } diff --git a/app/Http/routes.php b/app/Http/routes.php index 629f61ba9..9f226efd7 100644 --- a/app/Http/routes.php +++ b/app/Http/routes.php @@ -88,7 +88,8 @@ Route::group(['middleware' => 'auth'], function () { // Tag routes (AJAX) Route::group(['prefix' => 'ajax/tags'], function() { Route::get('/get/{entityType}/{entityId}', 'TagController@getForEntity'); - Route::get('/suggest', 'TagController@getNameSuggestions'); + Route::get('/suggest/names', 'TagController@getNameSuggestions'); + Route::get('/suggest/values', 'TagController@getValueSuggestions'); Route::post('/update/{entityType}/{entityId}', 'TagController@updateForEntity'); }); diff --git a/app/Repos/TagRepo.php b/app/Repos/TagRepo.php index 5d3670e6f..7d51d87f7 100644 --- a/app/Repos/TagRepo.php +++ b/app/Repos/TagRepo.php @@ -69,6 +69,18 @@ class TagRepo return $query->get(['name'])->pluck('name'); } + /** + * Get tag value suggestions from scanning existing tag values. + * @param $searchTerm + * @return array + */ + public function getValueSuggestions($searchTerm) + { + if ($searchTerm === '') return []; + $query = $this->tag->where('value', 'LIKE', $searchTerm . '%')->groupBy('value')->orderBy('value', 'desc'); + $query = $this->permissionService->filterRestrictedEntityRelations($query, 'tags', 'entity_id', 'entity_type'); + return $query->get(['value'])->pluck('value'); + } /** * Save an array of tags to an entity * @param Entity $entity diff --git a/resources/assets/js/directives.js b/resources/assets/js/directives.js index 74ecebc75..62557f976 100644 --- a/resources/assets/js/directives.js +++ b/resources/assets/js/directives.js @@ -339,4 +339,181 @@ module.exports = function (ngApp, events) { } }]); -}; \ No newline at end of file + ngApp.directive('autosuggestions', ['$http', function($http) { + return { + restrict: 'A', + link: function(scope, elem, attrs) { + + // Local storage for quick caching. + const localCache = {}; + + // Create suggestion element + const suggestionBox = document.createElement('ul'); + suggestionBox.className = 'suggestion-box'; + suggestionBox.style.position = 'absolute'; + suggestionBox.style.display = 'none'; + const $suggestionBox = $(suggestionBox); + + // General state tracking + let isShowing = false; + let currentInput = false; + let active = 0; + + // Listen to input events on autosuggest fields + elem.on('input', '[autosuggest]', function(event) { + let $input = $(this); + let val = $input.val(); + let url = $input.attr('autosuggest'); + // No suggestions until at least 3 chars + if (val.length < 3) { + if (isShowing) { + $suggestionBox.hide(); + isShowing = false; + } + return; + }; + + let suggestionPromise = getSuggestions(val.slice(0, 3), url); + suggestionPromise.then((suggestions) => { + if (val.length > 2) { + suggestions = suggestions.filter((item) => { + return item.toLowerCase().indexOf(val.toLowerCase()) !== -1; + }).slice(0, 4); + displaySuggestions($input, suggestions); + } + }); + }); + + // Hide autosuggestions when input loses focus. + // Slight delay to allow clicks. + elem.on('blur', '[autosuggest]', function(event) { + setTimeout(() => { + $suggestionBox.hide(); + isShowing = false; + }, 200) + }); + + elem.on('keydown', '[autosuggest]', function (event) { + if (!isShowing) return; + + let suggestionElems = suggestionBox.childNodes; + let suggestCount = suggestionElems.length; + + // Down arrow + if (event.keyCode === 40) { + let newActive = (active === suggestCount-1) ? 0 : active + 1; + changeActiveTo(newActive, suggestionElems); + } + // Up arrow + else if (event.keyCode === 38) { + let newActive = (active === 0) ? suggestCount-1 : active - 1; + changeActiveTo(newActive, suggestionElems); + } + // Enter key + else if (event.keyCode === 13) { + let text = suggestionElems[active].textContent; + currentInput[0].value = text; + currentInput.focus(); + $suggestionBox.hide(); + isShowing = false; + event.preventDefault(); + return false; + } + }); + + // Change the active suggestion to the given index + function changeActiveTo(index, suggestionElems) { + suggestionElems[active].className = ''; + active = index; + suggestionElems[active].className = 'active'; + } + + // Display suggestions on a field + let prevSuggestions = []; + function displaySuggestions($input, suggestions) { + + // Hide if no suggestions + if (suggestions.length === 0) { + $suggestionBox.hide(); + isShowing = false; + prevSuggestions = suggestions; + return; + } + + // Otherwise show and attach to input + if (!isShowing) { + $suggestionBox.show(); + isShowing = true; + } + if ($input !== currentInput) { + $suggestionBox.detach(); + $input.after($suggestionBox); + currentInput = $input; + } + + // Return if no change + if (prevSuggestions.join() === suggestions.join()) { + prevSuggestions = suggestions; + return; + } + + // Build suggestions + $suggestionBox[0].innerHTML = ''; + for (let i = 0; i < suggestions.length; i++) { + var suggestion = document.createElement('li'); + suggestion.textContent = suggestions[i]; + suggestion.onclick = suggestionClick; + if (i === 0) { + suggestion.className = 'active' + active = 0; + }; + $suggestionBox[0].appendChild(suggestion); + } + + prevSuggestions = suggestions; + } + + // Suggestion click event + function suggestionClick(event) { + let text = this.textContent; + currentInput[0].value = text; + currentInput.focus(); + $suggestionBox.hide(); + isShowing = false; + }; + + // Get suggestions & cache + function getSuggestions(input, url) { + let searchUrl = url + '?search=' + encodeURIComponent(input); + + // Get from local cache if exists + if (localCache[searchUrl]) { + return new Promise((resolve, reject) => { + resolve(localCache[input]); + }); + } + + return $http.get(searchUrl).then((response) => { + localCache[input] = response.data; + return response.data; + }); + } + + } + } + }]); +}; + + + + + + + + + + + + + + diff --git a/resources/assets/sass/_pages.scss b/resources/assets/sass/_pages.scss index a1297649b..ff1b47cd7 100644 --- a/resources/assets/sass/_pages.scss +++ b/resources/assets/sass/_pages.scss @@ -200,6 +200,7 @@ .tags td { padding-right: $-s; padding-top: $-s; + position: relative; } button.pos { position: absolute; @@ -269,6 +270,28 @@ } .tag { padding: $-s; + } +} +.suggestion-box { + position: absolute; + background-color: #FFF; + border: 1px solid #BBB; + box-shadow: $bs-light; + list-style: none; + z-index: 100; + padding: 0; + margin: 0; + border-radius: 3px; + li { + display: block; + padding: $-xs $-s; + border-bottom: 1px solid #DDD; + &:last-child { + border-bottom: 0; + } + &.active { + background-color: #EEE; + } } } \ No newline at end of file diff --git a/resources/views/pages/edit.blade.php b/resources/views/pages/edit.blade.php index c58c8edfb..de6051118 100644 --- a/resources/views/pages/edit.blade.php +++ b/resources/views/pages/edit.blade.php @@ -9,7 +9,7 @@ @section('content')
-
+ @if(!isset($isDraft)) @endif diff --git a/resources/views/pages/form-toolbox.blade.php b/resources/views/pages/form-toolbox.blade.php index 36044b789..ae17045d1 100644 --- a/resources/views/pages/form-toolbox.blade.php +++ b/resources/views/pages/form-toolbox.blade.php @@ -1,19 +1,21 @@
+
+

Page Tags

Add some tags to better categorise your content.
You can assign a value to a tag for more in-depth organisation.

- +
- - + + @@ -31,4 +33,5 @@
+
\ No newline at end of file diff --git a/tests/Entity/TagTests.php b/tests/Entity/TagTests.php index 379386404..0520e1a00 100644 --- a/tests/Entity/TagTests.php +++ b/tests/Entity/TagTests.php @@ -52,10 +52,28 @@ class TagTests extends \TestCase $attrs = $attrs->merge(factory(Tag::class, 5)->make(['name' => 'plans'])); $page = $this->getPageWithTags($attrs); - $this->asAdmin()->get('/ajax/tags/suggest?search=dog')->seeJsonEquals([]); - $this->get('/ajax/tags/suggest?search=co')->seeJsonEquals(['color', 'country', 'county']); - $this->get('/ajax/tags/suggest?search=cou')->seeJsonEquals(['country', 'county']); - $this->get('/ajax/tags/suggest?search=pla')->seeJsonEquals(['planet', 'plans']); + $this->asAdmin()->get('/ajax/tags/suggest/names?search=dog')->seeJsonEquals([]); + $this->get('/ajax/tags/suggest/names?search=co')->seeJsonEquals(['color', 'country', 'county']); + $this->get('/ajax/tags/suggest/names?search=cou')->seeJsonEquals(['country', 'county']); + $this->get('/ajax/tags/suggest/names?search=pla')->seeJsonEquals(['planet', 'plans']); + } + + public function test_tag_value_suggestions() + { + // Create some tags with similar values to test with + $attrs = collect(); + $attrs = $attrs->merge(factory(Tag::class, 5)->make(['name' => 'country', 'value' => 'cats'])); + $attrs = $attrs->merge(factory(Tag::class, 5)->make(['name' => 'color', 'value' => 'cattery'])); + $attrs = $attrs->merge(factory(Tag::class, 5)->make(['name' => 'city', 'value' => 'castle'])); + $attrs = $attrs->merge(factory(Tag::class, 5)->make(['name' => 'county', 'value' => 'dog'])); + $attrs = $attrs->merge(factory(Tag::class, 5)->make(['name' => 'planet', 'value' => 'catapult'])); + $attrs = $attrs->merge(factory(Tag::class, 5)->make(['name' => 'plans', 'value' => 'dodgy'])); + $page = $this->getPageWithTags($attrs); + + $this->asAdmin()->get('/ajax/tags/suggest/values?search=ora')->seeJsonEquals([]); + $this->get('/ajax/tags/suggest/values?search=cat')->seeJsonEquals(['cats', 'cattery', 'catapult']); + $this->get('/ajax/tags/suggest/values?search=do')->seeJsonEquals(['dog', 'dodgy']); + $this->get('/ajax/tags/suggest/values?search=cas')->seeJsonEquals(['castle']); } public function test_entity_permissions_effect_tag_suggestions()