diff --git a/web_src/js/features/comp/SearchRepoBox.ts b/web_src/js/features/comp/SearchRepoBox.ts index 46e7456468..51227bf509 100644 --- a/web_src/js/features/comp/SearchRepoBox.ts +++ b/web_src/js/features/comp/SearchRepoBox.ts @@ -1,22 +1,16 @@ -import {chooseFromApi} from '../../modules/search.ts'; +import {attachSearchBox} from '../../modules/search.ts'; const {appSubUrl} = window.config; type RepoSearchResponse = {data: Array<{repository: {full_name: string}}>}; -export async function initCompSearchRepoBox(el: HTMLElement) { +export function initCompSearchRepoBox(el: HTMLElement) { const uid = el.getAttribute('data-uid'); const exclusive = el.getAttribute('data-exclusive'); let url = `${appSubUrl}/repo/search?q={query}&uid=${uid}`; if (exclusive === 'true') url += `&exclusive=true`; - const input = el.querySelector('input.prompt')!; - - while (el.isConnected) { - const pick = await chooseFromApi(el, url, (response) => response.data.map((item) => ({ - title: item.repository.full_name.split('/')[1], - description: item.repository.full_name, - }))); - input.value = pick.title; - input.dispatchEvent(new Event('change', {bubbles: true})); - } + attachSearchBox(el, url, (response: RepoSearchResponse) => response.data.map((item) => ({ + title: item.repository.full_name.split('/')[1], + description: item.repository.full_name, + }))); } diff --git a/web_src/js/features/comp/SearchUserBox.ts b/web_src/js/features/comp/SearchUserBox.ts index b4850db46f..739b5c5994 100644 --- a/web_src/js/features/comp/SearchUserBox.ts +++ b/web_src/js/features/comp/SearchUserBox.ts @@ -1,11 +1,11 @@ -import {chooseFromApi, type SearchResult} from '../../modules/search.ts'; +import {attachSearchBox, type SearchResult} from '../../modules/search.ts'; const {appSubUrl} = window.config; const looksLikeEmailAddressCheck = /^\S+@\S+$/; type UserSearchResponse = {data: Array<{login: string; avatar_url: string; full_name: string}>}; -export async function initCompSearchUserBox() { +export function initCompSearchUserBox() { const box = document.querySelector('#search-user-box'); if (!box) return; @@ -13,23 +13,18 @@ export async function initCompSearchUserBox() { const allowEmailDescription = box.getAttribute('data-allow-email-description') ?? undefined; const includeOrgs = box.getAttribute('data-include-orgs') === 'true'; const url = `${appSubUrl}/user/search_candidates?q={query}&orgs=${includeOrgs}`; - const input = box.querySelector('input.prompt')!; - while (box.isConnected) { - const pick = await chooseFromApi(box, url, (response, query) => { - const items: SearchResult[] = []; - const queryUpper = query.toUpperCase(); - for (const item of response.data) { - const result: SearchResult = {title: item.login, image: item.avatar_url, description: item.full_name}; - if (queryUpper === item.login.toUpperCase()) items.unshift(result); // exact match floats to top - else items.push(result); - } - if (allowEmailInput && !items.length && looksLikeEmailAddressCheck.test(query)) { - items.push({title: query, description: allowEmailDescription}); - } - return items; - }); - input.value = pick.title; - input.dispatchEvent(new Event('change', {bubbles: true})); - } + attachSearchBox(box, url, (response: UserSearchResponse, query) => { + const items: SearchResult[] = []; + const queryUpper = query.toUpperCase(); + for (const item of response.data) { + const result: SearchResult = {title: item.login, image: item.avatar_url, description: item.full_name}; + if (queryUpper === item.login.toUpperCase()) items.unshift(result); // exact match floats to top + else items.push(result); + } + if (allowEmailInput && !items.length && looksLikeEmailAddressCheck.test(query)) { + items.push({title: query, description: allowEmailDescription}); + } + return items; + }); } diff --git a/web_src/js/features/repo-settings.ts b/web_src/js/features/repo-settings.ts index a0815212fe..f003200c3e 100644 --- a/web_src/js/features/repo-settings.ts +++ b/web_src/js/features/repo-settings.ts @@ -3,7 +3,7 @@ import {onInputDebounce, queryElems, toggleElem} from '../utils/dom.ts'; import {POST} from '../modules/fetch.ts'; import {initRepoSettingsBranchesDrag} from './repo-settings-branches.ts'; import {fomanticQuery} from '../modules/fomantic/base.ts'; -import {chooseFromApi} from '../modules/search.ts'; +import {attachSearchBox} from '../modules/search.ts'; import {globMatch} from '../utils/glob.ts'; const {appSubUrl} = window.config; @@ -48,20 +48,15 @@ function initRepoSettingsCollaboration() { type TeamSearchResponse = {data: Array<{name: string; permission: string}>}; -async function initRepoSettingsSearchTeamBox() { +function initRepoSettingsSearchTeamBox() { const box = document.querySelector('#search-team-box'); if (!box) return; const url = `${appSubUrl}/org/${box.getAttribute('data-org-name')}/teams/-/search?q={query}`; - const input = box.querySelector('input.prompt')!; - while (box.isConnected) { - const pick = await chooseFromApi(box, url, (response) => response.data.map((item) => ({ - title: item.name, - description: `${item.permission} access`, // TODO: translate this string - }))); - input.value = pick.title; - input.dispatchEvent(new Event('change', {bubbles: true})); - } + attachSearchBox(box, url, (response: TeamSearchResponse) => response.data.map((item) => ({ + title: item.name, + description: `${item.permission} access`, // TODO: translate this string + }))); } function initRepoSettingsGitHook() { diff --git a/web_src/js/modules/search.ts b/web_src/js/modules/search.ts index 5f580de54c..6a7c481387 100644 --- a/web_src/js/modules/search.ts +++ b/web_src/js/modules/search.ts @@ -14,101 +14,97 @@ function buildResultHTML(result: SearchResult): string { return html`${htmlRaw(img)}
${result.title}
${htmlRaw(desc)}
`; } -// Awaits one user selection from an autocomplete attached to `container`. Resolves with -// the chosen item; the caller writes it to whatever input/state it owns. Wrap in a loop -// to keep the search box live across selections. -export function chooseFromApi(container: HTMLElement, url: string, parse: (raw: T, query: string) => SearchResult[], {minCharacters = 2}: {minCharacters?: number} = {}): Promise { - return new Promise((resolve) => { - const input = container.querySelector('input.prompt') ?? container.querySelector('input')!; +/** Attach an API-driven autocomplete to `container`. `parse` maps the raw JSON response into the rendered result list. The selected result's title is written to the input on selection. */ +export function attachSearchBox(container: HTMLElement, url: string, parse: (raw: T, query: string) => SearchResult[], {minCharacters = 2}: {minCharacters?: number} = {}): void { + const input = container.querySelector('input.prompt') ?? container.querySelector('input'); + if (!input) return; - let resultsEl = container.querySelector(':scope > .results'); - if (!resultsEl) { - resultsEl = document.createElement('div'); - resultsEl.className = 'results'; - container.append(resultsEl); + let resultsEl = container.querySelector(':scope > .results'); + if (!resultsEl) { + resultsEl = document.createElement('div'); + resultsEl.className = 'results'; + container.append(resultsEl); + } + + let fetchController: AbortController | null = null; + const itemResults = new Map(); + const items = () => resultsEl.querySelectorAll('.result'); + + const hide = () => { + resultsEl.style.display = 'none'; + resultsEl.replaceChildren(); + itemResults.clear(); + }; + + const render = (results: SearchResult[]) => { + if (!results.length) return hide(); + resultsEl.replaceChildren(); + itemResults.clear(); + for (const result of results) { + const item = document.createElement('div'); + item.className = 'result'; + item.innerHTML = buildResultHTML(result); + itemResults.set(item, result); + resultsEl.append(item); } + resultsEl.style.display = 'block'; + }; - let fetchController: AbortController | null = null; - const lifecycleController = new AbortController(); - const itemResults = new Map(); - const items = () => resultsEl.querySelectorAll('.result'); + const select = (item: HTMLElement) => { + const picked = itemResults.get(item)!; + input.value = picked.title; + input.dispatchEvent(new Event('change', {bubbles: true})); + hide(); + }; - const hide = () => { - resultsEl.style.display = 'none'; - resultsEl.replaceChildren(); - itemResults.clear(); - }; + const performSearch = async (query: string) => { + fetchController?.abort(); + if (query.length < minCharacters) return hide(); + fetchController = new AbortController(); + try { + const response = await GET(url.replaceAll('{query}', encodeURIComponent(query)), {signal: fetchController.signal}); + if (!response.ok) return hide(); + const results = parse(await response.json(), query); + if (input.value !== query) return; // stale response racing a newer keystroke + render(results); + } catch (err) { + if ((err as Error).name !== 'AbortError') hide(); + } + }; - const render = (results: SearchResult[]) => { - if (!results.length) return hide(); - resultsEl.replaceChildren(); - itemResults.clear(); - for (const result of results) { - const item = document.createElement('div'); - item.className = 'result'; - item.innerHTML = buildResultHTML(result); - itemResults.set(item, result); - resultsEl.append(item); - } - resultsEl.style.display = 'block'; - }; + const debounced = debounce(200, (query: string) => { performSearch(query) }); - const finish = (item: HTMLElement) => { - const picked = itemResults.get(item)!; - hide(); - lifecycleController.abort(); - resolve(picked); - }; - - const performSearch = async (query: string) => { - fetchController?.abort(); - if (query.length < minCharacters) return hide(); - fetchController = new AbortController(); - try { - const response = await GET(url.replaceAll('{query}', encodeURIComponent(query)), {signal: fetchController.signal}); - if (!response.ok) return hide(); - const results = parse(await response.json(), query); - if (input.value !== query) return; // stale response racing a newer keystroke - render(results); - } catch (err) { - if ((err as Error).name !== 'AbortError') hide(); - } - }; - - const debounced = debounce(200, (query: string) => { performSearch(query) }); - - input.addEventListener('input', () => debounced(input.value), {signal: lifecycleController.signal}); - input.addEventListener('focus', () => { if (items().length) resultsEl.style.display = 'block'; }, {signal: lifecycleController.signal}); - input.addEventListener('keydown', (event) => { - const all = items(); - if (!all.length) return; - const activeIndex = Array.from(all).findIndex((it) => it.classList.contains('active')); - const move = (next: number) => { - event.preventDefault(); - all[activeIndex]?.classList.remove('active'); - all[next].classList.add('active'); - }; - if (event.key === 'ArrowDown') { - move((activeIndex + 1) % all.length); - } else if (event.key === 'ArrowUp') { - move(activeIndex <= 0 ? all.length - 1 : activeIndex - 1); - } else if (event.key === 'Enter' && activeIndex >= 0) { - event.preventDefault(); - finish(all[activeIndex]); - } else if (event.key === 'Escape') { - hide(); - } - }, {signal: lifecycleController.signal}); - // mousedown fires before input blur so the selection registers before blur-hide kicks in - resultsEl.addEventListener('mousedown', (event) => { - const target = (event.target as HTMLElement).closest('.result'); - if (!target) return; + input.addEventListener('input', () => debounced(input.value)); + input.addEventListener('focus', () => { if (items().length) resultsEl.style.display = 'block'; }); + input.addEventListener('keydown', (event) => { + const all = items(); + if (!all.length) return; + const activeIndex = Array.from(all).findIndex((item) => item.classList.contains('active')); + const move = (next: number) => { event.preventDefault(); - finish(target); - }, {signal: lifecycleController.signal}); - document.addEventListener('click', (event) => { - if (!container.contains(event.target as Node)) hide(); - }, {signal: lifecycleController.signal}); - input.addEventListener('blur', () => setTimeout(hide, 150), {signal: lifecycleController.signal}); // deferred so a result mousedown can land first + all[activeIndex]?.classList.remove('active'); + all[next].classList.add('active'); + }; + if (event.key === 'ArrowDown') { + move((activeIndex + 1) % all.length); + } else if (event.key === 'ArrowUp') { + move(activeIndex <= 0 ? all.length - 1 : activeIndex - 1); + } else if (event.key === 'Enter' && activeIndex >= 0) { + event.preventDefault(); + select(all[activeIndex]); + } else if (event.key === 'Escape') { + hide(); + } }); + // mousedown fires before input blur so the selection registers before blur-hide kicks in + resultsEl.addEventListener('mousedown', (event) => { + const target = (event.target as HTMLElement).closest('.result'); + if (!target) return; + event.preventDefault(); + select(target); + }); + document.addEventListener('click', (event) => { + if (!container.contains(event.target as Node)) hide(); + }); + input.addEventListener('blur', () => setTimeout(hide, 150)); // deferred so a result mousedown can land first }