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')