diff --git a/packages/app/src/app/pages/Dashboard/Content/routes/Search/index.tsx b/packages/app/src/app/pages/Dashboard/Content/routes/Search/index.tsx
index 2a645fb01df..25d89a4e8ea 100644
--- a/packages/app/src/app/pages/Dashboard/Content/routes/Search/index.tsx
+++ b/packages/app/src/app/pages/Dashboard/Content/routes/Search/index.tsx
@@ -19,7 +19,7 @@ export const SearchComponent = () => {
} = useAppState();
const location = useLocation();
const query = new URLSearchParams(location.search).get('query');
- const [items] = useGetItems({
+ const [items, _, isLoadingQuery] = useGetItems({
query,
username: user?.username,
getFilteredSandboxes,
@@ -48,8 +48,9 @@ export const SearchComponent = () => {
) : (
- There are no sandboxes, branches or repositories that match your
- query
+ {isLoadingQuery
+ ? 'Loading index...'
+ : 'There are no sandboxes, branches or repositories that match your query'}
)}
diff --git a/packages/app/src/app/pages/Dashboard/Content/routes/Search/searchItems.ts b/packages/app/src/app/pages/Dashboard/Content/routes/Search/searchItems.ts
index 6831a6686e0..6b3964d307a 100644
--- a/packages/app/src/app/pages/Dashboard/Content/routes/Search/searchItems.ts
+++ b/packages/app/src/app/pages/Dashboard/Content/routes/Search/searchItems.ts
@@ -9,78 +9,86 @@ import Fuse from 'fuse.js';
import React, { useEffect } from 'react';
import { sandboxesTypes } from 'app/overmind/namespaces/dashboard/types';
-const useSearchedSandboxes = (query: string) => {
- const state = useAppState();
- const actions = useActions();
- const [foundResults, setFoundResults] = React.useState<
- | (SandboxFragmentDashboardFragment | SidebarCollectionDashboardFragment)[]
- | null
- >(null);
- const [searchIndex, setSearchindex] = React.useState | null>(null);
-
- useEffect(() => {
- actions.dashboard.getPage(sandboxesTypes.SEARCH);
- }, [actions.dashboard, state.activeTeam]);
-
- useEffect(
- () => {
- setSearchindex(calculateSearchIndex(state.dashboard, state.activeTeam));
- },
- // eslint-disable-next-line react-hooks/exhaustive-deps
- [
- state.dashboard.sandboxes.SEARCH,
- state.dashboard.repositoriesByTeamId,
- state.activeTeam,
- ]
- );
-
- useEffect(() => {
- if (searchIndex) {
- setFoundResults(searchIndex.search(query));
- }
- // eslint-disable-next-line react-hooks/exhaustive-deps
- }, [query, searchIndex]);
-
- return foundResults;
-};
-
-const calculateSearchIndex = (dashboard: any, activeTeam: string) => {
- const sandboxes = dashboard.sandboxes.SEARCH || [];
-
- const folders: Collection[] = (dashboard.allCollections || [])
- .map(collection => ({
- ...collection,
- title: collection.name,
+type DashboardItem =
+ | SandboxFragmentDashboardFragment
+ | SidebarCollectionDashboardFragment;
+
+// define which fields to search, with per-key thresholds & weights
+const SEARCH_KEYS = [
+ { name: 'title', threshold: 0.2, weight: 0.4 },
+ { name: 'description', threshold: 0.3, weight: 0.2 },
+ { name: 'alias', threshold: 0.3, weight: 0.2 },
+ { name: 'source.template', threshold: 0.4, weight: 0.1 },
+ { name: 'id', threshold: 0.0, weight: 0.1 }, // exact-only
+] as const;
+
+interface SearchIndex {
+ fuses: Record>;
+ weights: Record;
+ items: DashboardItem[];
+}
+
+const buildSearchIndex = (dashboard: any, activeTeam: string): SearchIndex => {
+ const sandboxes: DashboardItem[] = dashboard.sandboxes.SEARCH || [];
+
+ const folders: DashboardItem[] = (dashboard.allCollections || [])
+ .map((c: Collection) => ({
+ ...c,
+ title: c.name,
}))
.filter(f => f.title);
- const teamRepos = dashboard.repositoriesByTeamId[activeTeam] ?? [];
- const repositories = (teamRepos || []).map((repo: Repository) => {
- return {
- title: repo.repository.name,
- /**
- * Due to the lack of description we add the owner so we can at least
- * include that in the search query.
- */
- description: repo.repository.owner,
- ...repo,
- };
- });
+ const repos: DashboardItem[] = (
+ dashboard.repositoriesByTeamId[activeTeam] || []
+ ).map((r: Repository) => ({
+ title: r.repository.name,
+ description: r.repository.owner,
+ ...r,
+ }));
+
+ const items = [...sandboxes, ...folders, ...repos];
+
+ // build a Fuse instance per key
+ const fuses: Record> = {};
+ const weights: Record = {};
+
+ for (const { name, threshold, weight } of SEARCH_KEYS) {
+ fuses[name] = new Fuse(items, {
+ keys: [name],
+ threshold: threshold,
+ distance: 1000,
+ });
+ weights[name] = weight;
+ }
+
+ return { fuses, weights, items };
+};
- return new Fuse([...sandboxes, ...folders, ...repositories], {
- threshold: 0.1,
- distance: 1000,
- keys: [
- { name: 'title', weight: 0.4 },
- { name: 'description', weight: 0.2 },
- { name: 'alias', weight: 0.2 },
- { name: 'source.template', weight: 0.1 },
- { name: 'id', weight: 0.1 },
- ],
- });
+// merge+dedupe results from every key
+const mergeSearchResults = (
+ index: SearchIndex,
+ query: string
+): DashboardItem[] => {
+ const hits: Array = [];
+
+ for (const key of Object.keys(index.fuses)) {
+ const fuse = index.fuses[key];
+ for (const item of fuse.search(query)) {
+ hits.push(item);
+ }
+ }
+
+ // dedupe by item.id, keep the best (lowest) weighted score
+ const byId: Record = {};
+ for (const item of hits) {
+ const id = (item as any).id as string;
+ if (!byId[id]) {
+ byId[id] = item;
+ }
+ }
+
+ // sort & return
+ return Object.values(byId);
};
export const useGetItems = ({
@@ -91,73 +99,71 @@ export const useGetItems = ({
query: string;
username: string;
getFilteredSandboxes: (
- sandboxes: (
- | SandboxFragmentDashboardFragment
- | SidebarCollectionDashboardFragment
- )[]
+ list: DashboardItem[]
) => SandboxFragmentDashboardFragment[];
}) => {
- const foundResults: Array<
- SandboxFragmentDashboardFragment | SidebarCollectionDashboardFragment
- > = useSearchedSandboxes(query) || [];
+ const state = useAppState();
+ const actions = useActions();
- // @ts-ignore
- const sandboxesInSearch = foundResults.filter(s => !s.path);
- // @ts-ignore
- const foldersInSearch = foundResults.filter(s => s.path);
+ // load page once
+ useEffect(() => {
+ actions.dashboard.getPage(sandboxesTypes.SEARCH);
+ }, [actions.dashboard, state.activeTeam]);
- const filteredSandboxes: SandboxFragmentDashboardFragment[] = getFilteredSandboxes(
- sandboxesInSearch
+ // keep a SearchIndex in state
+ const [searchIndex, setSearchIndex] = React.useState(
+ null
);
+ useEffect(() => {
+ if (!state.dashboard.sandboxes.SEARCH || !state.dashboard.allCollections)
+ return;
+ const idx = buildSearchIndex(state.dashboard, state.activeTeam);
+ setSearchIndex(idx);
+ }, [
+ state.dashboard.sandboxes.SEARCH,
+ state.dashboard.allCollections,
+ state.dashboard.repositoriesByTeamId,
+ state.activeTeam,
+ ]);
+
+ // run the merged search whenever query or index changes
+ const [foundResults, setFoundResults] = React.useState([]);
+ useEffect(() => {
+ if (searchIndex && query) {
+ setFoundResults(mergeSearchResults(searchIndex, query));
+ } else {
+ setFoundResults([]);
+ }
+ }, [query, searchIndex]);
- const orderedSandboxes = [...foldersInSearch, ...filteredSandboxes].filter(
- item => {
- // @ts-ignore
- if (item.path || item.repository) {
- return true;
- }
+ // then the rest is just your existing filtering / mapping logic:
+ const sandboxesInSearch = foundResults.filter(s => !(s as any).path);
+ const foldersInSearch = foundResults.filter(s => (s as any).path);
+ const filteredSandboxes = getFilteredSandboxes(sandboxesInSearch);
+ const isLoadingQuery = query && !searchIndex;
- const sandbox = item as SandboxFragmentDashboardFragment;
+ const ordered = [...foldersInSearch, ...filteredSandboxes].filter(item => {
+ if ((item as any).path || (item as any).repository) return true;
+ const sb = item as SandboxFragmentDashboardFragment;
+ return !sb.draft || (sb.draft && sb.author.username === username);
+ });
- // Remove draft sandboxes from other authors
- return (
- !sandbox.draft ||
- (sandbox.draft && sandbox.author.username === username)
- );
+ const items = ordered.map(found => {
+ if ((found as any).path) {
+ return { type: 'folder', ...(found as object) } as any;
}
- );
+ if ((found as any).repository) {
+ const f = found as any;
+ return {
+ type: 'repository',
+ repository: {
+ branchCount: f.branchCount,
+ repository: f.repository,
+ },
+ } as any;
+ }
+ return { type: 'sandbox', sandbox: found } as any;
+ });
- // @ts-ignore
- const items: DashboardGridItem[] =
- foundResults != null
- ? orderedSandboxes.map(found => {
- // @ts-ignore
- if (found.path) {
- return {
- type: 'folder',
- ...found,
- };
- }
-
- // @ts-ignore
- if (found.repository) {
- return {
- type: 'repository',
- repository: {
- // @ts-ignore
- branchCount: found.branchCount,
- // @ts-ignore
- repository: found.repository,
- },
- };
- }
-
- return {
- type: 'sandbox',
- sandbox: found,
- };
- })
- : [{ type: 'skeleton-row' }];
-
- return [items, sandboxesInSearch];
+ return [items, sandboxesInSearch, isLoadingQuery] as const;
};