]> BookStack Code Mirror - bookstack/blobdiff - resources/js/components/entity-selector.js
Opensearch: Fixed XML declaration when php short tags enabled
[bookstack] / resources / js / components / entity-selector.js
index 58879a20c0c5d8c76534e3af2bfc5a2da59c9e46..7491119a137ffbb24d0d83f0f4be1ec46add9ac7 100644 (file)
@@ -1,26 +1,59 @@
+import {onChildEvent} from '../services/dom.ts';
+import {Component} from './component';
+
+/**
+ * @typedef EntitySelectorSearchOptions
+ * @property entityTypes string
+ * @property entityPermission string
+ * @property searchEndpoint string
+ * @property initialValue string
+ */
+
+/**
+ * Entity Selector
+ */
+export class EntitySelector extends Component {
+
+    setup() {
+        this.elem = this.$el;
+
+        this.input = this.$refs.input;
+        this.searchInput = this.$refs.search;
+        this.loading = this.$refs.loading;
+        this.resultsContainer = this.$refs.results;
+
+        this.searchOptions = {
+            entityTypes: this.$opts.entityTypes || 'page,book,chapter',
+            entityPermission: this.$opts.entityPermission || 'view',
+            searchEndpoint: this.$opts.searchEndpoint || '',
+            initialValue: this.searchInput.value || '',
+        };
 
-class EntitySelector {
-
-    constructor(elem) {
-        this.elem = elem;
         this.search = '';
         this.lastClick = 0;
-        this.selectedItemData = null;
 
-        const entityTypes = elem.hasAttribute('entity-types') ? elem.getAttribute('entity-types') : 'page,book,chapter';
-        const entityPermission = elem.hasAttribute('entity-permission') ? elem.getAttribute('entity-permission') : 'view';
-        this.searchUrl = window.baseUrl(`/ajax/search/entities?types=${encodeURIComponent(entityTypes)}&permission=${encodeURIComponent(entityPermission)}`);
+        this.setupListeners();
+        this.showLoading();
 
-        this.input = elem.querySelector('[entity-selector-input]');
-        this.searchInput = elem.querySelector('[entity-selector-search]');
-        this.loading = elem.querySelector('[entity-selector-loading]');
-        this.resultsContainer = elem.querySelector('[entity-selector-results]');
-        this.addButton = elem.querySelector('[entity-selector-add-button]');
+        if (this.searchOptions.searchEndpoint) {
+            this.initialLoad();
+        }
+    }
 
+    /**
+     * @param {EntitySelectorSearchOptions} options
+     */
+    configureSearchOptions(options) {
+        Object.assign(this.searchOptions, options);
+        this.reset();
+        this.searchInput.value = this.searchOptions.initialValue;
+    }
+
+    setupListeners() {
         this.elem.addEventListener('click', this.onClick.bind(this));
 
         let lastSearch = 0;
-        this.searchInput.addEventListener('input', event => {
+        this.searchInput.addEventListener('input', () => {
             lastSearch = Date.now();
             this.showLoading();
             setTimeout(() => {
@@ -33,19 +66,51 @@ class EntitySelector {
             if (event.keyCode === 13) event.preventDefault();
         });
 
-        if (this.addButton) {
-            this.addButton.addEventListener('click', event => {
-                if (this.selectedItemData) {
-                    this.confirmSelection(this.selectedItemData);
-                    this.unselectAll();
+        // Keyboard navigation
+        onChildEvent(this.$el, '[data-entity-type]', 'keydown', event => {
+            if (event.ctrlKey && event.code === 'Enter') {
+                const form = this.$el.closest('form');
+                if (form) {
+                    form.submit();
+                    event.preventDefault();
+                    return;
                 }
-            });
+            }
+
+            if (event.code === 'ArrowDown') {
+                this.focusAdjacent(true);
+            }
+            if (event.code === 'ArrowUp') {
+                this.focusAdjacent(false);
+            }
+        });
+
+        this.searchInput.addEventListener('keydown', event => {
+            if (event.code === 'ArrowDown') {
+                this.focusAdjacent(true);
+            }
+        });
+    }
+
+    focusAdjacent(forward = true) {
+        const items = Array.from(this.resultsContainer.querySelectorAll('[data-entity-type]'));
+        const selectedIndex = items.indexOf(document.activeElement);
+        const newItem = items[selectedIndex + (forward ? 1 : -1)] || items[0];
+        if (newItem) {
+            newItem.focus();
         }
+    }
 
+    reset() {
+        this.searchInput.value = '';
         this.showLoading();
         this.initialLoad();
     }
 
+    focusSearch() {
+        this.searchInput.focus();
+    }
+
     showLoading() {
         this.loading.style.display = 'block';
         this.resultsContainer.style.display = 'none';
@@ -57,15 +122,33 @@ class EntitySelector {
     }
 
     initialLoad() {
-        window.$http.get(this.searchUrl).then(resp => {
+        if (!this.searchOptions.searchEndpoint) {
+            throw new Error('Search endpoint not set for entity-selector load');
+        }
+
+        if (this.searchOptions.initialValue) {
+            this.searchEntities(this.searchOptions.initialValue);
+            return;
+        }
+
+        window.$http.get(this.searchUrl()).then(resp => {
             this.resultsContainer.innerHTML = resp.data;
             this.hideLoading();
-        })
+        });
+    }
+
+    searchUrl() {
+        const query = `types=${encodeURIComponent(this.searchOptions.entityTypes)}&permission=${encodeURIComponent(this.searchOptions.entityPermission)}`;
+        return `${this.searchOptions.searchEndpoint}?${query}`;
     }
 
     searchEntities(searchTerm) {
+        if (!this.searchOptions.searchEndpoint) {
+            throw new Error('Search endpoint not set for entity-selector load');
+        }
+
         this.input.value = '';
-        let url = `${this.searchUrl}&term=${encodeURIComponent(searchTerm)}`;
+        const url = `${this.searchUrl()}&term=${encodeURIComponent(searchTerm)}`;
         window.$http.get(url).then(resp => {
             this.resultsContainer.innerHTML = resp.data;
             this.hideLoading();
@@ -73,8 +156,8 @@ class EntitySelector {
     }
 
     isDoubleClick() {
-        let now = Date.now();
-        let answer = now - this.lastClick < 300;
+        const now = Date.now();
+        const answer = now - this.lastClick < 300;
         this.lastClick = now;
         return answer;
     }
@@ -99,13 +182,12 @@ class EntitySelector {
 
         const link = item.getAttribute('href');
         const name = item.querySelector('.entity-list-item-name').textContent;
-        const data = {id: Number(id), name: name, link: link};
+        const data = {id: Number(id), name, link};
 
         if (isSelected) {
             item.classList.add('selected');
-            this.selectedItemData = data;
         } else {
-            window.$events.emit('entity-select-change', null)
+            window.$events.emit('entity-select-change', null);
         }
 
         if (!isDblClick && !isSelected) return;
@@ -114,7 +196,7 @@ class EntitySelector {
             this.confirmSelection(data);
         }
         if (isSelected) {
-            window.$events.emit('entity-select-change', data)
+            window.$events.emit('entity-select-change', data);
         }
     }
 
@@ -123,13 +205,10 @@ class EntitySelector {
     }
 
     unselectAll() {
-        let selected = this.elem.querySelectorAll('.selected');
-        for (let selectedElem of selected) {
+        const selected = this.elem.querySelectorAll('.selected');
+        for (const selectedElem of selected) {
             selectedElem.classList.remove('selected', 'primary-background');
         }
-        this.selectedItemData = null;
     }
 
 }
-
-export default EntitySelector;
\ No newline at end of file