From 0fe6cd78429ce1e400819b776b49959d5a7f97ec Mon Sep 17 00:00:00 2001 From: KoloMl Date: Sat, 31 Aug 2024 18:48:30 +0400 Subject: [PATCH 01/10] Extracting chunks of code & slightly refactoring autocomplete script --- assets/js/autocomplete.ts | 233 ++++++++++----------------------- assets/js/utils/suggestions.ts | 165 +++++++++++++++++++++++ 2 files changed, 233 insertions(+), 165 deletions(-) create mode 100644 assets/js/utils/suggestions.ts diff --git a/assets/js/autocomplete.ts b/assets/js/autocomplete.ts index 033176c8..4a769ef3 100644 --- a/assets/js/autocomplete.ts +++ b/assets/js/autocomplete.ts @@ -6,39 +6,23 @@ import { LocalAutocompleter } from './utils/local-autocompleter'; import { handleError } from './utils/requests'; import { getTermContexts } from './match_query'; import store from './utils/store'; -import { TermContext } from './query/lex.ts'; -import { $, $$, makeEl, removeEl } from './utils/dom.ts'; -import { mouseMoveThenOver } from './utils/events.ts'; +import { TermContext } from './query/lex'; +import { $$ } from './utils/dom'; +import { fetchSuggestions, SuggestionsPopup, TermSuggestion } from './utils/suggestions'; -type TermSuggestion = { - label: string; - value: string; -}; - -const cachedSuggestions: Record = {}; let inputField: HTMLInputElement | null = null, originalTerm: string | undefined, originalQuery: string | undefined, selectedTerm: TermContext | null = null; -function removeParent() { - const parent = $('.autocomplete'); - if (parent) removeEl(parent); -} - -function removeSelected() { - const selected = $('.autocomplete__item--selected'); - if (selected) selected.classList.remove('autocomplete__item--selected'); -} +const popup = new SuggestionsPopup(); function isSearchField(targetInput: HTMLElement): boolean { return targetInput && targetInput.dataset.acMode === 'search'; } function restoreOriginalValue() { - if (!inputField) { - return; - } + if (!inputField) return; if (isSearchField(inputField) && originalQuery) { inputField.value = originalQuery; @@ -50,9 +34,7 @@ function restoreOriginalValue() { } function applySelectedValue(selection: string) { - if (!inputField) { - return; - } + if (!inputField) return; if (!isSearchField(inputField)) { inputField.value = selection; @@ -67,21 +49,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 { if (!inputField || !selectedTerm) return true; if (inputField.selectionStart === null || inputField.selectionEnd === null) return true; @@ -93,127 +60,43 @@ function isSelectionOutsideCurrentTerm(): boolean { } function keydownHandler(event: KeyboardEvent) { - const selected = $('.autocomplete__item--selected'), - firstItem = $('.autocomplete__item:first-of-type'), - lastItem = $('.autocomplete__item:last-of-type'); + if (inputField !== event.currentTarget) return; if (inputField && isSearchField(inputField)) { // 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 - if (selectedTerm && firstItem && (event.keyCode === 37 || event.keyCode === 39)) { + if (selectedTerm && (event.keyCode === 37 || event.keyCode === 39)) { // ArrowLeft || ArrowRight requestAnimationFrame(() => { - if (isSelectionOutsideCurrentTerm()) removeParent(); + if (isSelectionOutsideCurrentTerm()) popup.hide(); }); } } - if (event.keyCode === 38) changeSelected(lastItem, selected, selected && selected.previousElementSibling); // ArrowUp - 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 (!popup.isActive) return; + + 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) { // ArrowUp || ArrowDown - const newSelected = $('.autocomplete__item--selected'); - if (newSelected?.dataset.value) applySelectedValue(newSelected.dataset.value); + if (popup.selectedTerm) { + applySelectedValue(popup.selectedTerm); + } else { + restoreOriginalValue(); + } + event.preventDefault(); } } -function createItem(list: HTMLUListElement, suggestion: TermSuggestion) { - const item = makeEl('li', { - className: 'autocomplete__item', - }); +function findSelectedTerm(targetInput: HTMLInputElement, searchQuery: string): TermContext | null { + if (targetInput.selectionStart === null || targetInput.selectionEnd === null) return null; - item.textContent = suggestion.label; - item.dataset.value = suggestion.value; - - 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 { - // 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); + const selectionIndex = Math.min(targetInput.selectionStart, targetInput.selectionEnd); + const terms = getTermContexts(searchQuery); return terms.find(([range]) => range[0] < selectionIndex && range[1] >= selectionIndex) ?? null; } @@ -232,7 +115,7 @@ function toggleSearchAutocomplete() { } function listenAutocomplete() { - let timeout: number | undefined; + let serverSideSuggestionsTimeout: number | undefined; let localAc: LocalAutocompleter | null = null; let localFetched = false; @@ -240,21 +123,25 @@ function listenAutocomplete() { document.addEventListener('focusin', fetchLocalAutocomplete); document.addEventListener('input', event => { - removeParent(); + popup.hide(); fetchLocalAutocomplete(event); - window.clearTimeout(timeout); + window.clearTimeout(serverSideSuggestionsTimeout); if (!(event.target instanceof HTMLInputElement)) return; 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; let suggestionsCount = 5; if (isSearchField(inputField)) { originalQuery = inputField.value; - selectedTerm = getSelectedTerm(); + selectedTerm = findSelectedTerm(inputField, originalQuery); suggestionsCount = 10; // We don't need to run auto-completion if user is not selecting tag at all @@ -273,40 +160,38 @@ function listenAutocomplete() { .map(({ name, imageCount }) => ({ label: `${name} (${imageCount})`, value: name })); 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 - timeout = window.setTimeout(() => { + serverSideSuggestionsTimeout = window.setTimeout(() => { inputField = targetedInput; originalTerm = inputField.value; const fetchedTerm = inputField.value; - const { ac, acMinLength, acSource } = inputField.dataset; - if (!ac || !acSource || (acMinLength && fetchedTerm.length < parseInt(acMinLength, 10))) { - return; - } + if (minTermLength && fetchedTerm.length < parseInt(minTermLength, 10)) return; - if (cachedSuggestions[fetchedTerm]) { - showAutocomplete(cachedSuggestions[fetchedTerm], fetchedTerm, targetedInput); - } else { - // inputField could get overwritten while the suggestions are being fetched - use event.target - getSuggestions(fetchedTerm).then(suggestions => { - if (fetchedTerm === targetedInput.value) { - showAutocomplete(suggestions, fetchedTerm, targetedInput); - } - }); - } + fetchSuggestions(endpointUrl, fetchedTerm).then(suggestions => { + // inputField could get overwritten while the suggestions are being fetched - use previously targeted input + if (fetchedTerm === targetedInput.value) { + popup.renderSuggestions(suggestions).showForField(targetedInput); + } + }); }, 300); }); // If there's a click outside the inputField, remove autocomplete 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()) { - removeParent(); + popup.hide(); } }); @@ -329,6 +214,24 @@ function listenAutocomplete() { } toggleSearchAutocomplete(); + + popup.onItemSelected((event: CustomEvent) => { + 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 }; diff --git a/assets/js/utils/suggestions.ts b/assets/js/utils/suggestions.ts new file mode 100644 index 00000000..7f4e9637 --- /dev/null +++ b/assets/js/utils/suggestions.ts @@ -0,0 +1,165 @@ +import { makeEl } from './dom.ts'; +import { mouseMoveThenOver } from './events.ts'; +import { handleError } from './requests.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) { + if (this.listElement.childElementCount === 0 || direction === 0) { + return; + } + + 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) || !nextTargetElement.dataset.value) { + this.clearSelection(); + return; + } + + this.updateSelection(nextTargetElement); + } + + selectNext() { + return this.changeSelection(1); + } + + selectPrevious() { + return this.changeSelection(-1); + } + + showForField(targetElement: HTMLElement): SuggestionsPopup { + 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); + + return this; + } + + onItemSelected(callback: (event: CustomEvent) => void) { + this.container.addEventListener('item_selected', callback as EventListener); + } +} + +const cachedSuggestions = new Map>(); + +export async function fetchSuggestions(endpoint: string, targetTerm: string) { + const normalizedTerm = targetTerm.trim().toLowerCase(); + + if (cachedSuggestions.has(normalizedTerm)) { + return cachedSuggestions.get(normalizedTerm)!; + } + + const promisedSuggestions: Promise = 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; +} From 997b1bbe8af09a18a50672979b46baf34be2c8db Mon Sep 17 00:00:00 2001 From: KoloMl Date: Sat, 31 Aug 2024 21:11:53 +0400 Subject: [PATCH 02/10] Extracted local autocompleter download function --- assets/js/autocomplete.ts | 26 ++++++++++---------------- assets/js/utils/suggestions.ts | 18 ++++++++++++++++++ 2 files changed, 28 insertions(+), 16 deletions(-) diff --git a/assets/js/autocomplete.ts b/assets/js/autocomplete.ts index 4a769ef3..8fac48ea 100644 --- a/assets/js/autocomplete.ts +++ b/assets/js/autocomplete.ts @@ -8,7 +8,7 @@ import { getTermContexts } from './match_query'; import store from './utils/store'; import { TermContext } from './query/lex'; import { $$ } from './utils/dom'; -import { fetchSuggestions, SuggestionsPopup, TermSuggestion } from './utils/suggestions'; +import { fetchLocalAutocomplete, fetchSuggestions, SuggestionsPopup, TermSuggestion } from './utils/suggestions'; let inputField: HTMLInputElement | null = null, originalTerm: string | undefined, @@ -118,13 +118,13 @@ function listenAutocomplete() { let serverSideSuggestionsTimeout: number | undefined; let localAc: LocalAutocompleter | null = null; - let localFetched = false; + let isLocalLoading = false; - document.addEventListener('focusin', fetchLocalAutocomplete); + document.addEventListener('focusin', loadAutocompleteFromEvent); document.addEventListener('input', event => { popup.hide(); - fetchLocalAutocomplete(event); + loadAutocompleteFromEvent(event); window.clearTimeout(serverSideSuggestionsTimeout); if (!(event.target instanceof HTMLInputElement)) return; @@ -195,21 +195,15 @@ function listenAutocomplete() { } }); - function fetchLocalAutocomplete(event: Event) { + function loadAutocompleteFromEvent(event: Event) { if (!(event.target instanceof HTMLInputElement)) return; - if (!localFetched && event.target.dataset && 'ac' in event.target.dataset) { - const now = new Date(); - const cacheKey = `${now.getUTCFullYear()}-${now.getUTCMonth()}-${now.getUTCDate()}`; + if (!isLocalLoading && event.target.dataset.ac) { + isLocalLoading = true; - localFetched = true; - - fetch(`/autocomplete/compiled?vsn=2&key=${cacheKey}`, { credentials: 'omit', cache: 'force-cache' }) - .then(handleError) - .then(resp => resp.arrayBuffer()) - .then(buf => { - localAc = new LocalAutocompleter(buf); - }); + fetchLocalAutocomplete().then(autocomplete => { + localAc = autocomplete; + }); } } diff --git a/assets/js/utils/suggestions.ts b/assets/js/utils/suggestions.ts index 7f4e9637..e9071c34 100644 --- a/assets/js/utils/suggestions.ts +++ b/assets/js/utils/suggestions.ts @@ -1,6 +1,7 @@ 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; @@ -163,3 +164,20 @@ export async function fetchSuggestions(endpoint: string, targetTerm: string) { return promisedSuggestions; } + +export function purgeSuggestionsCache() { + cachedSuggestions.clear(); +} + +export async function fetchLocalAutocomplete(): Promise { + 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)); +} From 4b3348aceecc20c6274724ad4c1459634f2bdf3e Mon Sep 17 00:00:00 2001 From: KoloMl Date: Sat, 31 Aug 2024 21:24:41 +0400 Subject: [PATCH 03/10] Tests: Covering server-side suggestions and local suggestions functions --- assets/js/utils/__tests__/suggestions.spec.ts | 128 ++++++++++++++++++ 1 file changed, 128 insertions(+) create mode 100644 assets/js/utils/__tests__/suggestions.spec.ts diff --git a/assets/js/utils/__tests__/suggestions.spec.ts b/assets/js/utils/__tests__/suggestions.spec.ts new file mode 100644 index 00000000..f7eaa489 --- /dev/null +++ b/assets/js/utils/__tests__/suggestions.spec.ts @@ -0,0 +1,128 @@ +import { fetchMock } from '../../../test/fetch-mock.ts'; +import { fetchLocalAutocomplete, fetchSuggestions, purgeSuggestionsCache } from '../suggestions.ts'; +import fs from 'fs'; +import path from 'path'; +import { LocalAutocompleter } from '../local-autocompleter.ts'; + +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' }, +]; + +describe('Suggestions', () => { + let mockedAutocompleteBuffer: ArrayBuffer; + + 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(); + }); + + 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', () => { + 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'); + }); + }); +}); From ab43c42e5301b006fc02c248c8aac9d31e4bd66b Mon Sep 17 00:00:00 2001 From: KoloMl Date: Sat, 31 Aug 2024 21:35:05 +0400 Subject: [PATCH 04/10] Removed unnecessary import --- assets/js/autocomplete.ts | 1 - 1 file changed, 1 deletion(-) diff --git a/assets/js/autocomplete.ts b/assets/js/autocomplete.ts index 8fac48ea..489392c3 100644 --- a/assets/js/autocomplete.ts +++ b/assets/js/autocomplete.ts @@ -3,7 +3,6 @@ */ import { LocalAutocompleter } from './utils/local-autocompleter'; -import { handleError } from './utils/requests'; import { getTermContexts } from './match_query'; import store from './utils/store'; import { TermContext } from './query/lex'; From 914aa75a8e5bcd42eae07df96f03c67a22dac6a8 Mon Sep 17 00:00:00 2001 From: KoloMl Date: Sat, 31 Aug 2024 23:19:46 +0400 Subject: [PATCH 05/10] Tests: Covering `SuggestionsPopup` with tests --- assets/js/utils/__tests__/suggestions.spec.ts | 160 +++++++++++++++++- 1 file changed, 159 insertions(+), 1 deletion(-) diff --git a/assets/js/utils/__tests__/suggestions.spec.ts b/assets/js/utils/__tests__/suggestions.spec.ts index f7eaa489..b65ca94f 100644 --- a/assets/js/utils/__tests__/suggestions.spec.ts +++ b/assets/js/utils/__tests__/suggestions.spec.ts @@ -1,8 +1,16 @@ import { fetchMock } from '../../../test/fetch-mock.ts'; -import { fetchLocalAutocomplete, fetchSuggestions, purgeSuggestionsCache } from '../suggestions.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 = [ @@ -13,8 +21,26 @@ const mockedSuggestionsResponse = [ { 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(); @@ -33,6 +59,136 @@ describe('Suggestions', () => { 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 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 | undefined; + + const itemSelectedHandler = vi.fn((event: CustomEvent) => { + 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]); + }); + }); + describe('fetchSuggestions', () => { it('should only call fetch once per single term', () => { fetchSuggestions(mockedSuggestionsEndpoint, 'art'); @@ -102,6 +258,8 @@ describe('Suggestions', () => { 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}`; From 7a6ca5b2340a2003808d6fea8966794cd81ebd63 Mon Sep 17 00:00:00 2001 From: KoloMl Date: Sat, 31 Aug 2024 23:32:55 +0400 Subject: [PATCH 06/10] Tests: Check selection when clicked element has no value --- assets/js/utils/__tests__/suggestions.spec.ts | 18 ++++++++++++++++++ 1 file changed, 18 insertions(+) diff --git a/assets/js/utils/__tests__/suggestions.spec.ts b/assets/js/utils/__tests__/suggestions.spec.ts index b65ca94f..c9a248d0 100644 --- a/assets/js/utils/__tests__/suggestions.spec.ts +++ b/assets/js/utils/__tests__/suggestions.spec.ts @@ -187,6 +187,24 @@ describe('Suggestions', () => { 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', () => { From 0111ac5dfb591559faaa5f125e493223606a308f Mon Sep 17 00:00:00 2001 From: KoloMl Date: Sat, 31 Aug 2024 23:36:07 +0400 Subject: [PATCH 07/10] Fixed selection using keyboard when stumbled upon option without value --- assets/js/utils/suggestions.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/assets/js/utils/suggestions.ts b/assets/js/utils/suggestions.ts index e9071c34..6f9522a1 100644 --- a/assets/js/utils/suggestions.ts +++ b/assets/js/utils/suggestions.ts @@ -102,7 +102,7 @@ export class SuggestionsPopup { direction > 0 ? this.selectedElement.nextElementSibling : this.selectedElement.previousElementSibling; } - if (!(nextTargetElement instanceof HTMLElement) || !nextTargetElement.dataset.value) { + if (!(nextTargetElement instanceof HTMLElement)) { this.clearSelection(); return; } From 098ec6c6db681a9701d7e5e01a8a040fc3d1e13c Mon Sep 17 00:00:00 2001 From: KoloMl Date: Sat, 31 Aug 2024 23:56:44 +0400 Subject: [PATCH 08/10] Removed the check for direction and elements count for private method --- assets/js/utils/suggestions.ts | 4 ---- 1 file changed, 4 deletions(-) diff --git a/assets/js/utils/suggestions.ts b/assets/js/utils/suggestions.ts index 6f9522a1..416546a7 100644 --- a/assets/js/utils/suggestions.ts +++ b/assets/js/utils/suggestions.ts @@ -89,10 +89,6 @@ export class SuggestionsPopup { } private changeSelection(direction: number) { - if (this.listElement.childElementCount === 0 || direction === 0) { - return; - } - let nextTargetElement: Element | null; if (!this.selectedElement) { From 1fe752dca3aba65e6906d91fa68af905eb81a747 Mon Sep 17 00:00:00 2001 From: KoloMl Date: Sat, 31 Aug 2024 23:57:24 +0400 Subject: [PATCH 09/10] Tests: Looping selection between from end to start and from start to end --- assets/js/utils/__tests__/suggestions.spec.ts | 30 +++++++++++++++++++ 1 file changed, 30 insertions(+) diff --git a/assets/js/utils/__tests__/suggestions.spec.ts b/assets/js/utils/__tests__/suggestions.spec.ts index c9a248d0..59102d2b 100644 --- a/assets/js/utils/__tests__/suggestions.spec.ts +++ b/assets/js/utils/__tests__/suggestions.spec.ts @@ -157,6 +157,36 @@ describe('Suggestions', () => { 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); From 18c72814a9f5a38ae8732321b140898b169ce75c Mon Sep 17 00:00:00 2001 From: Liam Date: Mon, 2 Sep 2024 09:32:50 -0400 Subject: [PATCH 10/10] Annotate return values --- assets/js/utils/suggestions.ts | 10 ++++------ 1 file changed, 4 insertions(+), 6 deletions(-) diff --git a/assets/js/utils/suggestions.ts b/assets/js/utils/suggestions.ts index 416546a7..fb810be3 100644 --- a/assets/js/utils/suggestions.ts +++ b/assets/js/utils/suggestions.ts @@ -107,14 +107,14 @@ export class SuggestionsPopup { } selectNext() { - return this.changeSelection(1); + this.changeSelection(1); } selectPrevious() { - return this.changeSelection(-1); + this.changeSelection(-1); } - showForField(targetElement: HTMLElement): SuggestionsPopup { + showForField(targetElement: HTMLElement) { this.container.style.position = 'absolute'; this.container.style.left = `${targetElement.offsetLeft}px`; @@ -127,8 +127,6 @@ export class SuggestionsPopup { this.container.style.top = `${topPosition}px`; document.body.appendChild(this.container); - - return this; } onItemSelected(callback: (event: CustomEvent) => void) { @@ -138,7 +136,7 @@ export class SuggestionsPopup { const cachedSuggestions = new Map>(); -export async function fetchSuggestions(endpoint: string, targetTerm: string) { +export async function fetchSuggestions(endpoint: string, targetTerm: string): Promise { const normalizedTerm = targetTerm.trim().toLowerCase(); if (cachedSuggestions.has(normalizedTerm)) {