mirror of
https://github.com/philomena-dev/philomena.git
synced 2024-11-30 14:57:59 +01:00
Merge pull request #352 from koloml/refactor-autocomplete
Autocomplete: Extracting & slightly refactoring parts of the code
This commit is contained in:
commit
f73befb439
3 changed files with 588 additions and 181 deletions
|
@ -3,42 +3,25 @@
|
||||||
*/
|
*/
|
||||||
|
|
||||||
import { LocalAutocompleter } from './utils/local-autocompleter';
|
import { LocalAutocompleter } from './utils/local-autocompleter';
|
||||||
import { handleError } from './utils/requests';
|
|
||||||
import { getTermContexts } from './match_query';
|
import { getTermContexts } from './match_query';
|
||||||
import store from './utils/store';
|
import store from './utils/store';
|
||||||
import { TermContext } from './query/lex.ts';
|
import { TermContext } from './query/lex';
|
||||||
import { $, $$, makeEl, removeEl } from './utils/dom.ts';
|
import { $$ } from './utils/dom';
|
||||||
import { mouseMoveThenOver } from './utils/events.ts';
|
import { fetchLocalAutocomplete, fetchSuggestions, SuggestionsPopup, TermSuggestion } from './utils/suggestions';
|
||||||
|
|
||||||
type TermSuggestion = {
|
|
||||||
label: string;
|
|
||||||
value: string;
|
|
||||||
};
|
|
||||||
|
|
||||||
const cachedSuggestions: Record<string, TermSuggestion[]> = {};
|
|
||||||
let inputField: HTMLInputElement | null = null,
|
let inputField: HTMLInputElement | null = null,
|
||||||
originalTerm: string | undefined,
|
originalTerm: string | undefined,
|
||||||
originalQuery: string | undefined,
|
originalQuery: string | undefined,
|
||||||
selectedTerm: TermContext | null = null;
|
selectedTerm: TermContext | null = null;
|
||||||
|
|
||||||
function removeParent() {
|
const popup = new SuggestionsPopup();
|
||||||
const parent = $<HTMLElement>('.autocomplete');
|
|
||||||
if (parent) removeEl(parent);
|
|
||||||
}
|
|
||||||
|
|
||||||
function removeSelected() {
|
|
||||||
const selected = $<HTMLElement>('.autocomplete__item--selected');
|
|
||||||
if (selected) selected.classList.remove('autocomplete__item--selected');
|
|
||||||
}
|
|
||||||
|
|
||||||
function isSearchField(targetInput: HTMLElement): boolean {
|
function isSearchField(targetInput: HTMLElement): boolean {
|
||||||
return targetInput && targetInput.dataset.acMode === 'search';
|
return targetInput && targetInput.dataset.acMode === 'search';
|
||||||
}
|
}
|
||||||
|
|
||||||
function restoreOriginalValue() {
|
function restoreOriginalValue() {
|
||||||
if (!inputField) {
|
if (!inputField) return;
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (isSearchField(inputField) && originalQuery) {
|
if (isSearchField(inputField) && originalQuery) {
|
||||||
inputField.value = originalQuery;
|
inputField.value = originalQuery;
|
||||||
|
@ -50,9 +33,7 @@ function restoreOriginalValue() {
|
||||||
}
|
}
|
||||||
|
|
||||||
function applySelectedValue(selection: string) {
|
function applySelectedValue(selection: string) {
|
||||||
if (!inputField) {
|
if (!inputField) return;
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (!isSearchField(inputField)) {
|
if (!isSearchField(inputField)) {
|
||||||
inputField.value = selection;
|
inputField.value = selection;
|
||||||
|
@ -67,21 +48,6 @@ function applySelectedValue(selection: string) {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
function changeSelected(firstOrLast: Element | null, current: Element | null, sibling: Element | null) {
|
|
||||||
if (current && sibling) {
|
|
||||||
// if the currently selected item has a sibling, move selection to it
|
|
||||||
current.classList.remove('autocomplete__item--selected');
|
|
||||||
sibling.classList.add('autocomplete__item--selected');
|
|
||||||
} else if (current) {
|
|
||||||
// if the next keypress will take the user outside the list, restore the unautocompleted term
|
|
||||||
restoreOriginalValue();
|
|
||||||
removeSelected();
|
|
||||||
} else if (firstOrLast) {
|
|
||||||
// if no item in the list is selected, select the first or last
|
|
||||||
firstOrLast.classList.add('autocomplete__item--selected');
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
function isSelectionOutsideCurrentTerm(): boolean {
|
function isSelectionOutsideCurrentTerm(): boolean {
|
||||||
if (!inputField || !selectedTerm) return true;
|
if (!inputField || !selectedTerm) return true;
|
||||||
if (inputField.selectionStart === null || inputField.selectionEnd === null) return true;
|
if (inputField.selectionStart === null || inputField.selectionEnd === null) return true;
|
||||||
|
@ -93,127 +59,43 @@ function isSelectionOutsideCurrentTerm(): boolean {
|
||||||
}
|
}
|
||||||
|
|
||||||
function keydownHandler(event: KeyboardEvent) {
|
function keydownHandler(event: KeyboardEvent) {
|
||||||
const selected = $<HTMLElement>('.autocomplete__item--selected'),
|
if (inputField !== event.currentTarget) return;
|
||||||
firstItem = $<HTMLElement>('.autocomplete__item:first-of-type'),
|
|
||||||
lastItem = $<HTMLElement>('.autocomplete__item:last-of-type');
|
|
||||||
|
|
||||||
if (inputField && isSearchField(inputField)) {
|
if (inputField && isSearchField(inputField)) {
|
||||||
// Prevent submission of the search field when Enter was hit
|
// Prevent submission of the search field when Enter was hit
|
||||||
if (selected && event.keyCode === 13) event.preventDefault(); // Enter
|
if (popup.selectedTerm && event.keyCode === 13) event.preventDefault(); // Enter
|
||||||
|
|
||||||
// Close autocompletion popup when text cursor is outside current tag
|
// Close autocompletion popup when text cursor is outside current tag
|
||||||
if (selectedTerm && firstItem && (event.keyCode === 37 || event.keyCode === 39)) {
|
if (selectedTerm && (event.keyCode === 37 || event.keyCode === 39)) {
|
||||||
// ArrowLeft || ArrowRight
|
// ArrowLeft || ArrowRight
|
||||||
requestAnimationFrame(() => {
|
requestAnimationFrame(() => {
|
||||||
if (isSelectionOutsideCurrentTerm()) removeParent();
|
if (isSelectionOutsideCurrentTerm()) popup.hide();
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
if (event.keyCode === 38) changeSelected(lastItem, selected, selected && selected.previousElementSibling); // ArrowUp
|
if (!popup.isActive) return;
|
||||||
if (event.keyCode === 40) changeSelected(firstItem, selected, selected && selected.nextElementSibling); // ArrowDown
|
|
||||||
if (event.keyCode === 13 || event.keyCode === 27 || event.keyCode === 188) removeParent(); // Enter || Esc || Comma
|
if (event.keyCode === 38) popup.selectPrevious(); // ArrowUp
|
||||||
|
if (event.keyCode === 40) popup.selectNext(); // ArrowDown
|
||||||
|
if (event.keyCode === 13 || event.keyCode === 27 || event.keyCode === 188) popup.hide(); // Enter || Esc || Comma
|
||||||
if (event.keyCode === 38 || event.keyCode === 40) {
|
if (event.keyCode === 38 || event.keyCode === 40) {
|
||||||
// ArrowUp || ArrowDown
|
// ArrowUp || ArrowDown
|
||||||
const newSelected = $<HTMLElement>('.autocomplete__item--selected');
|
if (popup.selectedTerm) {
|
||||||
if (newSelected?.dataset.value) applySelectedValue(newSelected.dataset.value);
|
applySelectedValue(popup.selectedTerm);
|
||||||
|
} else {
|
||||||
|
restoreOriginalValue();
|
||||||
|
}
|
||||||
|
|
||||||
event.preventDefault();
|
event.preventDefault();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
function createItem(list: HTMLUListElement, suggestion: TermSuggestion) {
|
function findSelectedTerm(targetInput: HTMLInputElement, searchQuery: string): TermContext | null {
|
||||||
const item = makeEl('li', {
|
if (targetInput.selectionStart === null || targetInput.selectionEnd === null) return null;
|
||||||
className: 'autocomplete__item',
|
|
||||||
});
|
|
||||||
|
|
||||||
item.textContent = suggestion.label;
|
const selectionIndex = Math.min(targetInput.selectionStart, targetInput.selectionEnd);
|
||||||
item.dataset.value = suggestion.value;
|
const terms = getTermContexts(searchQuery);
|
||||||
|
|
||||||
mouseMoveThenOver(item, () => {
|
|
||||||
removeSelected();
|
|
||||||
item.classList.add('autocomplete__item--selected');
|
|
||||||
});
|
|
||||||
|
|
||||||
item.addEventListener('mouseout', () => {
|
|
||||||
removeSelected();
|
|
||||||
});
|
|
||||||
|
|
||||||
item.addEventListener('click', () => {
|
|
||||||
if (!inputField || !item.dataset.value) return;
|
|
||||||
|
|
||||||
applySelectedValue(item.dataset.value);
|
|
||||||
|
|
||||||
inputField.dispatchEvent(
|
|
||||||
new CustomEvent('autocomplete', {
|
|
||||||
detail: {
|
|
||||||
type: 'click',
|
|
||||||
label: suggestion.label,
|
|
||||||
value: suggestion.value,
|
|
||||||
},
|
|
||||||
}),
|
|
||||||
);
|
|
||||||
});
|
|
||||||
|
|
||||||
list.appendChild(item);
|
|
||||||
}
|
|
||||||
|
|
||||||
function createList(parentElement: HTMLElement, suggestions: TermSuggestion[]) {
|
|
||||||
const list = makeEl('ul', {
|
|
||||||
className: 'autocomplete__list',
|
|
||||||
});
|
|
||||||
|
|
||||||
suggestions.forEach(suggestion => createItem(list, suggestion));
|
|
||||||
|
|
||||||
parentElement.appendChild(list);
|
|
||||||
}
|
|
||||||
|
|
||||||
function createParent(): HTMLElement {
|
|
||||||
const parent = makeEl('div');
|
|
||||||
parent.className = 'autocomplete';
|
|
||||||
|
|
||||||
if (inputField && inputField.parentElement) {
|
|
||||||
// Position the parent below the inputfield
|
|
||||||
parent.style.position = 'absolute';
|
|
||||||
parent.style.left = `${inputField.offsetLeft}px`;
|
|
||||||
// Take the inputfield offset, add its height and subtract the amount by which the parent element has scrolled
|
|
||||||
parent.style.top = `${inputField.offsetTop + inputField.offsetHeight - inputField.parentElement.scrollTop}px`;
|
|
||||||
}
|
|
||||||
|
|
||||||
// We append the parent at the end of body
|
|
||||||
document.body.appendChild(parent);
|
|
||||||
|
|
||||||
return parent;
|
|
||||||
}
|
|
||||||
|
|
||||||
function showAutocomplete(suggestions: TermSuggestion[], fetchedTerm: string, targetInput: HTMLInputElement) {
|
|
||||||
// Remove old autocomplete suggestions
|
|
||||||
removeParent();
|
|
||||||
|
|
||||||
// Save suggestions in cache
|
|
||||||
cachedSuggestions[fetchedTerm] = suggestions;
|
|
||||||
|
|
||||||
// If the input target is not empty, still visible, and suggestions were found
|
|
||||||
if (targetInput.value && targetInput.style.display !== 'none' && suggestions.length) {
|
|
||||||
createList(createParent(), suggestions);
|
|
||||||
targetInput.addEventListener('keydown', keydownHandler);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
async function getSuggestions(term: string): Promise<TermSuggestion[]> {
|
|
||||||
// In case source URL was not given at all, do not try sending the request.
|
|
||||||
if (!inputField?.dataset.acSource) return [];
|
|
||||||
|
|
||||||
return await fetch(`${inputField.dataset.acSource}${term}`)
|
|
||||||
.then(handleError)
|
|
||||||
.then(response => response.json());
|
|
||||||
}
|
|
||||||
|
|
||||||
function getSelectedTerm(): TermContext | null {
|
|
||||||
if (!inputField || !originalQuery) return null;
|
|
||||||
if (inputField.selectionStart === null || inputField.selectionEnd === null) return null;
|
|
||||||
|
|
||||||
const selectionIndex = Math.min(inputField.selectionStart, inputField.selectionEnd);
|
|
||||||
const terms = getTermContexts(originalQuery);
|
|
||||||
|
|
||||||
return terms.find(([range]) => range[0] < selectionIndex && range[1] >= selectionIndex) ?? null;
|
return terms.find(([range]) => range[0] < selectionIndex && range[1] >= selectionIndex) ?? null;
|
||||||
}
|
}
|
||||||
|
@ -232,29 +114,33 @@ function toggleSearchAutocomplete() {
|
||||||
}
|
}
|
||||||
|
|
||||||
function listenAutocomplete() {
|
function listenAutocomplete() {
|
||||||
let timeout: number | undefined;
|
let serverSideSuggestionsTimeout: number | undefined;
|
||||||
|
|
||||||
let localAc: LocalAutocompleter | null = null;
|
let localAc: LocalAutocompleter | null = null;
|
||||||
let localFetched = false;
|
let isLocalLoading = false;
|
||||||
|
|
||||||
document.addEventListener('focusin', fetchLocalAutocomplete);
|
document.addEventListener('focusin', loadAutocompleteFromEvent);
|
||||||
|
|
||||||
document.addEventListener('input', event => {
|
document.addEventListener('input', event => {
|
||||||
removeParent();
|
popup.hide();
|
||||||
fetchLocalAutocomplete(event);
|
loadAutocompleteFromEvent(event);
|
||||||
window.clearTimeout(timeout);
|
window.clearTimeout(serverSideSuggestionsTimeout);
|
||||||
|
|
||||||
if (!(event.target instanceof HTMLInputElement)) return;
|
if (!(event.target instanceof HTMLInputElement)) return;
|
||||||
|
|
||||||
const targetedInput = event.target;
|
const targetedInput = event.target;
|
||||||
|
|
||||||
if (localAc !== null && 'ac' in targetedInput.dataset) {
|
if (!targetedInput.dataset.ac) return;
|
||||||
|
|
||||||
|
targetedInput.addEventListener('keydown', keydownHandler);
|
||||||
|
|
||||||
|
if (localAc !== null) {
|
||||||
inputField = targetedInput;
|
inputField = targetedInput;
|
||||||
let suggestionsCount = 5;
|
let suggestionsCount = 5;
|
||||||
|
|
||||||
if (isSearchField(inputField)) {
|
if (isSearchField(inputField)) {
|
||||||
originalQuery = inputField.value;
|
originalQuery = inputField.value;
|
||||||
selectedTerm = getSelectedTerm();
|
selectedTerm = findSelectedTerm(inputField, originalQuery);
|
||||||
suggestionsCount = 10;
|
suggestionsCount = 10;
|
||||||
|
|
||||||
// We don't need to run auto-completion if user is not selecting tag at all
|
// We don't need to run auto-completion if user is not selecting tag at all
|
||||||
|
@ -273,62 +159,72 @@ function listenAutocomplete() {
|
||||||
.map(({ name, imageCount }) => ({ label: `${name} (${imageCount})`, value: name }));
|
.map(({ name, imageCount }) => ({ label: `${name} (${imageCount})`, value: name }));
|
||||||
|
|
||||||
if (suggestions.length) {
|
if (suggestions.length) {
|
||||||
return showAutocomplete(suggestions, originalTerm, targetedInput);
|
popup.renderSuggestions(suggestions).showForField(targetedInput);
|
||||||
|
return;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const { acMinLength: minTermLength, acSource: endpointUrl } = targetedInput.dataset;
|
||||||
|
|
||||||
|
if (!endpointUrl) return;
|
||||||
|
|
||||||
// Use a timeout to delay requests until the user has stopped typing
|
// Use a timeout to delay requests until the user has stopped typing
|
||||||
timeout = window.setTimeout(() => {
|
serverSideSuggestionsTimeout = window.setTimeout(() => {
|
||||||
inputField = targetedInput;
|
inputField = targetedInput;
|
||||||
originalTerm = inputField.value;
|
originalTerm = inputField.value;
|
||||||
|
|
||||||
const fetchedTerm = inputField.value;
|
const fetchedTerm = inputField.value;
|
||||||
const { ac, acMinLength, acSource } = inputField.dataset;
|
|
||||||
|
|
||||||
if (!ac || !acSource || (acMinLength && fetchedTerm.length < parseInt(acMinLength, 10))) {
|
if (minTermLength && fetchedTerm.length < parseInt(minTermLength, 10)) return;
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (cachedSuggestions[fetchedTerm]) {
|
fetchSuggestions(endpointUrl, fetchedTerm).then(suggestions => {
|
||||||
showAutocomplete(cachedSuggestions[fetchedTerm], fetchedTerm, targetedInput);
|
// inputField could get overwritten while the suggestions are being fetched - use previously targeted input
|
||||||
} else {
|
if (fetchedTerm === targetedInput.value) {
|
||||||
// inputField could get overwritten while the suggestions are being fetched - use event.target
|
popup.renderSuggestions(suggestions).showForField(targetedInput);
|
||||||
getSuggestions(fetchedTerm).then(suggestions => {
|
}
|
||||||
if (fetchedTerm === targetedInput.value) {
|
});
|
||||||
showAutocomplete(suggestions, fetchedTerm, targetedInput);
|
|
||||||
}
|
|
||||||
});
|
|
||||||
}
|
|
||||||
}, 300);
|
}, 300);
|
||||||
});
|
});
|
||||||
|
|
||||||
// If there's a click outside the inputField, remove autocomplete
|
// If there's a click outside the inputField, remove autocomplete
|
||||||
document.addEventListener('click', event => {
|
document.addEventListener('click', event => {
|
||||||
if (event.target && event.target !== inputField) removeParent();
|
if (event.target && event.target !== inputField) popup.hide();
|
||||||
if (inputField && event.target === inputField && isSearchField(inputField) && isSelectionOutsideCurrentTerm()) {
|
if (inputField && event.target === inputField && isSearchField(inputField) && isSelectionOutsideCurrentTerm()) {
|
||||||
removeParent();
|
popup.hide();
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
function fetchLocalAutocomplete(event: Event) {
|
function loadAutocompleteFromEvent(event: Event) {
|
||||||
if (!(event.target instanceof HTMLInputElement)) return;
|
if (!(event.target instanceof HTMLInputElement)) return;
|
||||||
|
|
||||||
if (!localFetched && event.target.dataset && 'ac' in event.target.dataset) {
|
if (!isLocalLoading && event.target.dataset.ac) {
|
||||||
const now = new Date();
|
isLocalLoading = true;
|
||||||
const cacheKey = `${now.getUTCFullYear()}-${now.getUTCMonth()}-${now.getUTCDate()}`;
|
|
||||||
|
|
||||||
localFetched = true;
|
fetchLocalAutocomplete().then(autocomplete => {
|
||||||
|
localAc = autocomplete;
|
||||||
fetch(`/autocomplete/compiled?vsn=2&key=${cacheKey}`, { credentials: 'omit', cache: 'force-cache' })
|
});
|
||||||
.then(handleError)
|
|
||||||
.then(resp => resp.arrayBuffer())
|
|
||||||
.then(buf => {
|
|
||||||
localAc = new LocalAutocompleter(buf);
|
|
||||||
});
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
toggleSearchAutocomplete();
|
toggleSearchAutocomplete();
|
||||||
|
|
||||||
|
popup.onItemSelected((event: CustomEvent<TermSuggestion>) => {
|
||||||
|
if (!event.detail || !inputField) return;
|
||||||
|
|
||||||
|
const originalSuggestion = event.detail;
|
||||||
|
applySelectedValue(originalSuggestion.value);
|
||||||
|
|
||||||
|
inputField.dispatchEvent(
|
||||||
|
new CustomEvent('autocomplete', {
|
||||||
|
detail: Object.assign(
|
||||||
|
{
|
||||||
|
type: 'click',
|
||||||
|
},
|
||||||
|
originalSuggestion,
|
||||||
|
),
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
export { listenAutocomplete };
|
export { listenAutocomplete };
|
||||||
|
|
334
assets/js/utils/__tests__/suggestions.spec.ts
Normal file
334
assets/js/utils/__tests__/suggestions.spec.ts
Normal file
|
@ -0,0 +1,334 @@
|
||||||
|
import { fetchMock } from '../../../test/fetch-mock.ts';
|
||||||
|
import {
|
||||||
|
fetchLocalAutocomplete,
|
||||||
|
fetchSuggestions,
|
||||||
|
purgeSuggestionsCache,
|
||||||
|
SuggestionsPopup,
|
||||||
|
TermSuggestion,
|
||||||
|
} from '../suggestions.ts';
|
||||||
|
import fs from 'fs';
|
||||||
|
import path from 'path';
|
||||||
|
import { LocalAutocompleter } from '../local-autocompleter.ts';
|
||||||
|
import { afterEach } from 'vitest';
|
||||||
|
import { fireEvent } from '@testing-library/dom';
|
||||||
|
|
||||||
|
const mockedSuggestionsEndpoint = '/endpoint?term=';
|
||||||
|
const mockedSuggestionsResponse = [
|
||||||
|
{ label: 'artist:assasinmonkey (1)', value: 'artist:assasinmonkey' },
|
||||||
|
{ label: 'artist:hydrusbeta (1)', value: 'artist:hydrusbeta' },
|
||||||
|
{ label: 'artist:the sexy assistant (1)', value: 'artist:the sexy assistant' },
|
||||||
|
{ label: 'artist:devinian (1)', value: 'artist:devinian' },
|
||||||
|
{ label: 'artist:moe (1)', value: 'artist:moe' },
|
||||||
|
];
|
||||||
|
|
||||||
|
function mockBaseSuggestionsPopup(includeMockedSuggestions: boolean = false): [SuggestionsPopup, HTMLInputElement] {
|
||||||
|
const input = document.createElement('input');
|
||||||
|
const popup = new SuggestionsPopup();
|
||||||
|
|
||||||
|
document.body.append(input);
|
||||||
|
popup.showForField(input);
|
||||||
|
|
||||||
|
if (includeMockedSuggestions) {
|
||||||
|
popup.renderSuggestions(mockedSuggestionsResponse);
|
||||||
|
}
|
||||||
|
|
||||||
|
return [popup, input];
|
||||||
|
}
|
||||||
|
|
||||||
|
const selectedItemClassName = 'autocomplete__item--selected';
|
||||||
|
|
||||||
|
describe('Suggestions', () => {
|
||||||
|
let mockedAutocompleteBuffer: ArrayBuffer;
|
||||||
|
let popup: SuggestionsPopup | undefined;
|
||||||
|
let input: HTMLInputElement | undefined;
|
||||||
|
|
||||||
|
beforeAll(async () => {
|
||||||
|
fetchMock.enableMocks();
|
||||||
|
|
||||||
|
mockedAutocompleteBuffer = await fs.promises
|
||||||
|
.readFile(path.join(__dirname, 'autocomplete-compiled-v2.bin'))
|
||||||
|
.then(fileBuffer => fileBuffer.buffer);
|
||||||
|
});
|
||||||
|
|
||||||
|
afterAll(() => {
|
||||||
|
fetchMock.disableMocks();
|
||||||
|
});
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
purgeSuggestionsCache();
|
||||||
|
fetchMock.resetMocks();
|
||||||
|
});
|
||||||
|
|
||||||
|
afterEach(() => {
|
||||||
|
if (input) {
|
||||||
|
input.remove();
|
||||||
|
input = undefined;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (popup) {
|
||||||
|
popup.hide();
|
||||||
|
popup = undefined;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('SuggestionsPopup', () => {
|
||||||
|
it('should create the popup container', () => {
|
||||||
|
[popup, input] = mockBaseSuggestionsPopup();
|
||||||
|
|
||||||
|
expect(document.querySelector('.autocomplete')).toBeInstanceOf(HTMLElement);
|
||||||
|
expect(popup.isActive).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should be removed when hidden', () => {
|
||||||
|
[popup, input] = mockBaseSuggestionsPopup();
|
||||||
|
|
||||||
|
popup.hide();
|
||||||
|
|
||||||
|
expect(document.querySelector('.autocomplete')).not.toBeInstanceOf(HTMLElement);
|
||||||
|
expect(popup.isActive).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should render suggestions', () => {
|
||||||
|
[popup, input] = mockBaseSuggestionsPopup(true);
|
||||||
|
|
||||||
|
expect(document.querySelectorAll('.autocomplete__item').length).toBe(mockedSuggestionsResponse.length);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should initially select first element when selectNext called', () => {
|
||||||
|
[popup, input] = mockBaseSuggestionsPopup(true);
|
||||||
|
|
||||||
|
popup.selectNext();
|
||||||
|
|
||||||
|
expect(document.querySelector('.autocomplete__item:first-child')).toHaveClass(selectedItemClassName);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should initially select last element when selectPrevious called', () => {
|
||||||
|
[popup, input] = mockBaseSuggestionsPopup(true);
|
||||||
|
|
||||||
|
popup.selectPrevious();
|
||||||
|
|
||||||
|
expect(document.querySelector('.autocomplete__item:last-child')).toHaveClass(selectedItemClassName);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should select and de-select items when hovering items over', () => {
|
||||||
|
[popup, input] = mockBaseSuggestionsPopup(true);
|
||||||
|
|
||||||
|
const firstItem = document.querySelector('.autocomplete__item:first-child');
|
||||||
|
const lastItem = document.querySelector('.autocomplete__item:last-child');
|
||||||
|
|
||||||
|
if (firstItem) {
|
||||||
|
fireEvent.mouseOver(firstItem);
|
||||||
|
fireEvent.mouseMove(firstItem);
|
||||||
|
}
|
||||||
|
|
||||||
|
expect(firstItem).toHaveClass(selectedItemClassName);
|
||||||
|
|
||||||
|
if (lastItem) {
|
||||||
|
fireEvent.mouseOver(lastItem);
|
||||||
|
fireEvent.mouseMove(lastItem);
|
||||||
|
}
|
||||||
|
|
||||||
|
expect(firstItem).not.toHaveClass(selectedItemClassName);
|
||||||
|
expect(lastItem).toHaveClass(selectedItemClassName);
|
||||||
|
|
||||||
|
if (lastItem) {
|
||||||
|
fireEvent.mouseOut(lastItem);
|
||||||
|
}
|
||||||
|
|
||||||
|
expect(lastItem).not.toHaveClass(selectedItemClassName);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should allow switching between mouse and selection', () => {
|
||||||
|
[popup, input] = mockBaseSuggestionsPopup(true);
|
||||||
|
|
||||||
|
const secondItem = document.querySelector('.autocomplete__item:nth-child(2)');
|
||||||
|
const thirdItem = document.querySelector('.autocomplete__item:nth-child(3)');
|
||||||
|
|
||||||
|
if (secondItem) {
|
||||||
|
fireEvent.mouseOver(secondItem);
|
||||||
|
fireEvent.mouseMove(secondItem);
|
||||||
|
}
|
||||||
|
|
||||||
|
expect(secondItem).toHaveClass(selectedItemClassName);
|
||||||
|
|
||||||
|
popup.selectNext();
|
||||||
|
|
||||||
|
expect(secondItem).not.toHaveClass(selectedItemClassName);
|
||||||
|
expect(thirdItem).toHaveClass(selectedItemClassName);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should loop around when selecting next on last and previous on first', () => {
|
||||||
|
[popup, input] = mockBaseSuggestionsPopup(true);
|
||||||
|
|
||||||
|
const firstItem = document.querySelector('.autocomplete__item:first-child');
|
||||||
|
const lastItem = document.querySelector('.autocomplete__item:last-child');
|
||||||
|
|
||||||
|
if (lastItem) {
|
||||||
|
fireEvent.mouseOver(lastItem);
|
||||||
|
fireEvent.mouseMove(lastItem);
|
||||||
|
}
|
||||||
|
|
||||||
|
expect(lastItem).toHaveClass(selectedItemClassName);
|
||||||
|
|
||||||
|
popup.selectNext();
|
||||||
|
|
||||||
|
expect(document.querySelector(`.${selectedItemClassName}`)).toBeNull();
|
||||||
|
|
||||||
|
popup.selectNext();
|
||||||
|
|
||||||
|
expect(firstItem).toHaveClass(selectedItemClassName);
|
||||||
|
|
||||||
|
popup.selectPrevious();
|
||||||
|
|
||||||
|
expect(document.querySelector(`.${selectedItemClassName}`)).toBeNull();
|
||||||
|
|
||||||
|
popup.selectPrevious();
|
||||||
|
|
||||||
|
expect(lastItem).toHaveClass(selectedItemClassName);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should return selected item value', () => {
|
||||||
|
[popup, input] = mockBaseSuggestionsPopup(true);
|
||||||
|
|
||||||
|
expect(popup.selectedTerm).toBe(null);
|
||||||
|
|
||||||
|
popup.selectNext();
|
||||||
|
|
||||||
|
expect(popup.selectedTerm).toBe(mockedSuggestionsResponse[0].value);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should emit an event when item was clicked with mouse', () => {
|
||||||
|
[popup, input] = mockBaseSuggestionsPopup(true);
|
||||||
|
|
||||||
|
let clickEvent: CustomEvent<TermSuggestion> | undefined;
|
||||||
|
|
||||||
|
const itemSelectedHandler = vi.fn((event: CustomEvent<TermSuggestion>) => {
|
||||||
|
clickEvent = event;
|
||||||
|
});
|
||||||
|
|
||||||
|
popup.onItemSelected(itemSelectedHandler);
|
||||||
|
|
||||||
|
const firstItem = document.querySelector('.autocomplete__item');
|
||||||
|
|
||||||
|
if (firstItem) {
|
||||||
|
fireEvent.click(firstItem);
|
||||||
|
}
|
||||||
|
|
||||||
|
expect(itemSelectedHandler).toBeCalledTimes(1);
|
||||||
|
expect(clickEvent?.detail).toEqual(mockedSuggestionsResponse[0]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should not emit selection on items without value', () => {
|
||||||
|
[popup, input] = mockBaseSuggestionsPopup();
|
||||||
|
|
||||||
|
popup.renderSuggestions([{ label: 'Option without value', value: '' }]);
|
||||||
|
|
||||||
|
const itemSelectionHandler = vi.fn();
|
||||||
|
|
||||||
|
popup.onItemSelected(itemSelectionHandler);
|
||||||
|
|
||||||
|
const firstItem = document.querySelector('.autocomplete__item:first-child')!;
|
||||||
|
|
||||||
|
if (firstItem) {
|
||||||
|
fireEvent.click(firstItem);
|
||||||
|
}
|
||||||
|
|
||||||
|
expect(itemSelectionHandler).not.toBeCalled();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('fetchSuggestions', () => {
|
||||||
|
it('should only call fetch once per single term', () => {
|
||||||
|
fetchSuggestions(mockedSuggestionsEndpoint, 'art');
|
||||||
|
fetchSuggestions(mockedSuggestionsEndpoint, 'art');
|
||||||
|
|
||||||
|
expect(fetch).toHaveBeenCalledTimes(1);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should be case-insensitive to terms and trim spaces', () => {
|
||||||
|
fetchSuggestions(mockedSuggestionsEndpoint, 'art');
|
||||||
|
fetchSuggestions(mockedSuggestionsEndpoint, 'Art');
|
||||||
|
fetchSuggestions(mockedSuggestionsEndpoint, ' ART ');
|
||||||
|
|
||||||
|
expect(fetch).toHaveBeenCalledTimes(1);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should return the same suggestions from cache', async () => {
|
||||||
|
fetchMock.mockResolvedValueOnce(new Response(JSON.stringify(mockedSuggestionsResponse), { status: 200 }));
|
||||||
|
|
||||||
|
const firstSuggestions = await fetchSuggestions(mockedSuggestionsEndpoint, 'art');
|
||||||
|
const secondSuggestions = await fetchSuggestions(mockedSuggestionsEndpoint, 'art');
|
||||||
|
|
||||||
|
expect(firstSuggestions).toBe(secondSuggestions);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should parse and return array of suggestions', async () => {
|
||||||
|
fetchMock.mockResolvedValueOnce(new Response(JSON.stringify(mockedSuggestionsResponse), { status: 200 }));
|
||||||
|
|
||||||
|
const resolvedSuggestions = await fetchSuggestions(mockedSuggestionsEndpoint, 'art');
|
||||||
|
|
||||||
|
expect(resolvedSuggestions).toBeInstanceOf(Array);
|
||||||
|
expect(resolvedSuggestions.length).toBe(mockedSuggestionsResponse.length);
|
||||||
|
expect(resolvedSuggestions).toEqual(mockedSuggestionsResponse);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should return empty array on server error', async () => {
|
||||||
|
fetchMock.mockResolvedValueOnce(new Response('', { status: 500 }));
|
||||||
|
|
||||||
|
const resolvedSuggestions = await fetchSuggestions(mockedSuggestionsEndpoint, 'unknown tag');
|
||||||
|
|
||||||
|
expect(resolvedSuggestions).toBeInstanceOf(Array);
|
||||||
|
expect(resolvedSuggestions.length).toBe(0);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should return empty array on invalid response format', async () => {
|
||||||
|
fetchMock.mockResolvedValueOnce(new Response('invalid non-JSON response', { status: 200 }));
|
||||||
|
|
||||||
|
const resolvedSuggestions = await fetchSuggestions(mockedSuggestionsEndpoint, 'invalid response');
|
||||||
|
|
||||||
|
expect(resolvedSuggestions).toBeInstanceOf(Array);
|
||||||
|
expect(resolvedSuggestions.length).toBe(0);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('purgeSuggestionsCache', () => {
|
||||||
|
it('should clear cached responses', async () => {
|
||||||
|
fetchMock.mockResolvedValueOnce(new Response(JSON.stringify(mockedSuggestionsResponse), { status: 200 }));
|
||||||
|
|
||||||
|
const firstResult = await fetchSuggestions(mockedSuggestionsEndpoint, 'art');
|
||||||
|
purgeSuggestionsCache();
|
||||||
|
const resultAfterPurge = await fetchSuggestions(mockedSuggestionsEndpoint, 'art');
|
||||||
|
|
||||||
|
expect(fetch).toBeCalledTimes(2);
|
||||||
|
expect(firstResult).not.toBe(resultAfterPurge);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('fetchLocalAutocomplete', () => {
|
||||||
|
it('should request binary with date-related cache key', () => {
|
||||||
|
fetchMock.mockResolvedValue(new Response(mockedAutocompleteBuffer, { status: 200 }));
|
||||||
|
|
||||||
|
const now = new Date();
|
||||||
|
const cacheKey = `${now.getUTCFullYear()}-${now.getUTCMonth()}-${now.getUTCDate()}`;
|
||||||
|
const expectedEndpoint = `/autocomplete/compiled?vsn=2&key=${cacheKey}`;
|
||||||
|
|
||||||
|
fetchLocalAutocomplete();
|
||||||
|
|
||||||
|
expect(fetch).toBeCalledWith(expectedEndpoint, { credentials: 'omit', cache: 'force-cache' });
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should return auto-completer instance', async () => {
|
||||||
|
fetchMock.mockResolvedValue(new Response(mockedAutocompleteBuffer, { status: 200 }));
|
||||||
|
|
||||||
|
const autocomplete = await fetchLocalAutocomplete();
|
||||||
|
|
||||||
|
expect(autocomplete).toBeInstanceOf(LocalAutocompleter);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should throw generic server error on failing response', async () => {
|
||||||
|
fetchMock.mockResolvedValue(new Response('error', { status: 500 }));
|
||||||
|
|
||||||
|
expect(() => fetchLocalAutocomplete()).rejects.toThrowError('Received error from server');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
177
assets/js/utils/suggestions.ts
Normal file
177
assets/js/utils/suggestions.ts
Normal file
|
@ -0,0 +1,177 @@
|
||||||
|
import { makeEl } from './dom.ts';
|
||||||
|
import { mouseMoveThenOver } from './events.ts';
|
||||||
|
import { handleError } from './requests.ts';
|
||||||
|
import { LocalAutocompleter } from './local-autocompleter.ts';
|
||||||
|
|
||||||
|
export interface TermSuggestion {
|
||||||
|
label: string;
|
||||||
|
value: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
const selectedSuggestionClassName = 'autocomplete__item--selected';
|
||||||
|
|
||||||
|
export class SuggestionsPopup {
|
||||||
|
private readonly container: HTMLElement;
|
||||||
|
private readonly listElement: HTMLUListElement;
|
||||||
|
private selectedElement: HTMLElement | null = null;
|
||||||
|
|
||||||
|
constructor() {
|
||||||
|
this.container = makeEl('div', {
|
||||||
|
className: 'autocomplete',
|
||||||
|
});
|
||||||
|
|
||||||
|
this.listElement = makeEl('ul', {
|
||||||
|
className: 'autocomplete__list',
|
||||||
|
});
|
||||||
|
|
||||||
|
this.container.appendChild(this.listElement);
|
||||||
|
}
|
||||||
|
|
||||||
|
get selectedTerm(): string | null {
|
||||||
|
return this.selectedElement?.dataset.value || null;
|
||||||
|
}
|
||||||
|
|
||||||
|
get isActive(): boolean {
|
||||||
|
return this.container.isConnected;
|
||||||
|
}
|
||||||
|
|
||||||
|
hide() {
|
||||||
|
this.clearSelection();
|
||||||
|
this.container.remove();
|
||||||
|
}
|
||||||
|
|
||||||
|
private clearSelection() {
|
||||||
|
if (!this.selectedElement) return;
|
||||||
|
|
||||||
|
this.selectedElement.classList.remove(selectedSuggestionClassName);
|
||||||
|
this.selectedElement = null;
|
||||||
|
}
|
||||||
|
|
||||||
|
private updateSelection(targetItem: HTMLElement) {
|
||||||
|
this.clearSelection();
|
||||||
|
|
||||||
|
this.selectedElement = targetItem;
|
||||||
|
this.selectedElement.classList.add(selectedSuggestionClassName);
|
||||||
|
}
|
||||||
|
|
||||||
|
renderSuggestions(suggestions: TermSuggestion[]): SuggestionsPopup {
|
||||||
|
this.clearSelection();
|
||||||
|
|
||||||
|
this.listElement.innerHTML = '';
|
||||||
|
|
||||||
|
for (const suggestedTerm of suggestions) {
|
||||||
|
const listItem = makeEl('li', {
|
||||||
|
className: 'autocomplete__item',
|
||||||
|
innerText: suggestedTerm.label,
|
||||||
|
});
|
||||||
|
|
||||||
|
listItem.dataset.value = suggestedTerm.value;
|
||||||
|
|
||||||
|
this.watchItem(listItem, suggestedTerm);
|
||||||
|
this.listElement.appendChild(listItem);
|
||||||
|
}
|
||||||
|
|
||||||
|
return this;
|
||||||
|
}
|
||||||
|
|
||||||
|
private watchItem(listItem: HTMLElement, suggestion: TermSuggestion) {
|
||||||
|
mouseMoveThenOver(listItem, () => this.updateSelection(listItem));
|
||||||
|
|
||||||
|
listItem.addEventListener('mouseout', () => this.clearSelection());
|
||||||
|
|
||||||
|
listItem.addEventListener('click', () => {
|
||||||
|
if (!listItem.dataset.value) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
this.container.dispatchEvent(new CustomEvent('item_selected', { detail: suggestion }));
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
private changeSelection(direction: number) {
|
||||||
|
let nextTargetElement: Element | null;
|
||||||
|
|
||||||
|
if (!this.selectedElement) {
|
||||||
|
nextTargetElement = direction > 0 ? this.listElement.firstElementChild : this.listElement.lastElementChild;
|
||||||
|
} else {
|
||||||
|
nextTargetElement =
|
||||||
|
direction > 0 ? this.selectedElement.nextElementSibling : this.selectedElement.previousElementSibling;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!(nextTargetElement instanceof HTMLElement)) {
|
||||||
|
this.clearSelection();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
this.updateSelection(nextTargetElement);
|
||||||
|
}
|
||||||
|
|
||||||
|
selectNext() {
|
||||||
|
this.changeSelection(1);
|
||||||
|
}
|
||||||
|
|
||||||
|
selectPrevious() {
|
||||||
|
this.changeSelection(-1);
|
||||||
|
}
|
||||||
|
|
||||||
|
showForField(targetElement: HTMLElement) {
|
||||||
|
this.container.style.position = 'absolute';
|
||||||
|
this.container.style.left = `${targetElement.offsetLeft}px`;
|
||||||
|
|
||||||
|
let topPosition = targetElement.offsetTop + targetElement.offsetHeight;
|
||||||
|
|
||||||
|
if (targetElement.parentElement) {
|
||||||
|
topPosition -= targetElement.parentElement.scrollTop;
|
||||||
|
}
|
||||||
|
|
||||||
|
this.container.style.top = `${topPosition}px`;
|
||||||
|
|
||||||
|
document.body.appendChild(this.container);
|
||||||
|
}
|
||||||
|
|
||||||
|
onItemSelected(callback: (event: CustomEvent<TermSuggestion>) => void) {
|
||||||
|
this.container.addEventListener('item_selected', callback as EventListener);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const cachedSuggestions = new Map<string, Promise<TermSuggestion[]>>();
|
||||||
|
|
||||||
|
export async function fetchSuggestions(endpoint: string, targetTerm: string): Promise<TermSuggestion[]> {
|
||||||
|
const normalizedTerm = targetTerm.trim().toLowerCase();
|
||||||
|
|
||||||
|
if (cachedSuggestions.has(normalizedTerm)) {
|
||||||
|
return cachedSuggestions.get(normalizedTerm)!;
|
||||||
|
}
|
||||||
|
|
||||||
|
const promisedSuggestions: Promise<TermSuggestion[]> = fetch(`${endpoint}${targetTerm}`)
|
||||||
|
.then(handleError)
|
||||||
|
.then(response => response.json())
|
||||||
|
.catch(() => {
|
||||||
|
// Deleting the promised result from cache to allow retrying
|
||||||
|
cachedSuggestions.delete(normalizedTerm);
|
||||||
|
|
||||||
|
// And resolve failed promise with empty array
|
||||||
|
return [];
|
||||||
|
});
|
||||||
|
|
||||||
|
cachedSuggestions.set(normalizedTerm, promisedSuggestions);
|
||||||
|
|
||||||
|
return promisedSuggestions;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function purgeSuggestionsCache() {
|
||||||
|
cachedSuggestions.clear();
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function fetchLocalAutocomplete(): Promise<LocalAutocompleter> {
|
||||||
|
const now = new Date();
|
||||||
|
const cacheKey = `${now.getUTCFullYear()}-${now.getUTCMonth()}-${now.getUTCDate()}`;
|
||||||
|
|
||||||
|
return await fetch(`/autocomplete/compiled?vsn=2&key=${cacheKey}`, {
|
||||||
|
credentials: 'omit',
|
||||||
|
cache: 'force-cache',
|
||||||
|
})
|
||||||
|
.then(handleError)
|
||||||
|
.then(resp => resp.arrayBuffer())
|
||||||
|
.then(buf => new LocalAutocompleter(buf));
|
||||||
|
}
|
Loading…
Reference in a new issue