-
-
Notifications
You must be signed in to change notification settings - Fork 57
feat: Enable fuzzy search for collection auto-add #2871
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Changes from all commits
368ad9b
5596bc8
fd6b2a0
1f68e77
35cf195
20d3c97
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -1,25 +1,31 @@ | ||
import { localized, msg, str } from "@lit/localize"; | ||
import { Task } from "@lit/task"; | ||
import { localized, msg } from "@lit/localize"; | ||
import { Task, TaskStatus } from "@lit/task"; | ||
import type { SlInput, SlMenuItem } from "@shoelace-style/shoelace"; | ||
import { html } from "lit"; | ||
import Fuse from "fuse.js"; | ||
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 type { BtrixRemoveLinkedCollectionEvent } from "@/features/collections/linked-collections/types"; | ||
import type { | ||
BtrixLoadedLinkedCollectionEvent, | ||
BtrixRemoveLinkedCollectionEvent, | ||
CollectionLikeItem, | ||
} from "@/features/collections/linked-collections/types"; | ||
import type { | ||
APIPaginatedList, | ||
APIPaginationQuery, | ||
APISortQuery, | ||
} from "@/types/api"; | ||
import type { Collection } from "@/types/collection"; | ||
import type { Collection, CollectionSearchValues } from "@/types/collection"; | ||
import type { UnderlyingFunction } from "@/types/utils"; | ||
import { TwoWayMap } from "@/utils/TwoWayMap"; | ||
|
||
const INITIAL_PAGE_SIZE = 10; | ||
const MIN_SEARCH_LENGTH = 2; | ||
const MIN_SEARCH_LENGTH = 1; | ||
|
||
export type CollectionsChangeEvent = CustomEvent<{ | ||
collections: string[]; | ||
|
@@ -48,19 +54,22 @@ export class CollectionsAdd extends BtrixElement { | |
@property({ type: String }) | ||
label?: string; | ||
|
||
/* Text to show on collection empty state */ | ||
@property({ type: String }) | ||
emptyText?: string; | ||
|
||
@state() | ||
private collectionIds: string[] = []; | ||
private collections: CollectionLikeItem[] = []; | ||
|
||
@query("#search-input") | ||
private readonly input?: SlInput | null; | ||
|
||
@query("btrix-combobox") | ||
private readonly combobox?: Combobox | null; | ||
|
||
// Map collection names to ID for managing search options | ||
private readonly nameSearchMap = new TwoWayMap<string, string>(); | ||
|
||
private get collectionIds() { | ||
return this.collections.map(({ id }) => id); | ||
} | ||
|
||
private get searchByValue() { | ||
return this.input ? this.input.value.trim() : ""; | ||
} | ||
|
@@ -69,6 +78,26 @@ export class CollectionsAdd extends BtrixElement { | |
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, | ||
}); | ||
Comment on lines
+90
to
+99
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. I'm not sure I understand why this is a |
||
|
||
private readonly searchResultsTask = new Task(this, { | ||
task: async ([searchByValue, hasSearchStr], { signal }) => { | ||
if (!hasSearchStr) return []; | ||
|
@@ -89,51 +118,57 @@ export class CollectionsAdd extends BtrixElement { | |
|
||
connectedCallback() { | ||
if (this.initialCollections) { | ||
this.collectionIds = this.initialCollections; | ||
this.collections = this.initialCollections.map((id) => ({ id })); | ||
} | ||
super.connectedCallback(); | ||
} | ||
|
||
disconnectedCallback() { | ||
super.disconnectedCallback(); | ||
} | ||
|
||
render() { | ||
return html`<div> | ||
<label class="form-label"> | ||
${this.label || msg("Add to Collection")} | ||
</label> | ||
<div class="mb-2 rounded-lg border bg-neutral-50 p-2"> | ||
<div class="rounded-lg border bg-neutral-50 p-2"> | ||
${this.renderSearch()} | ||
</div> | ||
|
||
${when(this.collectionIds, () => | ||
this.collectionIds.length | ||
${when(this.collections, (collections) => | ||
collections.length | ||
? html` | ||
<div class="mb-2"> | ||
<div class="mt-2"> | ||
<btrix-linked-collections | ||
.collectionIds=${this.collectionIds} | ||
.collections=${collections} | ||
removable | ||
@btrix-loaded=${(e: BtrixLoadedLinkedCollectionEvent) => { | ||
const { item } = e.detail; | ||
|
||
if (item.name) { | ||
this.nameSearchMap.set(item.name, item.id); | ||
} | ||
}} | ||
@btrix-remove=${(e: BtrixRemoveLinkedCollectionEvent) => { | ||
const { id } = e.detail.item; | ||
|
||
this.removeCollection(id); | ||
|
||
// Remove from search mapping | ||
const name = this.nameSearchMap.getByValue(id); | ||
|
||
if (name) { | ||
this.nameSearchMap.delete(name); | ||
} | ||
}} | ||
></btrix-linked-collections> | ||
</div> | ||
` | ||
: this.emptyText | ||
? html` | ||
<div class="mb-2"> | ||
<p class="text-0-500 text-center">${this.emptyText}</p> | ||
</div> | ||
` | ||
: "", | ||
: nothing, | ||
)} | ||
</div>`; | ||
} | ||
|
||
private renderSearch() { | ||
const disabled = !this.searchValuesTask.value?.length; | ||
|
||
return html` | ||
<btrix-combobox | ||
@request-close=${() => { | ||
|
@@ -143,24 +178,29 @@ export class CollectionsAdd extends BtrixElement { | |
@sl-select=${async (e: CustomEvent<{ item: SlMenuItem }>) => { | ||
this.combobox?.hide(); | ||
const item = e.detail.item; | ||
const collId = item.dataset["key"]; | ||
if (collId && this.collectionIds.indexOf(collId) === -1) { | ||
const coll = this.searchResultsTask.value?.find( | ||
(collection) => collection.id === collId, | ||
); | ||
if (coll) { | ||
const { id } = coll; | ||
this.collectionIds = [...this.collectionIds, id]; | ||
void this.dispatchChange(); | ||
const name = item.dataset["key"]; | ||
|
||
const collections = await this.getCollections({ namePrefix: name }); | ||
const coll = collections.items.find((c) => c.name === name); | ||
|
||
if (coll && this.findCollectionIndexById(coll.id) === -1) { | ||
this.collections = [...this.collections, coll]; | ||
void this.dispatchChange(); | ||
|
||
this.nameSearchMap.set(coll.name, coll.id); | ||
|
||
if (this.input) { | ||
this.input.value = ""; | ||
} | ||
} | ||
}} | ||
> | ||
<sl-input | ||
id="search-input" | ||
size="small" | ||
placeholder=${msg("Search by Collection name")} | ||
placeholder=${msg("Search for collection by name")} | ||
clearable | ||
?disabled=${disabled} | ||
@sl-clear=${() => { | ||
this.combobox?.hide(); | ||
}} | ||
|
@@ -174,20 +214,33 @@ export class CollectionsAdd extends BtrixElement { | |
>} | ||
> | ||
<sl-icon name="search" slot="prefix"></sl-icon> | ||
${when( | ||
disabled && this.searchValuesTask.status === TaskStatus.COMPLETE, | ||
() => html` | ||
<div slot="help-text"> | ||
${msg("No collections found.")} | ||
<btrix-link | ||
href="${this.navigate.orgBasePath}/collections" | ||
target="_blank" | ||
>${msg("Manage Collections")}</btrix-link | ||
> | ||
</div> | ||
`, | ||
)} | ||
</sl-input> | ||
${this.renderSearchResults()} | ||
</btrix-combobox> | ||
`; | ||
} | ||
|
||
private renderSearchResults() { | ||
return this.searchResultsTask.render({ | ||
return this.searchTask.render({ | ||
pending: () => html` | ||
<sl-menu-item slot="menu-item" disabled> | ||
<sl-spinner></sl-spinner> | ||
</sl-menu-item> | ||
`, | ||
complete: (searchResults) => { | ||
complete: (fuse) => { | ||
if (!this.hasSearchStr) { | ||
return html` | ||
<sl-menu-item slot="menu-item" disabled> | ||
|
@@ -196,12 +249,14 @@ export class CollectionsAdd extends BtrixElement { | |
`; | ||
} | ||
|
||
// Filter out stale search results from last debounce invocation | ||
const results = searchResults.filter((res) => | ||
new RegExp(`^${this.searchByValue}`, "i").test(res.name), | ||
); | ||
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) { | ||
if (!results?.length) { | ||
return html` | ||
<sl-menu-item slot="menu-item" disabled> | ||
${msg("No matching Collections found.")} | ||
|
@@ -210,16 +265,10 @@ export class CollectionsAdd extends BtrixElement { | |
} | ||
|
||
return html` | ||
${results.map((item: Collection) => { | ||
${results.map(({ item }: { item: string }) => { | ||
return html` | ||
<sl-menu-item slot="menu-item" data-key=${item.id}> | ||
${item.name} | ||
<div | ||
slot="suffix" | ||
class="font-monostyle flex-auto text-right text-xs text-neutral-500" | ||
> | ||
${msg(str`${item.crawlCount} items`)} | ||
</div> | ||
<sl-menu-item slot="menu-item" data-key=${item}> | ||
${item} | ||
</sl-menu-item> | ||
`; | ||
})} | ||
|
@@ -230,11 +279,12 @@ export class CollectionsAdd extends BtrixElement { | |
|
||
private removeCollection(collectionId: string) { | ||
if (collectionId) { | ||
const collIdIndex = this.collectionIds.indexOf(collectionId); | ||
const collIdIndex = this.findCollectionIndexById(collectionId); | ||
|
||
if (collIdIndex > -1) { | ||
this.collectionIds = [ | ||
...this.collectionIds.slice(0, collIdIndex), | ||
...this.collectionIds.slice(collIdIndex + 1), | ||
this.collections = [ | ||
...this.collections.slice(0, collIdIndex), | ||
...this.collections.slice(collIdIndex + 1), | ||
]; | ||
void this.dispatchChange(); | ||
} | ||
|
@@ -245,10 +295,14 @@ export class CollectionsAdd extends BtrixElement { | |
void this.searchResultsTask.run(); | ||
}); | ||
|
||
private findCollectionIndexById(collectionId: string) { | ||
return this.collections.findIndex(({ id }) => id === collectionId); | ||
} | ||
|
||
private filterOutSelectedCollections(results: Collection[]) { | ||
return results.filter((result) => { | ||
return !this.collectionIds.some((id) => id === result.id); | ||
}); | ||
return results.filter( | ||
(result) => this.findCollectionIndexById(result.id) > -1, | ||
); | ||
} | ||
|
||
private async fetchCollectionsByPrefix( | ||
|
@@ -300,12 +354,19 @@ export class CollectionsAdd extends BtrixElement { | |
return data; | ||
} | ||
|
||
private async getSearchValues(signal: AbortSignal) { | ||
return await this.api.fetch<CollectionSearchValues>( | ||
`/orgs/${this.orgId}/collections/search-values`, | ||
{ signal }, | ||
); | ||
} | ||
|
||
private async dispatchChange() { | ||
await this.updateComplete; | ||
this.dispatchEvent( | ||
new CustomEvent("collections-change", { | ||
new CustomEvent<CollectionsChangeEvent["detail"]>("collections-change", { | ||
detail: { collections: this.collectionIds }, | ||
}) as CollectionsChangeEvent, | ||
}), | ||
); | ||
} | ||
} |
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -1 +1,3 @@ | ||
import "./linked-collections"; | ||
import "./linked-collections-list"; | ||
import "./linked-collections-list-item"; |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Should
minMatchCharLength
beMIN_SEARCH_LENGTH
? As is, when starting to search for something, you don't get any results until you've typed more than two characters, even though when you've only typed one character you get both a) results back from the search prefix endpoint, if there's any collections starting with the character you've entered, and b) a message that says "no matching collections found", which isn't true.