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; };