diff --git a/frontend/src/components/ui/search-combobox.ts b/frontend/src/components/ui/search-combobox.ts index 8681d63960..6907612a27 100644 --- a/frontend/src/components/ui/search-combobox.ts +++ b/frontend/src/components/ui/search-combobox.ts @@ -155,9 +155,10 @@ export class SearchCombobox extends LitElement { `; } - const searchResults = this.fuse - .search(this.searchByValue) - .slice(0, MAX_SEARCH_RESULTS); + const searchResults = this.fuse.search(this.searchByValue, { + limit: MAX_SEARCH_RESULTS, + }); + if (!searchResults.length) { return html` ; + readonly #tasks = new Map(); + + constructor(host: BtrixElement) { + this.#host = host; + this.#context = new ContextProvider(this.#host, { + context: searchOrgContext, + initialValue: searchOrgInitialValue, + }); + + this.addTask("collections", this.getCollectionsSearchValues); + + host.addController(this); + } + + hostConnected(): void {} + hostDisconnected(): void {} + + public async refresh(key?: SearchOrgKey) { + if (key) { + void this.#tasks.get(key)?.run(); + } else { + for (const [_key, task] of this.#tasks) { + void task.run(); + } + } + } + + private addTask( + key: SearchOrgKey, + request: (orgId: string, signal: AbortSignal) => Promise, + ) { + this.#tasks.set( + key, + new Task(this.#host, { + task: async ([orgId], { signal }) => { + if (!orgId) return null; + + const values = await request(orgId, signal); + + if (signal.aborted) return; + + this.#context.setValue({ + ...this.#context.value, + [key]: connectFuse(values), + }); + }, + args: () => [this.#host.appState.orgId] as const, + }), + ); + } + + private readonly getCollectionsSearchValues = async ( + orgId: string, + signal: AbortSignal, + ) => { + try { + const { names } = await this.#host.api.fetch( + `/orgs/${orgId}/collections/search-values`, + { signal }, + ); + + return names.map((name) => ({ name })); + } catch (err) { + console.debug(err); + } + + return []; + }; +} diff --git a/frontend/src/context/search-org/WithSearchOrgContext.ts b/frontend/src/context/search-org/WithSearchOrgContext.ts new file mode 100644 index 0000000000..df09c942ae --- /dev/null +++ b/frontend/src/context/search-org/WithSearchOrgContext.ts @@ -0,0 +1,32 @@ +import { ContextConsumer } from "@lit/context"; +import type { LitElement } from "lit"; +import type { Constructor } from "type-fest"; + +import { searchOrgContext, searchOrgInitialValue } from "./search-org"; +import type { SearchOrgKey } from "./types"; + +/** + * Consume search data. + * + * @example Usage: + * ```ts + * class Component extends WithSearchOrgContext(BtrixElement) {} + * ``` + */ +export const WithSearchOrgContext = >( + superClass: T, +) => + class extends superClass { + readonly #searchOrg = new ContextConsumer(this, { + context: searchOrgContext, + subscribe: true, + }); + + public get searchOrg() { + return this.#searchOrg.value || searchOrgInitialValue; + } + + public listSearchValuesFor(key: SearchOrgKey) { + return this.searchOrg[key]?.getIndex().toJSON().records || null; + } + }; diff --git a/frontend/src/context/search-org/connectFuse.ts b/frontend/src/context/search-org/connectFuse.ts new file mode 100644 index 0000000000..5e080c0a09 --- /dev/null +++ b/frontend/src/context/search-org/connectFuse.ts @@ -0,0 +1,14 @@ +/** + * Enable fuzzy search on available values. + */ +import Fuse from "fuse.js"; + +import { searchQueryKeys, type SearchQuery } from "./types"; + +export function connectFuse(values: SearchQuery[]) { + return new Fuse(values, { + keys: searchQueryKeys, + threshold: 0.3, + useExtendedSearch: true, + }); +} diff --git a/frontend/src/context/search-org/index.ts b/frontend/src/context/search-org/index.ts new file mode 100644 index 0000000000..e6685e59d6 --- /dev/null +++ b/frontend/src/context/search-org/index.ts @@ -0,0 +1,5 @@ +import { searchOrgContext, type SearchOrgContext } from "./search-org"; + +export type { SearchOrgContext }; + +export default searchOrgContext; diff --git a/frontend/src/context/search-org/search-org.ts b/frontend/src/context/search-org/search-org.ts new file mode 100644 index 0000000000..afe44cb889 --- /dev/null +++ b/frontend/src/context/search-org/search-org.ts @@ -0,0 +1,20 @@ +/** + * Store org-wide searchable data, like collection names. + */ +import { createContext } from "@lit/context"; +import type Fuse from "fuse.js"; + +import { + searchOrgContextKey, + type SearchOrgKey, + type SearchQuery, +} from "./types"; + +export type SearchOrgContext = Record | null>; + +export const searchOrgInitialValue = { + collections: null, +} as const satisfies SearchOrgContext; + +export const searchOrgContext = + createContext(searchOrgContextKey); diff --git a/frontend/src/context/search-org/types.ts b/frontend/src/context/search-org/types.ts new file mode 100644 index 0000000000..5fa0acafcc --- /dev/null +++ b/frontend/src/context/search-org/types.ts @@ -0,0 +1,4 @@ +export const searchQueryKeys = ["name"]; +export const searchOrgContextKey = Symbol("search-values"); +export type SearchQuery = Record<(typeof searchQueryKeys)[number], string>; +export type SearchOrgKey = "collections"; diff --git a/frontend/src/features/collections/collections-add.ts b/frontend/src/features/collections/collections-add.ts index 0412f64f74..d9cec2ac87 100644 --- a/frontend/src/features/collections/collections-add.ts +++ b/frontend/src/features/collections/collections-add.ts @@ -1,15 +1,17 @@ import { localized, msg } from "@lit/localize"; -import { Task, TaskStatus } from "@lit/task"; -import type { SlInput, SlMenuItem } from "@shoelace-style/shoelace"; -import Fuse from "fuse.js"; +import type { + SlInput, + SlInputEvent, + SlMenuItem, +} from "@shoelace-style/shoelace"; import { html, nothing } from "lit"; import { customElement, property, query, state } from "lit/decorators.js"; import { when } from "lit/directives/when.js"; -import debounce from "lodash/fp/debounce"; import queryString from "query-string"; import { BtrixElement } from "@/classes/BtrixElement"; import type { Combobox } from "@/components/ui/combobox"; +import { WithSearchOrgContext } from "@/context/search-org/WithSearchOrgContext"; import type { BtrixLoadedLinkedCollectionEvent, BtrixRemoveLinkedCollectionEvent, @@ -20,12 +22,11 @@ import type { APIPaginationQuery, APISortQuery, } from "@/types/api"; -import type { Collection, CollectionSearchValues } from "@/types/collection"; -import type { UnderlyingFunction } from "@/types/utils"; +import type { Collection } from "@/types/collection"; import { TwoWayMap } from "@/utils/TwoWayMap"; -const INITIAL_PAGE_SIZE = 10; const MIN_SEARCH_LENGTH = 1; +const MAX_SEARCH_RESULTS = 5; export type CollectionsChangeEvent = CustomEvent<{ collections: string[]; @@ -44,7 +45,7 @@ export type CollectionsChangeEvent = CustomEvent<{ */ @customElement("btrix-collections-add") @localized() -export class CollectionsAdd extends BtrixElement { +export class CollectionsAdd extends WithSearchOrgContext(BtrixElement) { @property({ type: Array }) initialCollections?: string[]; @@ -57,6 +58,9 @@ export class CollectionsAdd extends BtrixElement { @state() private collections: CollectionLikeItem[] = []; + @state() + private searchByValue = ""; + @query("#search-input") private readonly input?: SlInput | null; @@ -64,53 +68,19 @@ export class CollectionsAdd extends BtrixElement { private readonly combobox?: Combobox | null; // Map collection names to ID for managing search options - private readonly nameSearchMap = new TwoWayMap(); + private readonly nameSearchMap = new TwoWayMap< + /* name: */ string, + /* ID: */ string + >(); private get collectionIds() { return this.collections.map(({ id }) => id); } - private get searchByValue() { - return this.input ? this.input.value.trim() : ""; - } - private get hasSearchStr() { return this.searchByValue.length >= MIN_SEARCH_LENGTH; } - private readonly searchValuesTask = new Task(this, { - task: async (_args, { signal }) => { - const { names } = await this.getSearchValues(signal); - - return names; - }, - args: () => [] as const, - }); - - private readonly searchTask = new Task(this, { - task: async ([names], { signal }) => { - if (!names || signal.aborted) { - return; - } - - return new Fuse(names, { threshold: 0.4, minMatchCharLength: 2 }); - }, - args: () => [this.searchValuesTask.value] as const, - }); - - private readonly searchResultsTask = new Task(this, { - task: async ([searchByValue, hasSearchStr], { signal }) => { - if (!hasSearchStr) return []; - const data = await this.fetchCollectionsByPrefix(searchByValue, signal); - let searchResults: Collection[] = []; - if (data?.items.length) { - searchResults = this.filterOutSelectedCollections(data.items); - } - return searchResults; - }, - args: () => [this.searchByValue, this.hasSearchStr] as const, - }); - public focus() { // Move focus to search input this.input?.focus(); @@ -167,7 +137,8 @@ export class CollectionsAdd extends BtrixElement { } private renderSearch() { - const disabled = !this.searchValuesTask.value?.length; + const collections = this.listSearchValuesFor("collections"); + const disabled = !collections?.length; return html` ) => { this.combobox?.hide(); const item = e.detail.item; - const name = item.dataset["key"]; + const name = item.dataset["value"]; const collections = await this.getCollections({ namePrefix: name }); const coll = collections.items.find((c) => c.name === name); @@ -209,13 +180,15 @@ export class CollectionsAdd extends BtrixElement { this.combobox.show(); } }} - @sl-input=${this.onSearchInput as UnderlyingFunction< - typeof this.onSearchInput - >} + @sl-input=${(e: SlInputEvent) => { + const input = e.target as SlInput; + + this.searchByValue = input.value.trim(); + }} > ${when( - disabled && this.searchValuesTask.status === TaskStatus.COMPLETE, + disabled && collections, () => html`
${msg("No collections found.")} @@ -234,47 +207,72 @@ export class CollectionsAdd extends BtrixElement { } private renderSearchResults() { - return this.searchTask.render({ - pending: () => html` + if (!this.searchOrg.collections) { + html` - `, - complete: (fuse) => { - if (!this.hasSearchStr) { - return html` - - ${msg("Start typing to search Collections.")} - - `; - } - - const results = fuse - ?.search(this.searchByValue) - // Filter out items that have been selected - .filter(({ item }) => !this.nameSearchMap.get(item)) - // Show first few results - .slice(0, 5); - - if (!results?.length) { - return html` - - ${msg("No matching Collections found.")} - - `; - } + `; + } + + if (!this.hasSearchStr) { + return html` + + ${msg("Start typing to search Collections.")} + + `; + } + + // Use search pattern that excludes selected names + const includePattern = `"${this.searchByValue}"`; + // Fuse doesn't support escaping quotes or operators in expressions yet, + // so we still need to manually filter out collection names with quotes + // from the search results + // https://github.com/krisk/Fuse/issues/765 + const excludeWithQuotes: string[] = []; + const excludeWithoutQuotes: string[] = []; + + this.nameSearchMap.keys().forEach((name) => { + if (name.includes('"')) { + excludeWithQuotes.push(name); + } else { + excludeWithoutQuotes.push(`!"${name}"`); + } + }); + + const excludePattern = excludeWithoutQuotes.join(" "); + const pattern = + includePattern + (excludePattern ? ` ${excludePattern}` : ""); + + // TODO Evaluate performance of searching in render, which will block the main thread + const results = this.searchOrg.collections + ?.search(pattern, { + limit: MAX_SEARCH_RESULTS + excludeWithQuotes.length, + }) + .filter(({ item }) => !excludeWithQuotes.includes(item["name"])) + .slice(0, MAX_SEARCH_RESULTS); + + if (!results?.length) { + return html` + + ${msg("No matching Collections found.")} + + `; + } + return html` + ${results.map(({ item }) => { return html` - ${results.map(({ item }: { item: string }) => { - return html` - - ${item} - - `; - })} + + ${item["name"]} + `; - }, - }); + })} + `; } private removeCollection(collectionId: string) { @@ -291,49 +289,10 @@ export class CollectionsAdd extends BtrixElement { } } - private readonly onSearchInput = debounce(400)(() => { - void this.searchResultsTask.run(); - }); - private findCollectionIndexById(collectionId: string) { return this.collections.findIndex(({ id }) => id === collectionId); } - private filterOutSelectedCollections(results: Collection[]) { - return results.filter( - (result) => this.findCollectionIndexById(result.id) > -1, - ); - } - - private async fetchCollectionsByPrefix( - namePrefix: string, - signal?: AbortSignal, - ) { - try { - const results = await this.getCollections( - { - oid: this.orgId, - namePrefix: namePrefix, - sortBy: "name", - pageSize: INITIAL_PAGE_SIZE, - }, - signal, - ); - return results; - } catch (e) { - if ((e as Error).name === "AbortError") { - console.debug("Fetch aborted to throttle"); - } else { - this.notify.toast({ - message: msg("Sorry, couldn't retrieve Collections at this time."), - variant: "danger", - icon: "exclamation-octagon", - id: "collection-fetch-throttled", - }); - } - } - } - private async getCollections( params?: Partial<{ oid?: string; @@ -354,13 +313,6 @@ export class CollectionsAdd extends BtrixElement { return data; } - private async getSearchValues(signal: AbortSignal) { - return await this.api.fetch( - `/orgs/${this.orgId}/collections/search-values`, - { signal }, - ); - } - private async dispatchChange() { await this.updateComplete; this.dispatchEvent( diff --git a/frontend/src/pages/org/collections-list.ts b/frontend/src/pages/org/collections-list.ts index d7d49ff0ed..c31214354a 100644 --- a/frontend/src/pages/org/collections-list.ts +++ b/frontend/src/pages/org/collections-list.ts @@ -5,7 +5,6 @@ import type { SlMenuItem, SlRadioGroup, } from "@shoelace-style/shoelace"; -import Fuse from "fuse.js"; import { html, nothing, type PropertyValues } from "lit"; import { customElement, property, query, state } from "lit/decorators.js"; import { choose } from "lit/directives/choose.js"; @@ -18,6 +17,7 @@ import type { SelectNewDialogEvent } from "."; import { BtrixElement } from "@/classes/BtrixElement"; import { parsePage, type PageChangeEvent } from "@/components/ui/pagination"; +import { WithSearchOrgContext } from "@/context/search-org/WithSearchOrgContext"; import { ClipboardController } from "@/controllers/clipboard"; import type { CollectionSavedEvent } from "@/features/collections/collection-create-dialog"; import { SelectCollectionAccess } from "@/features/collections/select-collection-access"; @@ -27,11 +27,7 @@ import { RouteNamespace } from "@/routes"; import { metadata } from "@/strings/collections/metadata"; import { monthYearDateRange } from "@/strings/utils"; import type { APIPaginatedList, APIPaginationQuery } from "@/types/api"; -import { - CollectionAccess, - type Collection, - type CollectionSearchValues, -} from "@/types/collection"; +import { CollectionAccess, type Collection } from "@/types/collection"; import { SortDirection, type UnderlyingFunction } from "@/types/utils"; import { isApiError } from "@/utils/api"; import { pluralOf } from "@/utils/pluralize"; @@ -39,12 +35,6 @@ import { tw } from "@/utils/tailwind"; type Collections = APIPaginatedList; type SearchFields = "name"; -type SearchResult = { - item: { - key: SearchFields; - value: string; - }; -}; type SortField = | "modified" | "dateLatest" @@ -83,6 +73,7 @@ const sortableFields: Record< }, }; const MIN_SEARCH_LENGTH = 2; +const MAX_SEARCH_RESULTS = 5; enum ListView { List = "list", @@ -91,7 +82,7 @@ enum ListView { @customElement("btrix-collections-list") @localized() -export class CollectionsList extends BtrixElement { +export class CollectionsList extends WithSearchOrgContext(BtrixElement) { @property({ type: Boolean }) isCrawler?: boolean; @@ -138,13 +129,6 @@ export class CollectionsList extends BtrixElement { @query("sl-input") private readonly input?: SlInput | null; - // For fuzzy search: - private readonly fuse = new Fuse<{ key: "name"; value: string }>([], { - keys: ["value"], - shouldSort: false, - threshold: 0.2, // stricter; default is 0.6 - }); - private getShareLink(collection: Collection) { return `${window.location.protocol}//${window.location.hostname}${window.location.port ? `:${window.location.port}` : ""}/${collection.access === CollectionAccess.Private ? `${RouteNamespace.PrivateOrgs}/${this.orgSlugState}/collections/view` : `${RouteNamespace.PublicOrgs}/${this.orgSlugState}/collections`}/${collection.slug}`; } @@ -161,10 +145,6 @@ export class CollectionsList extends BtrixElement { } } - protected firstUpdated() { - void this.fetchSearchValues(); - } - render() { return html`
@@ -437,7 +417,10 @@ export class CollectionsList extends BtrixElement { `; } - const searchResults = this.fuse.search(this.searchByValue).slice(0, 10); + const searchResults = + this.searchOrg.collections?.search(this.searchByValue, { + limit: MAX_SEARCH_RESULTS, + }) || []; if (!searchResults.length) { return html` html` - - ${item.value} + ({ item }) => html` + + ${item.name} `, )} @@ -811,26 +790,6 @@ export class CollectionsList extends BtrixElement { } } - private async fetchSearchValues() { - try { - const searchValues: CollectionSearchValues = await this.api.fetch( - `/orgs/${this.orgId}/collections/search-values`, - ); - const names = searchValues.names; - - // Update search/filter collection - const toSearchItem = - (key: SearchFields) => - (value: string): SearchResult["item"] => ({ - key, - value, - }); - this.fuse.setCollection([...names.map(toSearchItem("name"))]); - } catch (e) { - console.debug(e); - } - } - private async fetchCollections(params?: APIPaginationQuery) { this.fetchErrorStatusCode = undefined; diff --git a/frontend/src/pages/org/index.ts b/frontend/src/pages/org/index.ts index e7c1980e83..7fd5f19c88 100644 --- a/frontend/src/pages/org/index.ts +++ b/frontend/src/pages/org/index.ts @@ -18,6 +18,8 @@ import type { import { BtrixElement } from "@/classes/BtrixElement"; import { proxiesContext, type ProxiesContext } from "@/context/org"; +import { SearchOrgContextController } from "@/context/search-org/SearchOrgContextController"; +import { searchOrgContextKey } from "@/context/search-org/types"; import type { QuotaUpdateDetail } from "@/controllers/api"; import needLogin from "@/decorators/needLogin"; import type { CollectionSavedEvent } from "@/features/collections/collection-create-dialog"; @@ -122,6 +124,8 @@ export class Org extends BtrixElement { @state() private isCreateDialogVisible = false; + private readonly [searchOrgContextKey] = new SearchOrgContextController(this); + connectedCallback() { if ( !this.orgTab || @@ -177,6 +181,7 @@ export class Org extends BtrixElement { } else if (changedProperties.has("orgTab") && this.orgId) { // Get most up to date org data void this.updateOrg(); + void this[searchOrgContextKey].refresh(); } if (changedProperties.has("openDialogName")) { // Sync URL to create dialog @@ -210,6 +215,7 @@ export class Org extends BtrixElement { } if (!this.userInfo || !this.orgId) return; + try { const org = await this.getOrg(this.orgId);