]> BookStack Code Mirror - bookstack/blob - resources/js/components/entity-selector.ts
TypeScript: Updated compile target, addressed issues
[bookstack] / resources / js / components / entity-selector.ts
1 import {onChildEvent} from '../services/dom';
2 import {Component} from './component';
3
4 export interface EntitySelectorSearchOptions {
5     entityTypes: string;
6     entityPermission: string;
7     searchEndpoint: string;
8     initialValue: string;
9 }
10
11 export type EntitySelectorEntity = {
12     id: number,
13     name: string,
14     link: string,
15 };
16
17 export class EntitySelector extends Component {
18     protected elem!: HTMLElement;
19     protected input!: HTMLInputElement;
20     protected searchInput!: HTMLInputElement;
21     protected loading!: HTMLElement;
22     protected resultsContainer!: HTMLElement;
23
24     protected searchOptions!: EntitySelectorSearchOptions;
25
26     protected search = '';
27     protected lastClick = 0;
28
29     setup() {
30         this.elem = this.$el;
31
32         this.input = this.$refs.input as HTMLInputElement;
33         this.searchInput = this.$refs.search as HTMLInputElement;
34         this.loading = this.$refs.loading;
35         this.resultsContainer = this.$refs.results;
36
37         this.searchOptions = {
38             entityTypes: this.$opts.entityTypes || 'page,book,chapter',
39             entityPermission: this.$opts.entityPermission || 'view',
40             searchEndpoint: this.$opts.searchEndpoint || '',
41             initialValue: this.searchInput.value || '',
42         };
43
44         this.setupListeners();
45         this.showLoading();
46
47         if (this.searchOptions.searchEndpoint) {
48             this.initialLoad();
49         }
50     }
51
52     configureSearchOptions(options: Partial<EntitySelectorSearchOptions>): void {
53         Object.assign(this.searchOptions, options);
54         this.reset();
55         this.searchInput.value = this.searchOptions.initialValue;
56     }
57
58     setupListeners(): void {
59         this.elem.addEventListener('click', this.onClick.bind(this));
60
61         let lastSearch = 0;
62         this.searchInput.addEventListener('input', () => {
63             lastSearch = Date.now();
64             this.showLoading();
65             setTimeout(() => {
66                 if (Date.now() - lastSearch < 199) return;
67                 this.searchEntities(this.searchInput.value);
68             }, 200);
69         });
70
71         this.searchInput.addEventListener('keydown', event => {
72             if (event.keyCode === 13) event.preventDefault();
73         });
74
75         // Keyboard navigation
76         onChildEvent(this.$el, '[data-entity-type]', 'keydown', ((event: KeyboardEvent) => {
77             if (event.ctrlKey && event.code === 'Enter') {
78                 const form = this.$el.closest('form');
79                 if (form) {
80                     form.submit();
81                     event.preventDefault();
82                     return;
83                 }
84             }
85
86             if (event.code === 'ArrowDown') {
87                 this.focusAdjacent(true);
88             }
89             if (event.code === 'ArrowUp') {
90                 this.focusAdjacent(false);
91             }
92         }) as (event: Event) => void);
93
94         this.searchInput.addEventListener('keydown', event => {
95             if (event.code === 'ArrowDown') {
96                 this.focusAdjacent(true);
97             }
98         });
99     }
100
101     focusAdjacent(forward = true) {
102         const items: (Element|null)[] = Array.from(this.resultsContainer.querySelectorAll('[data-entity-type]'));
103         const selectedIndex = items.indexOf(document.activeElement);
104         const newItem = items[selectedIndex + (forward ? 1 : -1)] || items[0];
105         if (newItem instanceof HTMLElement) {
106             newItem.focus();
107         }
108     }
109
110     reset() {
111         this.searchInput.value = '';
112         this.showLoading();
113         this.initialLoad();
114     }
115
116     focusSearch() {
117         this.searchInput.focus();
118     }
119
120     showLoading() {
121         this.loading.style.display = 'block';
122         this.resultsContainer.style.display = 'none';
123     }
124
125     hideLoading() {
126         this.loading.style.display = 'none';
127         this.resultsContainer.style.display = 'block';
128     }
129
130     initialLoad() {
131         if (!this.searchOptions.searchEndpoint) {
132             throw new Error('Search endpoint not set for entity-selector load');
133         }
134
135         if (this.searchOptions.initialValue) {
136             this.searchEntities(this.searchOptions.initialValue);
137             return;
138         }
139
140         window.$http.get(this.searchUrl()).then(resp => {
141             this.resultsContainer.innerHTML = resp.data as string;
142             this.hideLoading();
143         });
144     }
145
146     searchUrl() {
147         const query = `types=${encodeURIComponent(this.searchOptions.entityTypes)}&permission=${encodeURIComponent(this.searchOptions.entityPermission)}`;
148         return `${this.searchOptions.searchEndpoint}?${query}`;
149     }
150
151     searchEntities(searchTerm: string) {
152         if (!this.searchOptions.searchEndpoint) {
153             throw new Error('Search endpoint not set for entity-selector load');
154         }
155
156         this.input.value = '';
157         const url = `${this.searchUrl()}&term=${encodeURIComponent(searchTerm)}`;
158         window.$http.get(url).then(resp => {
159             this.resultsContainer.innerHTML = resp.data as string;
160             this.hideLoading();
161         });
162     }
163
164     isDoubleClick() {
165         const now = Date.now();
166         const answer = now - this.lastClick < 300;
167         this.lastClick = now;
168         return answer;
169     }
170
171     onClick(event: MouseEvent) {
172         const listItem = (event.target as HTMLElement).closest('[data-entity-type]');
173         if (listItem instanceof HTMLElement) {
174             event.preventDefault();
175             event.stopPropagation();
176             this.selectItem(listItem);
177         }
178     }
179
180     selectItem(item: HTMLElement): void {
181         const isDblClick = this.isDoubleClick();
182         const type = item.getAttribute('data-entity-type');
183         const id = item.getAttribute('data-entity-id');
184         const isSelected = (!item.classList.contains('selected') || isDblClick);
185
186         this.unselectAll();
187         this.input.value = isSelected ? `${type}:${id}` : '';
188
189         const link = item.getAttribute('href') || '';
190         const name = item.querySelector('.entity-list-item-name')?.textContent || '';
191         const data: EntitySelectorEntity = {id: Number(id), name, link};
192
193         if (isSelected) {
194             item.classList.add('selected');
195         } else {
196             window.$events.emit('entity-select-change');
197         }
198
199         if (!isDblClick && !isSelected) return;
200
201         if (isDblClick) {
202             this.confirmSelection(data);
203         }
204         if (isSelected) {
205             window.$events.emit('entity-select-change', data);
206         }
207     }
208
209     confirmSelection(data: EntitySelectorEntity) {
210         window.$events.emit('entity-select-confirm', data);
211     }
212
213     unselectAll() {
214         const selected = this.elem.querySelectorAll('.selected');
215         for (const selectedElem of selected) {
216             selectedElem.classList.remove('selected', 'primary-background');
217         }
218     }
219
220 }