mirror of
https://github.com/BookStackApp/BookStack.git
synced 2025-04-17 08:49:00 +08:00
Finished moving tag-manager from a vue to a component
Now tags load with the page, not via AJAX.
This commit is contained in:
parent
4e107b9160
commit
573c4e26d5
@ -2,71 +2,31 @@
|
|||||||
|
|
||||||
use BookStack\Auth\Permissions\PermissionService;
|
use BookStack\Auth\Permissions\PermissionService;
|
||||||
use BookStack\Entities\Entity;
|
use BookStack\Entities\Entity;
|
||||||
|
use DB;
|
||||||
|
use Illuminate\Support\Collection;
|
||||||
|
|
||||||
/**
|
|
||||||
* Class TagRepo
|
|
||||||
* @package BookStack\Repos
|
|
||||||
*/
|
|
||||||
class TagRepo
|
class TagRepo
|
||||||
{
|
{
|
||||||
|
|
||||||
protected $tag;
|
protected $tag;
|
||||||
protected $entity;
|
|
||||||
protected $permissionService;
|
protected $permissionService;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* TagRepo constructor.
|
* 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->tag = $tag;
|
||||||
$this->entity = $ent;
|
|
||||||
$this->permissionService = $ps;
|
$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.
|
* Get tag name suggestions from scanning existing tag names.
|
||||||
* If no search term is given the 50 most popular tag names are provided.
|
* 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) {
|
if ($searchTerm) {
|
||||||
$query = $query->where('name', 'LIKE', $searchTerm . '%')->orderBy('name', 'desc');
|
$query = $query->where('name', 'LIKE', $searchTerm . '%')->orderBy('name', 'desc');
|
||||||
@ -82,13 +42,10 @@ class TagRepo
|
|||||||
* Get tag value suggestions from scanning existing tag values.
|
* Get tag value suggestions from scanning existing tag values.
|
||||||
* If no search is given the 50 most popular values are provided.
|
* 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.
|
* 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) {
|
if ($searchTerm) {
|
||||||
$query = $query->where('value', 'LIKE', $searchTerm . '%')->orderBy('value', 'desc');
|
$query = $query->where('value', 'LIKE', $searchTerm . '%')->orderBy('value', 'desc');
|
||||||
@ -96,7 +53,7 @@ class TagRepo
|
|||||||
$query = $query->orderBy('count', 'desc')->take(50);
|
$query = $query->orderBy('count', 'desc')->take(50);
|
||||||
}
|
}
|
||||||
|
|
||||||
if ($tagName !== false) {
|
if ($tagName) {
|
||||||
$query = $query->where('name', '=', $tagName);
|
$query = $query->where('name', '=', $tagName);
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -106,34 +63,28 @@ class TagRepo
|
|||||||
|
|
||||||
/**
|
/**
|
||||||
* Save an array of tags to an entity
|
* 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();
|
$entity->tags()->delete();
|
||||||
$newTags = [];
|
|
||||||
|
|
||||||
foreach ($tags as $tag) {
|
$newTags = collect($tags)->filter(function ($tag) {
|
||||||
if (trim($tag['name']) === '') {
|
return boolval(trim($tag['name']));
|
||||||
continue;
|
})->map(function ($tag) {
|
||||||
}
|
return $this->newInstanceFromInput($tag);
|
||||||
$newTags[] = $this->newInstanceFromInput($tag);
|
})->all();
|
||||||
}
|
|
||||||
|
|
||||||
return $entity->tags()->saveMany($newTags);
|
return $entity->tags()->saveMany($newTags);
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Create a new Tag instance from user input.
|
* Create a new Tag instance from user input.
|
||||||
* @param $input
|
* Input must be an array with a 'name' and an optional 'value' key.
|
||||||
* @return \BookStack\Actions\Tag
|
|
||||||
*/
|
*/
|
||||||
protected function newInstanceFromInput($input)
|
protected function newInstanceFromInput(array $input): Tag
|
||||||
{
|
{
|
||||||
$name = trim($input['name']);
|
$name = trim($input['name']);
|
||||||
$value = isset($input['value']) ? trim($input['value']) : '';
|
$value = isset($input['value']) ? trim($input['value']) : '';
|
||||||
// Any other modification or cleanup required can go here
|
return $this->tag->newInstance(['name' => $name, 'value' => $value]);
|
||||||
$values = ['name' => $name, 'value' => $value];
|
|
||||||
return $this->tag->newInstance($values);
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
@ -10,7 +10,6 @@ class TagController extends Controller
|
|||||||
|
|
||||||
/**
|
/**
|
||||||
* TagController constructor.
|
* TagController constructor.
|
||||||
* @param $tagRepo
|
|
||||||
*/
|
*/
|
||||||
public function __construct(TagRepo $tagRepo)
|
public function __construct(TagRepo $tagRepo)
|
||||||
{
|
{
|
||||||
@ -18,39 +17,23 @@ class TagController extends Controller
|
|||||||
parent::__construct();
|
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.
|
* Get tag name suggestions from a given search term.
|
||||||
* @param Request $request
|
|
||||||
* @return \Illuminate\Http\JsonResponse
|
|
||||||
*/
|
*/
|
||||||
public function getNameSuggestions(Request $request)
|
public function getNameSuggestions(Request $request)
|
||||||
{
|
{
|
||||||
$searchTerm = $request->get('search', false);
|
$searchTerm = $request->get('search', null);
|
||||||
$suggestions = $this->tagRepo->getNameSuggestions($searchTerm);
|
$suggestions = $this->tagRepo->getNameSuggestions($searchTerm);
|
||||||
return response()->json($suggestions);
|
return response()->json($suggestions);
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Get tag value suggestions from a given search term.
|
* Get tag value suggestions from a given search term.
|
||||||
* @param Request $request
|
|
||||||
* @return \Illuminate\Http\JsonResponse
|
|
||||||
*/
|
*/
|
||||||
public function getValueSuggestions(Request $request)
|
public function getValueSuggestions(Request $request)
|
||||||
{
|
{
|
||||||
$searchTerm = $request->get('search', false);
|
$searchTerm = $request->get('search', null);
|
||||||
$tagName = $request->get('name', false);
|
$tagName = $request->get('name', null);
|
||||||
$suggestions = $this->tagRepo->getValueSuggestions($searchTerm, $tagName);
|
$suggestions = $this->tagRepo->getValueSuggestions($searchTerm, $tagName);
|
||||||
return response()->json($suggestions);
|
return response()->json($suggestions);
|
||||||
}
|
}
|
||||||
|
@ -1,4 +1,5 @@
|
|||||||
import {onChildEvent} from "../services/dom";
|
import {onChildEvent} from "../services/dom";
|
||||||
|
import {uniqueId} from "../services/util";
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* AddRemoveRows
|
* AddRemoveRows
|
||||||
@ -11,21 +12,43 @@ class AddRemoveRows {
|
|||||||
this.modelRow = this.$refs.model;
|
this.modelRow = this.$refs.model;
|
||||||
this.addButton = this.$refs.add;
|
this.addButton = this.$refs.add;
|
||||||
this.removeSelector = this.$opts.removeSelector;
|
this.removeSelector = this.$opts.removeSelector;
|
||||||
|
this.rowSelector = this.$opts.rowSelector;
|
||||||
this.setupListeners();
|
this.setupListeners();
|
||||||
}
|
}
|
||||||
|
|
||||||
setupListeners() {
|
setupListeners() {
|
||||||
this.addButton.addEventListener('click', e => {
|
this.addButton.addEventListener('click', this.add.bind(this));
|
||||||
const clone = this.modelRow.cloneNode(true);
|
|
||||||
clone.classList.remove('hidden');
|
|
||||||
this.modelRow.parentNode.insertBefore(clone, this.modelRow);
|
|
||||||
});
|
|
||||||
|
|
||||||
onChildEvent(this.$el, this.removeSelector, 'click', (e) => {
|
onChildEvent(this.$el, this.removeSelector, 'click', (e) => {
|
||||||
const row = e.target.closest('tr');
|
const row = e.target.closest(this.rowSelector);
|
||||||
row.remove();
|
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;
|
export default AddRemoveRows;
|
@ -16,6 +16,7 @@ class AutoSuggest {
|
|||||||
this.input = this.$refs.input;
|
this.input = this.$refs.input;
|
||||||
this.list = this.$refs.list;
|
this.list = this.$refs.list;
|
||||||
|
|
||||||
|
this.lastPopulated = 0;
|
||||||
this.setupListeners();
|
this.setupListeners();
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -44,7 +45,10 @@ class AutoSuggest {
|
|||||||
|
|
||||||
selectSuggestion(value) {
|
selectSuggestion(value) {
|
||||||
this.input.value = value;
|
this.input.value = value;
|
||||||
|
this.lastPopulated = Date.now();
|
||||||
this.input.focus();
|
this.input.focus();
|
||||||
|
this.input.dispatchEvent(new Event('input', {bubbles: true}));
|
||||||
|
this.input.dispatchEvent(new Event('change', {bubbles: true}));
|
||||||
this.hideSuggestions();
|
this.hideSuggestions();
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -79,8 +83,12 @@ class AutoSuggest {
|
|||||||
}
|
}
|
||||||
|
|
||||||
async requestSuggestions() {
|
async requestSuggestions() {
|
||||||
|
if (Date.now() - this.lastPopulated < 50) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
const nameFilter = this.getNameFilterIfNeeded();
|
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);
|
const suggestions = await this.loadSuggestions(search, nameFilter);
|
||||||
let toShow = suggestions.slice(0, 6);
|
let toShow = suggestions.slice(0, 6);
|
||||||
if (search.length > 0) {
|
if (search.length > 0) {
|
||||||
|
@ -37,7 +37,7 @@ class Collapsible {
|
|||||||
}
|
}
|
||||||
|
|
||||||
openIfContainsError() {
|
openIfContainsError() {
|
||||||
const error = this.content.querySelector('.text-neg');
|
const error = this.content.querySelector('.text-neg.text-small');
|
||||||
if (error) {
|
if (error) {
|
||||||
this.open();
|
this.open();
|
||||||
}
|
}
|
||||||
|
@ -70,13 +70,20 @@ function initComponent(name, element) {
|
|||||||
function parseRefs(name, element) {
|
function parseRefs(name, element) {
|
||||||
const refs = {};
|
const refs = {};
|
||||||
const manyRefs = {};
|
const manyRefs = {};
|
||||||
|
|
||||||
const prefix = `${name}@`
|
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) {
|
for (const el of refElems) {
|
||||||
const refNames = el.getAttribute('refs')
|
const refNames = el.getAttribute('refs')
|
||||||
.split(' ')
|
.split(' ')
|
||||||
.filter(str => str.startsWith(prefix))
|
.filter(str => str.startsWith(prefix))
|
||||||
.map(str => str.replace(prefix, ''));
|
.map(str => str.replace(prefix, ''))
|
||||||
|
.map(kebabToCamel);
|
||||||
for (const ref of refNames) {
|
for (const ref of refNames) {
|
||||||
refs[ref] = el;
|
refs[ref] = el;
|
||||||
if (typeof manyRefs[ref] === 'undefined') {
|
if (typeof manyRefs[ref] === 'undefined') {
|
||||||
|
19
resources/js/components/sortable-list.js
Normal file
19
resources/js/components/sortable-list.js
Normal file
@ -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;
|
32
resources/js/components/tag-manager.js
Normal file
32
resources/js/components/tag-manager.js
Normal file
@ -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;
|
@ -60,4 +60,14 @@ export function escapeHtml(unsafe) {
|
|||||||
.replace(/>/g, ">")
|
.replace(/>/g, ">")
|
||||||
.replace(/"/g, """)
|
.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());
|
||||||
}
|
}
|
@ -1,134 +0,0 @@
|
|||||||
|
|
||||||
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};
|
|
@ -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
|
|
||||||
};
|
|
@ -5,13 +5,11 @@ function exists(id) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
import imageManager from "./image-manager";
|
import imageManager from "./image-manager";
|
||||||
import tagManager from "./tag-manager";
|
|
||||||
import attachmentManager from "./attachment-manager";
|
import attachmentManager from "./attachment-manager";
|
||||||
import pageEditor from "./page-editor";
|
import pageEditor from "./page-editor";
|
||||||
|
|
||||||
let vueMapping = {
|
let vueMapping = {
|
||||||
'image-manager': imageManager,
|
'image-manager': imageManager,
|
||||||
'tag-manager': tagManager,
|
|
||||||
'attachment-manager': attachmentManager,
|
'attachment-manager': attachmentManager,
|
||||||
'page-editor': pageEditor,
|
'page-editor': pageEditor,
|
||||||
};
|
};
|
||||||
|
@ -31,7 +31,7 @@
|
|||||||
<label for="tag-manager">{{ trans('entities.book_tags') }}</label>
|
<label for="tag-manager">{{ trans('entities.book_tags') }}</label>
|
||||||
</button>
|
</button>
|
||||||
<div class="collapse-content" collapsible-content>
|
<div class="collapse-content" collapsible-content>
|
||||||
@include('components.tag-manager', ['entity' => $book ?? null, 'entityType' => 'chapter'])
|
@include('components.tag-manager', ['entity' => $book ?? null])
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
@ -16,7 +16,7 @@
|
|||||||
<label for="tags">{{ trans('entities.chapter_tags') }}</label>
|
<label for="tags">{{ trans('entities.chapter_tags') }}</label>
|
||||||
</button>
|
</button>
|
||||||
<div class="collapse-content" collapsible-content>
|
<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>
|
||||||
|
|
||||||
|
@ -66,7 +66,4 @@
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@include('components.tag-manager', ['entity' => \BookStack\Entities\Book::find(1), 'entityType' => 'book'])
|
|
||||||
|
|
||||||
|
|
||||||
@stop
|
@stop
|
||||||
|
@ -1,5 +1,5 @@
|
|||||||
@foreach(array_merge($tags, [new \BookStack\Actions\Tag]) as $index => $tag)
|
@foreach(array_merge($tags, [null, null]) as $index => $tag)
|
||||||
<div class="card drag-card">
|
<div class="card drag-card {{ $loop->last ? 'hidden' : '' }}" @if($loop->last) refs="add-remove-rows@model" @endif>
|
||||||
<div class="handle">@icon('grip')</div>
|
<div class="handle">@icon('grip')</div>
|
||||||
@foreach(['name', 'value'] as $type)
|
@foreach(['name', 'value'] as $type)
|
||||||
<div component="auto-suggest"
|
<div component="auto-suggest"
|
||||||
@ -9,16 +9,16 @@
|
|||||||
<input value="{{ $tag->$type ?? '' }}"
|
<input value="{{ $tag->$type ?? '' }}"
|
||||||
placeholder="{{ trans('entities.tag_' . $type) }}"
|
placeholder="{{ trans('entities.tag_' . $type) }}"
|
||||||
aria-label="{{ trans('entities.tag_' . $type) }}"
|
aria-label="{{ trans('entities.tag_' . $type) }}"
|
||||||
name="tags[{{ $index }}][{{ $type }}]"
|
name="tags[{{ $loop->parent->last ? 'randrowid' : $index }}][{{ $type }}]"
|
||||||
type="text"
|
type="text"
|
||||||
refs="auto-suggest@input"
|
refs="auto-suggest@input"
|
||||||
autocomplete="off"/>
|
autocomplete="off"/>
|
||||||
<ul refs="auto-suggest@list" class="suggestion-box dropdown-menu"></ul>
|
<ul refs="auto-suggest@list" class="suggestion-box dropdown-menu"></ul>
|
||||||
</div>
|
</div>
|
||||||
@endforeach
|
@endforeach
|
||||||
<button refs="tag-manager@remove" type="button"
|
<button type="button"
|
||||||
aria-label="{{ trans('entities.tags_remove') }}"
|
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')
|
@icon('close')
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
|
@ -1,24 +1,16 @@
|
|||||||
<div id="tag-manager" entity-id="{{ $entity->id ?? 0 }}" entity-type="{{ $entity ? $entity->getType() : $entityType }}">
|
<div components="tag-manager add-remove-rows"
|
||||||
<div class="tags">
|
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">
|
||||||
|
|
||||||
<p class="text-muted small">{!! nl2br(e(trans('entities.tags_explain'))) !!}</p>
|
<p class="text-muted small">{!! nl2br(e(trans('entities.tags_explain'))) !!}</p>
|
||||||
|
|
||||||
@include('components.tag-manager-list', ['tags' => $entity->tags->all() ?? []])
|
<div component="sortable-list"
|
||||||
|
option:sortable-list:handle-selector=".handle">
|
||||||
|
@include('components.tag-manager-list', ['tags' => $entity->tags->all() ?? []])
|
||||||
|
</div>
|
||||||
|
|
||||||
<draggable :options="{handle: '.handle'}" :list="tags" element="div">
|
<button refs="add-remove-rows@add" type="button" class="text-button">{{ trans('entities.tags_add') }}</button>
|
||||||
<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>
|
|
||||||
|
|
||||||
<button @click="addEmptyTag" type="button" class="text-button">{{ trans('entities.tags_add') }}</button>
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
@ -12,7 +12,7 @@
|
|||||||
<div toolbox-tab-content="tags">
|
<div toolbox-tab-content="tags">
|
||||||
<h4>{{ trans('entities.page_tags') }}</h4>
|
<h4>{{ trans('entities.page_tags') }}</h4>
|
||||||
<div class="px-l">
|
<div class="px-l">
|
||||||
@include('components.tag-manager', ['entity' => $page, 'entityType' => 'page'])
|
@include('components.tag-manager', ['entity' => $page])
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
@ -4,6 +4,7 @@
|
|||||||
--}}
|
--}}
|
||||||
<table component="add-remove-rows"
|
<table component="add-remove-rows"
|
||||||
option:add-remove-rows:remove-selector="button.text-neg"
|
option:add-remove-rows:remove-selector="button.text-neg"
|
||||||
|
option:add-remove-rows:row-selector="tr"
|
||||||
class="no-style">
|
class="no-style">
|
||||||
@foreach(array_merge($currentList, ['']) as $term)
|
@foreach(array_merge($currentList, ['']) as $term)
|
||||||
<tr @if(empty($term)) class="hidden" refs="add-remove-rows@model" @endif>
|
<tr @if(empty($term)) class="hidden" refs="add-remove-rows@model" @endif>
|
||||||
|
@ -60,7 +60,7 @@
|
|||||||
<label for="tag-manager">{{ trans('entities.shelf_tags') }}</label>
|
<label for="tag-manager">{{ trans('entities.shelf_tags') }}</label>
|
||||||
</button>
|
</button>
|
||||||
<div class="collapse-content" collapsible-content>
|
<div class="collapse-content" collapsible-content>
|
||||||
@include('components.tag-manager', ['entity' => $shelf ?? null, 'entityType' => 'bookshelf'])
|
@include('components.tag-manager', ['entity' => $shelf ?? null])
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
@ -134,8 +134,7 @@ Route::group(['middleware' => 'auth'], function () {
|
|||||||
Route::delete('/ajax/page/{id}', 'PageController@ajaxDestroy');
|
Route::delete('/ajax/page/{id}', 'PageController@ajaxDestroy');
|
||||||
|
|
||||||
// Tag routes (AJAX)
|
// Tag routes (AJAX)
|
||||||
Route::group(['prefix' => 'ajax/tags'], function() {
|
Route::group(['prefix' => 'ajax/tags'], function () {
|
||||||
Route::get('/get/{entityType}/{entityId}', 'TagController@getForEntity');
|
|
||||||
Route::get('/suggest/names', 'TagController@getNameSuggestions');
|
Route::get('/suggest/names', 'TagController@getNameSuggestions');
|
||||||
Route::get('/suggest/values', 'TagController@getValueSuggestions');
|
Route::get('/suggest/values', 'TagController@getValueSuggestions');
|
||||||
});
|
});
|
||||||
|
Loading…
x
Reference in New Issue
Block a user