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 = ` -