Showing
8 changed files
with
253 additions
and
9 deletions
| ... | @@ -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 | + 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 | + }]); | ||
| 342 | }; | 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() | ... | ... |
-
Please register or sign in to post a comment