Now tags load with the page, not via AJAX.
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');
* 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');
$query = $query->orderBy('count', 'desc')->take(50);
}
- if ($tagName !== false) {
+ if ($tagName) {
$query = $query->where('name', '=', $tagName);
}
/**
* 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]);
}
}
/**
* TagController constructor.
- * @param $tagRepo
*/
public function __construct(TagRepo $tagRepo)
{
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);
}
import {onChildEvent} from "../services/dom";
+import {uniqueId} from "../services/util";
/**
* 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
this.input = this.$refs.input;
this.list = this.$refs.list;
+ this.lastPopulated = 0;
this.setupListeners();
}
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();
}
}
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) {
}
openIfContainsError() {
- const error = this.content.querySelector('.text-neg');
+ const error = this.content.querySelector('.text-neg.text-small');
if (error) {
this.open();
}
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') {
--- /dev/null
+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
--- /dev/null
+/**
+ * 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
.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
+++ /dev/null
-
-const template = `
- <div>
- <input :value="value" :autosuggest-type="type" ref="input"
- :placeholder="placeholder"
- :name="name"
- type="text"
- @input="inputUpdate($event.target.value)"
- @focus="inputUpdate($event.target.value)"
- @blur="inputBlur"
- @keydown="inputKeydown"
- :aria-label="placeholder"
- autocomplete="off"
- />
- <ul class="suggestion-box" v-if="showSuggestions">
- <li v-for="(suggestion, i) in suggestions"
- @click="selectSuggestion(suggestion)"
- :class="{active: (i === active)}">{{suggestion}}</li>
- </ul>
- </div>
-`;
-
-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
+++ /dev/null
-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
}
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,
};
<label for="tag-manager">{{ trans('entities.book_tags') }}</label>
</button>
<div class="collapse-content" collapsible-content>
- @include('components.tag-manager', ['entity' => $book ?? null, 'entityType' => 'chapter'])
+ @include('components.tag-manager', ['entity' => $book ?? null])
</div>
</div>
<label for="tags">{{ trans('entities.chapter_tags') }}</label>
</button>
<div class="collapse-content" collapsible-content>
- @include('components.tag-manager', ['entity' => isset($chapter)?$chapter:null, 'entityType' => 'chapter'])
+ @include('components.tag-manager', ['entity' => $chapter ?? null])
</div>
</div>
</div>
</div>
- @include('components.tag-manager', ['entity' => \BookStack\Entities\Book::find(1), 'entityType' => 'book'])
-
-
@stop
-@foreach(array_merge($tags, [new \BookStack\Actions\Tag]) as $index => $tag)
- <div class="card drag-card">
+@foreach(array_merge($tags, [null, null]) as $index => $tag)
+ <div class="card drag-card {{ $loop->last ? 'hidden' : '' }}" @if($loop->last) refs="add-remove-rows@model" @endif>
<div class="handle">@icon('grip')</div>
@foreach(['name', 'value'] as $type)
<div component="auto-suggest"
<input value="{{ $tag->$type ?? '' }}"
placeholder="{{ trans('entities.tag_' . $type) }}"
aria-label="{{ trans('entities.tag_' . $type) }}"
- name="tags[{{ $index }}][{{ $type }}]"
+ name="tags[{{ $loop->parent->last ? 'randrowid' : $index }}][{{ $type }}]"
type="text"
refs="auto-suggest@input"
autocomplete="off"/>
<ul refs="auto-suggest@list" class="suggestion-box dropdown-menu"></ul>
</div>
@endforeach
- <button refs="tag-manager@remove" type="button"
+ <button type="button"
aria-label="{{ trans('entities.tags_remove') }}"
- class="text-center drag-card-action text-neg {{ count($tags) > 0 ? '' : 'hidden' }}">
+ class="text-center drag-card-action text-neg">
@icon('close')
</button>
</div>
-<div id="tag-manager" entity-id="{{ $entity->id ?? 0 }}" entity-type="{{ $entity ? $entity->getType() : $entityType }}">
- <div class="tags">
- <p class="text-muted small">{!! nl2br(e(trans('entities.tags_explain'))) !!}</p>
+<div components="tag-manager add-remove-rows"
+ option:add-remove-rows:row-selector=".card"
+ option:add-remove-rows:remove-selector="button.text-neg"
+ option:tag-manager:row-selector=".card:not(.hidden)"
+ refs="tag-manager@add-remove"
+ class="tags">
- @include('components.tag-manager-list', ['tags' => $entity->tags->all() ?? []])
+ <p class="text-muted small">{!! nl2br(e(trans('entities.tags_explain'))) !!}</p>
- <draggable :options="{handle: '.handle'}" :list="tags" element="div">
- <div v-for="(tag, i) in tags" :key="tag.key" class="card drag-card">
- <div class="handle" >@icon('grip')</div>
- <div>
- <autosuggest url="{{ url('/ajax/tags/suggest/names') }}" type="name" class="outline" :name="getTagFieldName(i, 'name')"
- v-model="tag.name" @input="tagChange(tag)" @blur="tagBlur(tag)" placeholder="{{ trans('entities.tag_name') }}"/>
- </div>
- <div>
- <autosuggest url="{{ url('/ajax/tags/suggest/values') }}" type="value" class="outline" :name="getTagFieldName(i, 'value')"
- v-model="tag.value" @change="tagChange(tag)" @blur="tagBlur(tag)" placeholder="{{ trans('entities.tag_value') }}"/>
- </div>
- <button type="button" aria-label="{{ trans('entities.tags_remove') }}" v-show="tags.length !== 1" class="text-center drag-card-action text-neg" @click="removeTag(tag)">@icon('close')</button>
- </div>
- </draggable>
+ <div component="sortable-list"
+ option:sortable-list:handle-selector=".handle">
+ @include('components.tag-manager-list', ['tags' => $entity->tags->all() ?? []])
+ </div>
- <button @click="addEmptyTag" type="button" class="text-button">{{ trans('entities.tags_add') }}</button>
- </div>
+ <button refs="add-remove-rows@add" type="button" class="text-button">{{ trans('entities.tags_add') }}</button>
</div>
\ No newline at end of file
<div toolbox-tab-content="tags">
<h4>{{ trans('entities.page_tags') }}</h4>
<div class="px-l">
- @include('components.tag-manager', ['entity' => $page, 'entityType' => 'page'])
+ @include('components.tag-manager', ['entity' => $page])
</div>
</div>
--}}
<table component="add-remove-rows"
option:add-remove-rows:remove-selector="button.text-neg"
+ option:add-remove-rows:row-selector="tr"
class="no-style">
@foreach(array_merge($currentList, ['']) as $term)
<tr @if(empty($term)) class="hidden" refs="add-remove-rows@model" @endif>
<label for="tag-manager">{{ trans('entities.shelf_tags') }}</label>
</button>
<div class="collapse-content" collapsible-content>
- @include('components.tag-manager', ['entity' => $shelf ?? null, 'entityType' => 'bookshelf'])
+ @include('components.tag-manager', ['entity' => $shelf ?? null])
</div>
</div>
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');
});