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
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -48,8 +48,9 @@ export const SearchComponent = () => {
) : (
<Stack justify="center" align="center" marginTop={120}>
<Text variant="muted">
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'}
</Text>
</Stack>
)}
Expand Down
260 changes: 133 additions & 127 deletions packages/app/src/app/pages/Dashboard/Content/routes/Search/searchItems.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<Fuse<
SandboxFragmentDashboardFragment | SidebarCollectionDashboardFragment,
unknown
> | 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<string, Fuse<DashboardItem>>;
weights: Record<string, number>;
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<string, Fuse<DashboardItem>> = {};
const weights: Record<string, number> = {};

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<DashboardItem> = [];

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<string, DashboardItem> = {};
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 = ({
Expand All @@ -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<SearchIndex | null>(
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<DashboardItem[]>([]);
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;
};
Loading