Dan Brown

Added auto-suggestions to tag names and values

...@@ -60,5 +60,15 @@ class TagController extends Controller ...@@ -60,5 +60,15 @@ class TagController extends Controller
60 return response()->json($suggestions); 60 return response()->json($suggestions);
61 } 61 }
62 62
63 + /**
64 + * Get tag value suggestions from a given search term.
65 + * @param Request $request
66 + */
67 + public function getValueSuggestions(Request $request)
68 + {
69 + $searchTerm = $request->get('search');
70 + $suggestions = $this->tagRepo->getValueSuggestions($searchTerm);
71 + return response()->json($suggestions);
72 + }
63 73
64 } 74 }
......
...@@ -88,7 +88,8 @@ Route::group(['middleware' => 'auth'], function () { ...@@ -88,7 +88,8 @@ Route::group(['middleware' => 'auth'], function () {
88 // Tag routes (AJAX) 88 // Tag routes (AJAX)
89 Route::group(['prefix' => 'ajax/tags'], function() { 89 Route::group(['prefix' => 'ajax/tags'], function() {
90 Route::get('/get/{entityType}/{entityId}', 'TagController@getForEntity'); 90 Route::get('/get/{entityType}/{entityId}', 'TagController@getForEntity');
91 - Route::get('/suggest', 'TagController@getNameSuggestions'); 91 + Route::get('/suggest/names', 'TagController@getNameSuggestions');
92 + Route::get('/suggest/values', 'TagController@getValueSuggestions');
92 Route::post('/update/{entityType}/{entityId}', 'TagController@updateForEntity'); 93 Route::post('/update/{entityType}/{entityId}', 'TagController@updateForEntity');
93 }); 94 });
94 95
......
...@@ -70,6 +70,18 @@ class TagRepo ...@@ -70,6 +70,18 @@ class TagRepo
70 } 70 }
71 71
72 /** 72 /**
73 + * Get tag value suggestions from scanning existing tag values.
74 + * @param $searchTerm
75 + * @return array
76 + */
77 + public function getValueSuggestions($searchTerm)
78 + {
79 + if ($searchTerm === '') return [];
80 + $query = $this->tag->where('value', 'LIKE', $searchTerm . '%')->groupBy('value')->orderBy('value', 'desc');
81 + $query = $this->permissionService->filterRestrictedEntityRelations($query, 'tags', 'entity_id', 'entity_type');
82 + return $query->get(['value'])->pluck('value');
83 + }
84 + /**
73 * Save an array of tags to an entity 85 * Save an array of tags to an entity
74 * @param Entity $entity 86 * @param Entity $entity
75 * @param array $tags 87 * @param array $tags
......
...@@ -339,4 +339,181 @@ module.exports = function (ngApp, events) { ...@@ -339,4 +339,181 @@ module.exports = function (ngApp, events) {
339 } 339 }
340 }]); 340 }]);
341 341
342 -};
...\ No newline at end of file ...\ No newline at end of file
342 + ngApp.directive('autosuggestions', ['$http', function($http) {
343 + return {
344 + restrict: 'A',
345 + link: function(scope, elem, attrs) {
346 +
347 + // Local storage for quick caching.
348 + const localCache = {};
349 +
350 + // Create suggestion element
351 + const suggestionBox = document.createElement('ul');
352 + suggestionBox.className = 'suggestion-box';
353 + suggestionBox.style.position = 'absolute';
354 + suggestionBox.style.display = 'none';
355 + const $suggestionBox = $(suggestionBox);
356 +
357 + // General state tracking
358 + let isShowing = false;
359 + let currentInput = false;
360 + let active = 0;
361 +
362 + // Listen to input events on autosuggest fields
363 + elem.on('input', '[autosuggest]', function(event) {
364 + let $input = $(this);
365 + let val = $input.val();
366 + let url = $input.attr('autosuggest');
367 + // No suggestions until at least 3 chars
368 + if (val.length < 3) {
369 + if (isShowing) {
370 + $suggestionBox.hide();
371 + isShowing = false;
372 + }
373 + return;
374 + };
375 +
376 + let suggestionPromise = getSuggestions(val.slice(0, 3), url);
377 + suggestionPromise.then((suggestions) => {
378 + if (val.length > 2) {
379 + suggestions = suggestions.filter((item) => {
380 + return item.toLowerCase().indexOf(val.toLowerCase()) !== -1;
381 + }).slice(0, 4);
382 + displaySuggestions($input, suggestions);
383 + }
384 + });
385 + });
386 +
387 + // Hide autosuggestions when input loses focus.
388 + // Slight delay to allow clicks.
389 + elem.on('blur', '[autosuggest]', function(event) {
390 + setTimeout(() => {
391 + $suggestionBox.hide();
392 + isShowing = false;
393 + }, 200)
394 + });
395 +
396 + elem.on('keydown', '[autosuggest]', function (event) {
397 + if (!isShowing) return;
398 +
399 + let suggestionElems = suggestionBox.childNodes;
400 + let suggestCount = suggestionElems.length;
401 +
402 + // Down arrow
403 + if (event.keyCode === 40) {
404 + let newActive = (active === suggestCount-1) ? 0 : active + 1;
405 + changeActiveTo(newActive, suggestionElems);
406 + }
407 + // Up arrow
408 + else if (event.keyCode === 38) {
409 + let newActive = (active === 0) ? suggestCount-1 : active - 1;
410 + changeActiveTo(newActive, suggestionElems);
411 + }
412 + // Enter key
413 + else if (event.keyCode === 13) {
414 + let text = suggestionElems[active].textContent;
415 + currentInput[0].value = text;
416 + currentInput.focus();
417 + $suggestionBox.hide();
418 + isShowing = false;
419 + event.preventDefault();
420 + return false;
421 + }
422 + });
423 +
424 + // Change the active suggestion to the given index
425 + function changeActiveTo(index, suggestionElems) {
426 + suggestionElems[active].className = '';
427 + active = index;
428 + suggestionElems[active].className = 'active';
429 + }
430 +
431 + // Display suggestions on a field
432 + let prevSuggestions = [];
433 + function displaySuggestions($input, suggestions) {
434 +
435 + // Hide if no suggestions
436 + if (suggestions.length === 0) {
437 + $suggestionBox.hide();
438 + isShowing = false;
439 + prevSuggestions = suggestions;
440 + return;
441 + }
442 +
443 + // Otherwise show and attach to input
444 + if (!isShowing) {
445 + $suggestionBox.show();
446 + isShowing = true;
447 + }
448 + if ($input !== currentInput) {
449 + $suggestionBox.detach();
450 + $input.after($suggestionBox);
451 + currentInput = $input;
452 + }
453 +
454 + // Return if no change
455 + if (prevSuggestions.join() === suggestions.join()) {
456 + prevSuggestions = suggestions;
457 + return;
458 + }
459 +
460 + // Build suggestions
461 + $suggestionBox[0].innerHTML = '';
462 + for (let i = 0; i < suggestions.length; i++) {
463 + var suggestion = document.createElement('li');
464 + suggestion.textContent = suggestions[i];
465 + suggestion.onclick = suggestionClick;
466 + if (i === 0) {
467 + suggestion.className = 'active'
468 + active = 0;
469 + };
470 + $suggestionBox[0].appendChild(suggestion);
471 + }
472 +
473 + prevSuggestions = suggestions;
474 + }
475 +
476 + // Suggestion click event
477 + function suggestionClick(event) {
478 + let text = this.textContent;
479 + currentInput[0].value = text;
480 + currentInput.focus();
481 + $suggestionBox.hide();
482 + isShowing = false;
483 + };
484 +
485 + // Get suggestions & cache
486 + function getSuggestions(input, url) {
487 + let searchUrl = url + '?search=' + encodeURIComponent(input);
488 +
489 + // Get from local cache if exists
490 + if (localCache[searchUrl]) {
491 + return new Promise((resolve, reject) => {
492 + resolve(localCache[input]);
493 + });
494 + }
495 +
496 + return $http.get(searchUrl).then((response) => {
497 + localCache[input] = response.data;
498 + return response.data;
499 + });
500 + }
501 +
502 + }
503 + }
504 + }]);
505 +};
506 +
507 +
508 +
509 +
510 +
511 +
512 +
513 +
514 +
515 +
516 +
517 +
518 +
519 +
......
...@@ -200,6 +200,7 @@ ...@@ -200,6 +200,7 @@
200 .tags td { 200 .tags td {
201 padding-right: $-s; 201 padding-right: $-s;
202 padding-top: $-s; 202 padding-top: $-s;
203 + position: relative;
203 } 204 }
204 button.pos { 205 button.pos {
205 position: absolute; 206 position: absolute;
...@@ -269,6 +270,28 @@ ...@@ -269,6 +270,28 @@
269 } 270 }
270 .tag { 271 .tag {
271 padding: $-s; 272 padding: $-s;
273 + }
274 +}
272 275
276 +.suggestion-box {
277 + position: absolute;
278 + background-color: #FFF;
279 + border: 1px solid #BBB;
280 + box-shadow: $bs-light;
281 + list-style: none;
282 + z-index: 100;
283 + padding: 0;
284 + margin: 0;
285 + border-radius: 3px;
286 + li {
287 + display: block;
288 + padding: $-xs $-s;
289 + border-bottom: 1px solid #DDD;
290 + &:last-child {
291 + border-bottom: 0;
292 + }
293 + &.active {
294 + background-color: #EEE;
295 + }
273 } 296 }
274 } 297 }
...\ No newline at end of file ...\ No newline at end of file
......
...@@ -9,7 +9,7 @@ ...@@ -9,7 +9,7 @@
9 @section('content') 9 @section('content')
10 10
11 <div class="flex-fill flex"> 11 <div class="flex-fill flex">
12 - <form action="{{$page->getUrl()}}" data-page-id="{{ $page->id }}" method="POST" class="flex flex-fill"> 12 + <form action="{{$page->getUrl()}}" autocomplete="off" data-page-id="{{ $page->id }}" method="POST" class="flex flex-fill">
13 @if(!isset($isDraft)) 13 @if(!isset($isDraft))
14 <input type="hidden" name="_method" value="PUT"> 14 <input type="hidden" name="_method" value="PUT">
15 @endif 15 @endif
......
1 1
2 <div toolbox class="floating-toolbox"> 2 <div toolbox class="floating-toolbox">
3 +
3 <div class="tabs primary-background-light"> 4 <div class="tabs primary-background-light">
4 <span toolbox-toggle><i class="zmdi zmdi-caret-left-circle"></i></span> 5 <span toolbox-toggle><i class="zmdi zmdi-caret-left-circle"></i></span>
5 <span tab-button="tags" title="Page Tags" class="active"><i class="zmdi zmdi-tag"></i></span> 6 <span tab-button="tags" title="Page Tags" class="active"><i class="zmdi zmdi-tag"></i></span>
6 </div> 7 </div>
8 +
7 <div tab-content="tags" ng-controller="PageTagController" page-id="{{ $page->id or 0 }}"> 9 <div tab-content="tags" ng-controller="PageTagController" page-id="{{ $page->id or 0 }}">
8 <h4>Page Tags</h4> 10 <h4>Page Tags</h4>
9 <div class="padded tags"> 11 <div class="padded tags">
10 <p class="muted small">Add some tags to better categorise your content. <br> You can assign a value to a tag for more in-depth organisation.</p> 12 <p class="muted small">Add some tags to better categorise your content. <br> You can assign a value to a tag for more in-depth organisation.</p>
11 - <table class="no-style" style="width: 100%;"> 13 + <table class="no-style" autosuggestions style="width: 100%;">
12 <tbody ui-sortable="sortOptions" ng-model="tags" > 14 <tbody ui-sortable="sortOptions" ng-model="tags" >
13 <tr ng-repeat="tag in tags track by $index"> 15 <tr ng-repeat="tag in tags track by $index">
14 <td width="20" ><i class="handle zmdi zmdi-menu"></i></td> 16 <td width="20" ><i class="handle zmdi zmdi-menu"></i></td>
15 - <td><input class="outline" ng-attr-name="tags[@{{$index}}][name]" type="text" ng-model="tag.name" ng-change="tagChange(tag)" ng-blur="tagBlur(tag)" placeholder="Tag"></td> 17 + <td><input autosuggest="/ajax/tags/suggest/names" class="outline" ng-attr-name="tags[@{{$index}}][name]" type="text" ng-model="tag.name" ng-change="tagChange(tag)" ng-blur="tagBlur(tag)" placeholder="Tag"></td>
16 - <td><input class="outline" ng-attr-name="tags[@{{$index}}][value]" type="text" ng-model="tag.value" ng-change="tagChange(tag)" ng-blur="tagBlur(tag)" placeholder="Tag Value (Optional)"></td> 18 + <td><input autosuggest="/ajax/tags/suggest/values" class="outline" ng-attr-name="tags[@{{$index}}][value]" type="text" ng-model="tag.value" ng-change="tagChange(tag)" ng-blur="tagBlur(tag)" placeholder="Tag Value (Optional)"></td>
17 <td width="10" ng-show="tags.length != 1" class="text-center text-neg" style="padding: 0;" ng-click="removeTag(tag)"><i class="zmdi zmdi-close"></i></td> 19 <td width="10" ng-show="tags.length != 1" class="text-center text-neg" style="padding: 0;" ng-click="removeTag(tag)"><i class="zmdi zmdi-close"></i></td>
18 </tr> 20 </tr>
19 </tbody> 21 </tbody>
...@@ -31,4 +33,5 @@ ...@@ -31,4 +33,5 @@
31 </table> 33 </table>
32 </div> 34 </div>
33 </div> 35 </div>
36 +
34 </div> 37 </div>
...\ No newline at end of file ...\ No newline at end of file
......
...@@ -52,10 +52,28 @@ class TagTests extends \TestCase ...@@ -52,10 +52,28 @@ class TagTests extends \TestCase
52 $attrs = $attrs->merge(factory(Tag::class, 5)->make(['name' => 'plans'])); 52 $attrs = $attrs->merge(factory(Tag::class, 5)->make(['name' => 'plans']));
53 $page = $this->getPageWithTags($attrs); 53 $page = $this->getPageWithTags($attrs);
54 54
55 - $this->asAdmin()->get('/ajax/tags/suggest?search=dog')->seeJsonEquals([]); 55 + $this->asAdmin()->get('/ajax/tags/suggest/names?search=dog')->seeJsonEquals([]);
56 - $this->get('/ajax/tags/suggest?search=co')->seeJsonEquals(['color', 'country', 'county']); 56 + $this->get('/ajax/tags/suggest/names?search=co')->seeJsonEquals(['color', 'country', 'county']);
57 - $this->get('/ajax/tags/suggest?search=cou')->seeJsonEquals(['country', 'county']); 57 + $this->get('/ajax/tags/suggest/names?search=cou')->seeJsonEquals(['country', 'county']);
58 - $this->get('/ajax/tags/suggest?search=pla')->seeJsonEquals(['planet', 'plans']); 58 + $this->get('/ajax/tags/suggest/names?search=pla')->seeJsonEquals(['planet', 'plans']);
59 + }
60 +
61 + public function test_tag_value_suggestions()
62 + {
63 + // Create some tags with similar values to test with
64 + $attrs = collect();
65 + $attrs = $attrs->merge(factory(Tag::class, 5)->make(['name' => 'country', 'value' => 'cats']));
66 + $attrs = $attrs->merge(factory(Tag::class, 5)->make(['name' => 'color', 'value' => 'cattery']));
67 + $attrs = $attrs->merge(factory(Tag::class, 5)->make(['name' => 'city', 'value' => 'castle']));
68 + $attrs = $attrs->merge(factory(Tag::class, 5)->make(['name' => 'county', 'value' => 'dog']));
69 + $attrs = $attrs->merge(factory(Tag::class, 5)->make(['name' => 'planet', 'value' => 'catapult']));
70 + $attrs = $attrs->merge(factory(Tag::class, 5)->make(['name' => 'plans', 'value' => 'dodgy']));
71 + $page = $this->getPageWithTags($attrs);
72 +
73 + $this->asAdmin()->get('/ajax/tags/suggest/values?search=ora')->seeJsonEquals([]);
74 + $this->get('/ajax/tags/suggest/values?search=cat')->seeJsonEquals(['cats', 'cattery', 'catapult']);
75 + $this->get('/ajax/tags/suggest/values?search=do')->seeJsonEquals(['dog', 'dodgy']);
76 + $this->get('/ajax/tags/suggest/values?search=cas')->seeJsonEquals(['castle']);
59 } 77 }
60 78
61 public function test_entity_permissions_effect_tag_suggestions() 79 public function test_entity_permissions_effect_tag_suggestions()
......