Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
7 changes: 4 additions & 3 deletions frontend/src/components/ui/search-combobox.ts
Original file line number Diff line number Diff line change
Expand Up @@ -155,9 +155,10 @@ export class SearchCombobox<T> 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`
<sl-menu-item slot="menu-item" disabled
Expand Down
97 changes: 97 additions & 0 deletions frontend/src/context/search-org/SearchOrgContextController.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,97 @@
import { ContextProvider } from "@lit/context";
import { Task } from "@lit/task";
import { type ReactiveController } from "lit";

import { connectFuse } from "./connectFuse";
import {
searchOrgContext,
searchOrgInitialValue,
type SearchOrgContext,
} from "./search-org";
import { type SearchOrgKey, type SearchQuery } from "./types";

import type { BtrixElement } from "@/classes/BtrixElement";
import type { CollectionSearchValues } from "@/types/collection";

/**
* Provides org-wide search data to all descendents of a component.
*
* @example Usage:
* ```ts
* class Component extends BtrixElement {
* readonly [searchOrgContextKey] = new SearchOrgContextController(this);
* }
* ```
*/
export class SearchOrgContextController implements ReactiveController {
readonly #host: BtrixElement;
readonly #context: ContextProvider<{ __context__: SearchOrgContext }>;
readonly #tasks = new Map<SearchOrgKey, Task>();

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<SearchQuery[]>,
) {
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<CollectionSearchValues>(
`/orgs/${orgId}/collections/search-values`,
{ signal },
);

return names.map((name) => ({ name }));
} catch (err) {
console.debug(err);
}

return [];
};
}
32 changes: 32 additions & 0 deletions frontend/src/context/search-org/WithSearchOrgContext.ts
Original file line number Diff line number Diff line change
@@ -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 = <T extends Constructor<LitElement>>(
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;
}
};
14 changes: 14 additions & 0 deletions frontend/src/context/search-org/connectFuse.ts
Original file line number Diff line number Diff line change
@@ -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,
});
}
5 changes: 5 additions & 0 deletions frontend/src/context/search-org/index.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
import { searchOrgContext, type SearchOrgContext } from "./search-org";

export type { SearchOrgContext };

export default searchOrgContext;
20 changes: 20 additions & 0 deletions frontend/src/context/search-org/search-org.ts
Original file line number Diff line number Diff line change
@@ -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<SearchOrgKey, Fuse<SearchQuery> | null>;

export const searchOrgInitialValue = {
collections: null,
} as const satisfies SearchOrgContext;

export const searchOrgContext =
createContext<SearchOrgContext>(searchOrgContextKey);
4 changes: 4 additions & 0 deletions frontend/src/context/search-org/types.ts
Original file line number Diff line number Diff line change
@@ -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";
Loading
Loading