mirror of
https://github.com/BookStackApp/BookStack.git
synced 2025-07-28 17:02:04 +03: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:
@ -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;
|
@ -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) {
|
||||
|
@ -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();
|
||||
}
|
||||
|
@ -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') {
|
||||
|
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, "'");
|
||||
}
|
||||
|
||||
/**
|
||||
* 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 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,
|
||||
};
|
||||
|
Reference in New Issue
Block a user