From 573c4e26d515babb4bdbb732d1e39a0ae614cec9 Mon Sep 17 00:00:00 2001 From: Dan Brown Date: Mon, 29 Jun 2020 22:11:03 +0100 Subject: [PATCH] Finished moving tag-manager from a vue to a component Now tags load with the page, not via AJAX. --- app/Actions/TagRepo.php | 85 +++-------- app/Http/Controllers/TagController.php | 23 +-- resources/js/components/add-remove-rows.js | 35 ++++- resources/js/components/auto-suggest.js | 10 +- resources/js/components/collapsible.js | 2 +- resources/js/components/index.js | 11 +- resources/js/components/sortable-list.js | 19 +++ resources/js/components/tag-manager.js | 32 +++++ resources/js/services/util.js | 10 ++ resources/js/vues/components/autosuggest.js | 134 ------------------ resources/js/vues/tag-manager.js | 68 --------- resources/js/vues/vues.js | 2 - resources/views/books/form.blade.php | 2 +- resources/views/chapters/form.blade.php | 2 +- resources/views/common/home.blade.php | 3 - .../components/tag-manager-list.blade.php | 10 +- .../views/components/tag-manager.blade.php | 32 ++--- .../views/pages/editor-toolbox.blade.php | 2 +- .../views/search/form/term-list.blade.php | 1 + resources/views/shelves/form.blade.php | 2 +- routes/web.php | 3 +- 21 files changed, 153 insertions(+), 335 deletions(-) create mode 100644 resources/js/components/sortable-list.js create mode 100644 resources/js/components/tag-manager.js delete mode 100644 resources/js/vues/components/autosuggest.js delete mode 100644 resources/js/vues/tag-manager.js diff --git a/app/Actions/TagRepo.php b/app/Actions/TagRepo.php index b8b1eb464..0297d8bc6 100644 --- a/app/Actions/TagRepo.php +++ b/app/Actions/TagRepo.php @@ -2,71 +2,31 @@ use BookStack\Auth\Permissions\PermissionService; use BookStack\Entities\Entity; +use DB; +use Illuminate\Support\Collection; -/** - * Class TagRepo - * @package BookStack\Repos - */ class TagRepo { protected $tag; - protected $entity; protected $permissionService; /** * TagRepo constructor. - * @param \BookStack\Actions\Tag $attr - * @param \BookStack\Entities\Entity $ent - * @param \BookStack\Auth\Permissions\PermissionService $ps */ - public function __construct(Tag $attr, Entity $ent, PermissionService $ps) + public function __construct(Tag $tag, PermissionService $ps) { - $this->tag = $attr; - $this->entity = $ent; + $this->tag = $tag; $this->permissionService = $ps; } - /** - * Get an entity instance of its particular type. - * @param $entityType - * @param $entityId - * @param string $action - * @return \Illuminate\Database\Eloquent\Model|null|static - */ - public function getEntity($entityType, $entityId, $action = 'view') - { - $entityInstance = $this->entity->getEntityInstance($entityType); - $searchQuery = $entityInstance->where('id', '=', $entityId)->with('tags'); - $searchQuery = $this->permissionService->enforceEntityRestrictions($entityType, $searchQuery, $action); - return $searchQuery->first(); - } - - /** - * Get all tags for a particular entity. - * @param string $entityType - * @param int $entityId - * @return mixed - */ - public function getForEntity($entityType, $entityId) - { - $entity = $this->getEntity($entityType, $entityId); - if ($entity === null) { - return collect(); - } - - return $entity->tags; - } - /** * Get tag name suggestions from scanning existing tag names. * If no search term is given the 50 most popular tag names are provided. - * @param $searchTerm - * @return array */ - public function getNameSuggestions($searchTerm = false) + public function getNameSuggestions(?string $searchTerm): Collection { - $query = $this->tag->select('*', \DB::raw('count(*) as count'))->groupBy('name'); + $query = $this->tag->select('*', DB::raw('count(*) as count'))->groupBy('name'); if ($searchTerm) { $query = $query->where('name', 'LIKE', $searchTerm . '%')->orderBy('name', 'desc'); @@ -82,13 +42,10 @@ class TagRepo * Get tag value suggestions from scanning existing tag values. * If no search is given the 50 most popular values are provided. * Passing a tagName will only find values for a tags with a particular name. - * @param $searchTerm - * @param $tagName - * @return array */ - public function getValueSuggestions($searchTerm = false, $tagName = false) + public function getValueSuggestions(?string $searchTerm, ?string $tagName): Collection { - $query = $this->tag->select('*', \DB::raw('count(*) as count'))->groupBy('value'); + $query = $this->tag->select('*', DB::raw('count(*) as count'))->groupBy('value'); if ($searchTerm) { $query = $query->where('value', 'LIKE', $searchTerm . '%')->orderBy('value', 'desc'); @@ -96,7 +53,7 @@ class TagRepo $query = $query->orderBy('count', 'desc')->take(50); } - if ($tagName !== false) { + if ($tagName) { $query = $query->where('name', '=', $tagName); } @@ -106,34 +63,28 @@ class TagRepo /** * Save an array of tags to an entity - * @return array|\Illuminate\Database\Eloquent\Collection */ - public function saveTagsToEntity(Entity $entity, array $tags = []) + public function saveTagsToEntity(Entity $entity, array $tags = []): iterable { $entity->tags()->delete(); - $newTags = []; - foreach ($tags as $tag) { - if (trim($tag['name']) === '') { - continue; - } - $newTags[] = $this->newInstanceFromInput($tag); - } + $newTags = collect($tags)->filter(function ($tag) { + return boolval(trim($tag['name'])); + })->map(function ($tag) { + return $this->newInstanceFromInput($tag); + })->all(); return $entity->tags()->saveMany($newTags); } /** * Create a new Tag instance from user input. - * @param $input - * @return \BookStack\Actions\Tag + * Input must be an array with a 'name' and an optional 'value' key. */ - protected function newInstanceFromInput($input) + protected function newInstanceFromInput(array $input): Tag { $name = trim($input['name']); $value = isset($input['value']) ? trim($input['value']) : ''; - // Any other modification or cleanup required can go here - $values = ['name' => $name, 'value' => $value]; - return $this->tag->newInstance($values); + return $this->tag->newInstance(['name' => $name, 'value' => $value]); } } diff --git a/app/Http/Controllers/TagController.php b/app/Http/Controllers/TagController.php index 6abbeeeba..8c6d6748f 100644 --- a/app/Http/Controllers/TagController.php +++ b/app/Http/Controllers/TagController.php @@ -10,7 +10,6 @@ class TagController extends Controller /** * TagController constructor. - * @param $tagRepo */ public function __construct(TagRepo $tagRepo) { @@ -18,39 +17,23 @@ class TagController extends Controller parent::__construct(); } - /** - * Get all the Tags for a particular entity - * @param $entityType - * @param $entityId - * @return \Illuminate\Http\JsonResponse - */ - public function getForEntity($entityType, $entityId) - { - $tags = $this->tagRepo->getForEntity($entityType, $entityId); - return response()->json($tags); - } - /** * Get tag name suggestions from a given search term. - * @param Request $request - * @return \Illuminate\Http\JsonResponse */ public function getNameSuggestions(Request $request) { - $searchTerm = $request->get('search', false); + $searchTerm = $request->get('search', null); $suggestions = $this->tagRepo->getNameSuggestions($searchTerm); return response()->json($suggestions); } /** * Get tag value suggestions from a given search term. - * @param Request $request - * @return \Illuminate\Http\JsonResponse */ public function getValueSuggestions(Request $request) { - $searchTerm = $request->get('search', false); - $tagName = $request->get('name', false); + $searchTerm = $request->get('search', null); + $tagName = $request->get('name', null); $suggestions = $this->tagRepo->getValueSuggestions($searchTerm, $tagName); return response()->json($suggestions); } diff --git a/resources/js/components/add-remove-rows.js b/resources/js/components/add-remove-rows.js index 81eeb43c4..9a5f019c5 100644 --- a/resources/js/components/add-remove-rows.js +++ b/resources/js/components/add-remove-rows.js @@ -1,4 +1,5 @@ import {onChildEvent} from "../services/dom"; +import {uniqueId} from "../services/util"; /** * AddRemoveRows @@ -11,21 +12,43 @@ class AddRemoveRows { this.modelRow = this.$refs.model; this.addButton = this.$refs.add; this.removeSelector = this.$opts.removeSelector; + this.rowSelector = this.$opts.rowSelector; this.setupListeners(); } setupListeners() { - this.addButton.addEventListener('click', e => { - const clone = this.modelRow.cloneNode(true); - clone.classList.remove('hidden'); - this.modelRow.parentNode.insertBefore(clone, this.modelRow); - }); + this.addButton.addEventListener('click', this.add.bind(this)); onChildEvent(this.$el, this.removeSelector, 'click', (e) => { - const row = e.target.closest('tr'); + const row = e.target.closest(this.rowSelector); row.remove(); }); } + + // For external use + add() { + const clone = this.modelRow.cloneNode(true); + clone.classList.remove('hidden'); + this.setClonedInputNames(clone); + this.modelRow.parentNode.insertBefore(clone, this.modelRow); + window.components.init(clone); + } + + /** + * Update the HTML names of a clone to be unique if required. + * Names can use placeholder values. For exmaple, a model row + * may have name="tags[randrowid][name]". + * These are the available placeholder values: + * - randrowid - An random string ID, applied the same across the row. + * @param {HTMLElement} clone + */ + setClonedInputNames(clone) { + const rowId = uniqueId(); + const randRowIdElems = clone.querySelectorAll(`[name*="randrowid"]`); + for (const elem of randRowIdElems) { + elem.name = elem.name.split('randrowid').join(rowId); + } + } } export default AddRemoveRows; \ No newline at end of file diff --git a/resources/js/components/auto-suggest.js b/resources/js/components/auto-suggest.js index 7fce09890..68de49b4a 100644 --- a/resources/js/components/auto-suggest.js +++ b/resources/js/components/auto-suggest.js @@ -16,6 +16,7 @@ class AutoSuggest { this.input = this.$refs.input; this.list = this.$refs.list; + this.lastPopulated = 0; this.setupListeners(); } @@ -44,7 +45,10 @@ class AutoSuggest { selectSuggestion(value) { this.input.value = value; + this.lastPopulated = Date.now(); this.input.focus(); + this.input.dispatchEvent(new Event('input', {bubbles: true})); + this.input.dispatchEvent(new Event('change', {bubbles: true})); this.hideSuggestions(); } @@ -79,8 +83,12 @@ class AutoSuggest { } async requestSuggestions() { + if (Date.now() - this.lastPopulated < 50) { + return; + } + const nameFilter = this.getNameFilterIfNeeded(); - const search = this.input.value.slice(0, 3); + const search = this.input.value.slice(0, 3).toLowerCase(); const suggestions = await this.loadSuggestions(search, nameFilter); let toShow = suggestions.slice(0, 6); if (search.length > 0) { diff --git a/resources/js/components/collapsible.js b/resources/js/components/collapsible.js index a630f38f2..544f91008 100644 --- a/resources/js/components/collapsible.js +++ b/resources/js/components/collapsible.js @@ -37,7 +37,7 @@ class Collapsible { } openIfContainsError() { - const error = this.content.querySelector('.text-neg'); + const error = this.content.querySelector('.text-neg.text-small'); if (error) { this.open(); } diff --git a/resources/js/components/index.js b/resources/js/components/index.js index 1cea8949e..68f97b280 100644 --- a/resources/js/components/index.js +++ b/resources/js/components/index.js @@ -70,13 +70,20 @@ function initComponent(name, element) { function parseRefs(name, element) { const refs = {}; const manyRefs = {}; + const prefix = `${name}@` - const refElems = element.querySelectorAll(`[refs*="${prefix}"]`); + const selector = `[refs*="${prefix}"]`; + const refElems = [...element.querySelectorAll(selector)]; + if (element.matches(selector)) { + refElems.push(element); + } + for (const el of refElems) { const refNames = el.getAttribute('refs') .split(' ') .filter(str => str.startsWith(prefix)) - .map(str => str.replace(prefix, '')); + .map(str => str.replace(prefix, '')) + .map(kebabToCamel); for (const ref of refNames) { refs[ref] = el; if (typeof manyRefs[ref] === 'undefined') { diff --git a/resources/js/components/sortable-list.js b/resources/js/components/sortable-list.js new file mode 100644 index 000000000..6efcb4e84 --- /dev/null +++ b/resources/js/components/sortable-list.js @@ -0,0 +1,19 @@ +import Sortable from "sortablejs"; + +/** + * SortableList + * @extends {Component} + */ +class SortableList { + setup() { + this.container = this.$el; + this.handleSelector = this.$opts.handleSelector; + + new Sortable(this.container, { + handle: this.handleSelector, + animation: 150, + }); + } +} + +export default SortableList; \ No newline at end of file diff --git a/resources/js/components/tag-manager.js b/resources/js/components/tag-manager.js new file mode 100644 index 000000000..99302b6c0 --- /dev/null +++ b/resources/js/components/tag-manager.js @@ -0,0 +1,32 @@ +/** + * TagManager + * @extends {Component} + */ +class TagManager { + setup() { + this.addRemoveComponentEl = this.$refs.addRemove; + this.container = this.$el; + this.rowSelector = this.$opts.rowSelector; + + this.setupListeners(); + } + + setupListeners() { + this.container.addEventListener('change', event => { + const addRemoveComponent = this.addRemoveComponentEl.components['add-remove-rows']; + if (!this.hasEmptyRows()) { + addRemoveComponent.add(); + } + }); + } + + hasEmptyRows() { + const rows = this.container.querySelectorAll(this.rowSelector); + const firstEmpty = [...rows].find(row => { + return [...row.querySelectorAll('input')].filter(input => input.value).length === 0; + }); + return firstEmpty !== undefined; + } +} + +export default TagManager; \ No newline at end of file diff --git a/resources/js/services/util.js b/resources/js/services/util.js index b44b7de6c..de2ca20c1 100644 --- a/resources/js/services/util.js +++ b/resources/js/services/util.js @@ -60,4 +60,14 @@ export function escapeHtml(unsafe) { .replace(/>/g, ">") .replace(/"/g, """) .replace(/'/g, "'"); +} + +/** + * Generate a random unique ID. + * + * @returns {string} + */ +export function uniqueId() { + const S4 = () => (((1+Math.random())*0x10000)|0).toString(16).substring(1); + return (S4()+S4()+"-"+S4()+"-"+S4()+"-"+S4()+"-"+S4()+S4()+S4()); } \ No newline at end of file diff --git a/resources/js/vues/components/autosuggest.js b/resources/js/vues/components/autosuggest.js deleted file mode 100644 index f4bb3d815..000000000 --- a/resources/js/vues/components/autosuggest.js +++ /dev/null @@ -1,134 +0,0 @@ - -const template = ` -
- - -
-`; - -function data() { - return { - suggestions: [], - showSuggestions: false, - active: 0, - }; -} - -const ajaxCache = {}; - -const props = ['url', 'type', 'value', 'placeholder', 'name']; - -function getNameInputVal(valInput) { - let parentRow = valInput.parentNode.parentNode; - let nameInput = parentRow.querySelector('[autosuggest-type="name"]'); - return (nameInput === null) ? '' : nameInput.value; -} - -const methods = { - - inputUpdate(inputValue) { - this.$emit('input', inputValue); - let params = {}; - - if (this.type === 'value') { - let nameVal = getNameInputVal(this.$el); - if (nameVal !== "") params.name = nameVal; - } - - this.getSuggestions(inputValue.slice(0, 3), params).then(suggestions => { - if (inputValue.length === 0) { - this.displaySuggestions(suggestions.slice(0, 6)); - return; - } - // Filter to suggestions containing searched term - suggestions = suggestions.filter(item => { - return item.toLowerCase().indexOf(inputValue.toLowerCase()) !== -1; - }).slice(0, 4); - this.displaySuggestions(suggestions); - }); - }, - - inputBlur() { - setTimeout(() => { - this.$emit('blur'); - this.showSuggestions = false; - }, 100); - }, - - inputKeydown(event) { - if (event.key === 'Enter') event.preventDefault(); - if (!this.showSuggestions) return; - - // Down arrow - if (event.key === 'ArrowDown') { - this.active = (this.active === this.suggestions.length - 1) ? 0 : this.active+1; - } - // Up Arrow - else if (event.key === 'ArrowUp') { - this.active = (this.active === 0) ? this.suggestions.length - 1 : this.active-1; - } - // Enter key - else if ((event.key === 'Enter') && !event.shiftKey) { - this.selectSuggestion(this.suggestions[this.active]); - } - // Escape key - else if (event.key === 'Escape') { - this.showSuggestions = false; - } - }, - - displaySuggestions(suggestions) { - if (suggestions.length === 0) { - this.suggestions = []; - this.showSuggestions = false; - return; - } - - this.suggestions = suggestions; - this.showSuggestions = true; - this.active = 0; - }, - - selectSuggestion(suggestion) { - this.$refs.input.value = suggestion; - this.$refs.input.focus(); - this.$emit('input', suggestion); - this.showSuggestions = false; - }, - - /** - * Get suggestions from BookStack. Store and use local cache if already searched. - * @param {String} input - * @param {Object} params - */ - getSuggestions(input, params) { - params.search = input; - const cacheKey = `${this.url}:${JSON.stringify(params)}`; - - if (typeof ajaxCache[cacheKey] !== "undefined") { - return Promise.resolve(ajaxCache[cacheKey]); - } - - return this.$http.get(this.url, params).then(resp => { - ajaxCache[cacheKey] = resp.data; - return resp.data; - }); - } - -}; - -export default {template, data, props, methods}; \ No newline at end of file diff --git a/resources/js/vues/tag-manager.js b/resources/js/vues/tag-manager.js deleted file mode 100644 index 65233cbb6..000000000 --- a/resources/js/vues/tag-manager.js +++ /dev/null @@ -1,68 +0,0 @@ -import draggable from 'vuedraggable'; -import autosuggest from './components/autosuggest'; - -const data = { - entityId: false, - entityType: null, - tags: [], -}; - -const components = {draggable, autosuggest}; -const directives = {}; - -const methods = { - - addEmptyTag() { - this.tags.push({name: '', value: '', key: Math.random().toString(36).substring(7)}); - }, - - /** - * When an tag changes check if another empty editable field needs to be added onto the end. - * @param tag - */ - tagChange(tag) { - let tagPos = this.tags.indexOf(tag); - if (tagPos === this.tags.length-1 && (tag.name !== '' || tag.value !== '')) this.addEmptyTag(); - }, - - /** - * When an tag field loses focus check the tag to see if its - * empty and therefore could be removed from the list. - * @param tag - */ - tagBlur(tag) { - let isLast = (this.tags.indexOf(tag) === this.tags.length-1); - if (tag.name !== '' || tag.value !== '' || isLast) return; - let cPos = this.tags.indexOf(tag); - this.tags.splice(cPos, 1); - }, - - removeTag(tag) { - let tagPos = this.tags.indexOf(tag); - if (tagPos === -1) return; - this.tags.splice(tagPos, 1); - }, - - getTagFieldName(index, key) { - return `tags[${index}][${key}]`; - }, -}; - -function mounted() { - this.entityId = Number(this.$el.getAttribute('entity-id')); - this.entityType = this.$el.getAttribute('entity-type'); - - let url = window.baseUrl(`/ajax/tags/get/${this.entityType}/${this.entityId}`); - this.$http.get(url).then(response => { - let tags = response.data; - for (let i = 0, len = tags.length; i < len; i++) { - tags[i].key = Math.random().toString(36).substring(7); - } - this.tags = tags; - this.addEmptyTag(); - }); -} - -export default { - data, methods, mounted, components, directives -}; \ No newline at end of file diff --git a/resources/js/vues/vues.js b/resources/js/vues/vues.js index f18724929..0d3817f0e 100644 --- a/resources/js/vues/vues.js +++ b/resources/js/vues/vues.js @@ -5,13 +5,11 @@ function exists(id) { } import imageManager from "./image-manager"; -import tagManager from "./tag-manager"; import attachmentManager from "./attachment-manager"; import pageEditor from "./page-editor"; let vueMapping = { 'image-manager': imageManager, - 'tag-manager': tagManager, 'attachment-manager': attachmentManager, 'page-editor': pageEditor, }; diff --git a/resources/views/books/form.blade.php b/resources/views/books/form.blade.php index a3235036e..840d0604c 100644 --- a/resources/views/books/form.blade.php +++ b/resources/views/books/form.blade.php @@ -31,7 +31,7 @@
- @include('components.tag-manager', ['entity' => $book ?? null, 'entityType' => 'chapter']) + @include('components.tag-manager', ['entity' => $book ?? null])
diff --git a/resources/views/chapters/form.blade.php b/resources/views/chapters/form.blade.php index cd240e685..60cfe6674 100644 --- a/resources/views/chapters/form.blade.php +++ b/resources/views/chapters/form.blade.php @@ -16,7 +16,7 @@
- @include('components.tag-manager', ['entity' => isset($chapter)?$chapter:null, 'entityType' => 'chapter']) + @include('components.tag-manager', ['entity' => $chapter ?? null])
diff --git a/resources/views/common/home.blade.php b/resources/views/common/home.blade.php index 7df6d4ce6..2631f1a57 100644 --- a/resources/views/common/home.blade.php +++ b/resources/views/common/home.blade.php @@ -66,7 +66,4 @@ - @include('components.tag-manager', ['entity' => \BookStack\Entities\Book::find(1), 'entityType' => 'book']) - - @stop diff --git a/resources/views/components/tag-manager-list.blade.php b/resources/views/components/tag-manager-list.blade.php index 99ee87782..6fbce2f88 100644 --- a/resources/views/components/tag-manager-list.blade.php +++ b/resources/views/components/tag-manager-list.blade.php @@ -1,5 +1,5 @@ -@foreach(array_merge($tags, [new \BookStack\Actions\Tag]) as $index => $tag) -
+@foreach(array_merge($tags, [null, null]) as $index => $tag) +
last) refs="add-remove-rows@model" @endif>
@icon('grip')
@foreach(['name', 'value'] as $type)
@endforeach -
diff --git a/resources/views/components/tag-manager.blade.php b/resources/views/components/tag-manager.blade.php index 0fab30d63..aad5fb9d6 100644 --- a/resources/views/components/tag-manager.blade.php +++ b/resources/views/components/tag-manager.blade.php @@ -1,24 +1,16 @@ -
-
+
+

{!! nl2br(e(trans('entities.tags_explain'))) !!}

- @include('components.tag-manager-list', ['tags' => $entity->tags->all() ?? []]) +
+ @include('components.tag-manager-list', ['tags' => $entity->tags->all() ?? []]) +
- -
-
@icon('grip')
-
- -
-
- -
- -
-
- - -
+
\ No newline at end of file diff --git a/resources/views/pages/editor-toolbox.blade.php b/resources/views/pages/editor-toolbox.blade.php index 6ea651820..3741c9246 100644 --- a/resources/views/pages/editor-toolbox.blade.php +++ b/resources/views/pages/editor-toolbox.blade.php @@ -12,7 +12,7 @@

{{ trans('entities.page_tags') }}

- @include('components.tag-manager', ['entity' => $page, 'entityType' => 'page']) + @include('components.tag-manager', ['entity' => $page])
diff --git a/resources/views/search/form/term-list.blade.php b/resources/views/search/form/term-list.blade.php index 435de73f1..3fbfa18fe 100644 --- a/resources/views/search/form/term-list.blade.php +++ b/resources/views/search/form/term-list.blade.php @@ -4,6 +4,7 @@ --}} @foreach(array_merge($currentList, ['']) as $term) diff --git a/resources/views/shelves/form.blade.php b/resources/views/shelves/form.blade.php index 19c5bbecd..e635455bf 100644 --- a/resources/views/shelves/form.blade.php +++ b/resources/views/shelves/form.blade.php @@ -60,7 +60,7 @@
- @include('components.tag-manager', ['entity' => $shelf ?? null, 'entityType' => 'bookshelf']) + @include('components.tag-manager', ['entity' => $shelf ?? null])
diff --git a/routes/web.php b/routes/web.php index 3e05e394d..6b7911825 100644 --- a/routes/web.php +++ b/routes/web.php @@ -134,8 +134,7 @@ Route::group(['middleware' => 'auth'], function () { Route::delete('/ajax/page/{id}', 'PageController@ajaxDestroy'); // Tag routes (AJAX) - Route::group(['prefix' => 'ajax/tags'], function() { - Route::get('/get/{entityType}/{entityId}', 'TagController@getForEntity'); + Route::group(['prefix' => 'ajax/tags'], function () { Route::get('/suggest/names', 'TagController@getNameSuggestions'); Route::get('/suggest/values', 'TagController@getValueSuggestions'); });