Embed filter selection in query params (#276)

This commit is contained in:
Brendan Kellam 2025-04-28 12:10:43 -07:00 committed by GitHub
parent 78ec512770
commit ceb8b3ab2e
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
4 changed files with 77 additions and 55 deletions

View file

@ -7,6 +7,9 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
## [Unreleased] ## [Unreleased]
### Changed
- Changed the filter panel to embed the filter selection state in the query params. [#276](https://github.com/sourcebot-dev/sourcebot/pull/276)
## [3.1.0] - 2025-04-25 ## [3.1.0] - 2025-04-25
### Added ### Added

View file

@ -1,13 +1,14 @@
'use client'; 'use client';
import { FileIcon } from "@/components/ui/fileIcon";
import { Repository, SearchResultFile } from "@/lib/types"; import { Repository, SearchResultFile } from "@/lib/types";
import { cn, getRepoCodeHostInfo } from "@/lib/utils"; import { cn, getRepoCodeHostInfo } from "@/lib/utils";
import { SetStateAction, useCallback, useEffect, useState } from "react"; import { LaptopIcon } from "@radix-ui/react-icons";
import Image from "next/image";
import { useRouter, useSearchParams } from "next/navigation";
import { useEffect, useMemo } from "react";
import { Entry } from "./entry"; import { Entry } from "./entry";
import { Filter } from "./filter"; import { Filter } from "./filter";
import Image from "next/image";
import { LaptopIcon } from "@radix-ui/react-icons";
import { FileIcon } from "@/components/ui/fileIcon";
interface FilePanelProps { interface FilePanelProps {
matches: SearchResultFile[]; matches: SearchResultFile[];
@ -15,16 +16,26 @@ interface FilePanelProps {
repoMetadata: Record<string, Repository>; repoMetadata: Record<string, Repository>;
} }
const LANGUAGES_QUERY_PARAM = "langs";
const REPOS_QUERY_PARAM = "repos";
export const FilterPanel = ({ export const FilterPanel = ({
matches, matches,
onFilterChanged, onFilterChanged,
repoMetadata, repoMetadata,
}: FilePanelProps) => { }: FilePanelProps) => {
const [repos, setRepos] = useState<Record<string, Entry>>({}); const router = useRouter();
const [languages, setLanguages] = useState<Record<string, Entry>>({}); const searchParams = useSearchParams();
useEffect(() => { // Helper to parse query params into sets
const _repos = aggregateMatches( const getSelectedFromQuery = (param: string) => {
const value = searchParams.get(param);
return value ? new Set(value.split(',')) : new Set();
};
const repos = useMemo(() => {
const selectedRepos = getSelectedFromQuery(REPOS_QUERY_PARAM);
return aggregateMatches(
"Repository", "Repository",
matches, matches,
(key) => { (key) => {
@ -44,17 +55,16 @@ export const FilterPanel = ({
key, key,
displayName: info?.displayName ?? key, displayName: info?.displayName ?? key,
count: 0, count: 0,
isSelected: false, isSelected: selectedRepos.has(key),
Icon, Icon,
}; };
} }
); )
}, [searchParams]);
setRepos(_repos); const languages = useMemo(() => {
}, [matches, repoMetadata, setRepos]); const selectedLanguages = getSelectedFromQuery(LANGUAGES_QUERY_PARAM);
return aggregateMatches(
useEffect(() => {
const _languages = aggregateMatches(
"Language", "Language",
matches, matches,
(key) => { (key) => {
@ -66,40 +76,18 @@ export const FilterPanel = ({
key, key,
displayName: key, displayName: key,
count: 0, count: 0,
isSelected: false, isSelected: selectedLanguages.has(key),
Icon: Icon, Icon: Icon,
} satisfies Entry; } satisfies Entry;
} }
) );
}, [searchParams]);
setLanguages(_languages);
}, [matches, setLanguages]);
const onEntryClicked = useCallback((
key: string,
setter: (value: SetStateAction<Record<string, Entry>>) => void,
) => {
setter((values) => ({
...values,
[key]: {
...values[key],
isSelected: !values[key].isSelected,
},
}));
}, []);
// Calls `onFilterChanged` with the filtered list of matches
// whenever the filter state changes.
useEffect(() => { useEffect(() => {
const selectedRepos = new Set( const selectedRepos = new Set(Object.keys(repos).filter((key) => repos[key].isSelected));
Object.entries(repos) const selectedLanguages = new Set(Object.keys(languages).filter((key) => languages[key].isSelected));
.filter(([_, { isSelected }]) => isSelected)
.map(([key]) => key)
);
const selectedLanguages = new Set(
Object.entries(languages)
.filter(([_, { isSelected }]) => isSelected)
.map(([key]) => key)
);
const filteredMatches = matches.filter((match) => const filteredMatches = matches.filter((match) =>
( (
@ -107,26 +95,57 @@ export const FilterPanel = ({
(selectedLanguages.size === 0 ? true : selectedLanguages.has(match.Language)) (selectedLanguages.size === 0 ? true : selectedLanguages.has(match.Language))
) )
); );
onFilterChanged(filteredMatches); onFilterChanged(filteredMatches);
}, [matches, repos, languages, onFilterChanged]);
const numRepos = Object.keys(repos).length > 100 ? '100+' : Object.keys(repos).length; }, [matches, repos, languages, onFilterChanged, searchParams, router]);
const numLanguages = Object.keys(languages).length > 100 ? '100+' : Object.keys(languages).length;
const numRepos = useMemo(() => Object.keys(repos).length > 100 ? '100+' : Object.keys(repos).length, [repos]);
const numLanguages = useMemo(() => Object.keys(languages).length > 100 ? '100+' : Object.keys(languages).length, [languages]);
return ( return (
<div className="p-3 flex flex-col gap-3 h-full"> <div className="p-3 flex flex-col gap-3 h-full">
<Filter <Filter
title="Filter By Repository" title="Filter By Repository"
searchPlaceholder={`Filter ${numRepos} repositories`} searchPlaceholder={`Filter ${numRepos} repositories`}
entries={Object.values(repos)} entries={Object.values(repos)}
onEntryClicked={(key) => onEntryClicked(key, setRepos)} onEntryClicked={(key) => {
const newRepos = { ...repos };
newRepos[key].isSelected = !newRepos[key].isSelected;
const selectedRepos = Object.keys(newRepos).filter((key) => newRepos[key].isSelected);
const newParams = new URLSearchParams(searchParams.toString());
if (selectedRepos.length > 0) {
newParams.set(REPOS_QUERY_PARAM, selectedRepos.join(','));
} else {
newParams.delete(REPOS_QUERY_PARAM);
}
if (newParams.toString() !== searchParams.toString()) {
router.replace(`?${newParams.toString()}`, { scroll: false });
}
}}
className="max-h-[50%]" className="max-h-[50%]"
/> />
<Filter <Filter
title="Filter By Language" title="Filter By Language"
searchPlaceholder={`Filter ${numLanguages} languages`} searchPlaceholder={`Filter ${numLanguages} languages`}
entries={Object.values(languages)} entries={Object.values(languages)}
onEntryClicked={(key) => onEntryClicked(key, setLanguages)} onEntryClicked={(key) => {
const newLanguages = { ...languages };
newLanguages[key].isSelected = !newLanguages[key].isSelected;
const selectedLanguages = Object.keys(newLanguages).filter((key) => newLanguages[key].isSelected);
const newParams = new URLSearchParams(searchParams.toString());
if (selectedLanguages.length > 0) {
newParams.set(LANGUAGES_QUERY_PARAM, selectedLanguages.join(','));
} else {
newParams.delete(LANGUAGES_QUERY_PARAM);
}
if (newParams.toString() !== searchParams.toString()) {
router.replace(`?${newParams.toString()}`, { scroll: false });
}
}}
className="overflow-auto" className="overflow-auto"
/> />
</div> </div>

View file

@ -47,7 +47,7 @@ const SearchPageInternal = () => {
const domain = useDomain(); const domain = useDomain();
const { toast } = useToast(); const { toast } = useToast();
const { data: searchResponse, isLoading, error } = useQuery({ const { data: searchResponse, isLoading: isSearchLoading, error } = useQuery({
queryKey: ["search", searchQuery, maxMatchDisplayCount], queryKey: ["search", searchQuery, maxMatchDisplayCount],
queryFn: () => measure(() => unwrapServiceError(search({ queryFn: () => measure(() => unwrapServiceError(search({
query: searchQuery, query: searchQuery,
@ -91,7 +91,7 @@ const SearchPageInternal = () => {
// repository metadata (like host type, repo name, etc.) // repository metadata (like host type, repo name, etc.)
// Convert this into a map of repo name to repo metadata // Convert this into a map of repo name to repo metadata
// for easy lookup. // for easy lookup.
const { data: repoMetadata } = useQuery({ const { data: repoMetadata, isLoading: isRepoMetadataLoading } = useQuery({
queryKey: ["repos"], queryKey: ["repos"],
queryFn: () => getRepos(domain), queryFn: () => getRepos(domain),
select: (data): Record<string, Repository> => select: (data): Record<string, Repository> =>
@ -194,7 +194,7 @@ const SearchPageInternal = () => {
<Separator /> <Separator />
</div> </div>
{isLoading ? ( {(isSearchLoading || isRepoMetadataLoading) ? (
<div className="flex flex-col items-center justify-center h-full gap-2"> <div className="flex flex-col items-center justify-center h-full gap-2">
<SymbolIcon className="h-6 w-6 animate-spin" /> <SymbolIcon className="h-6 w-6 animate-spin" />
<p className="font-semibold text-center">Searching...</p> <p className="font-semibold text-center">Searching...</p>

View file

@ -17,11 +17,11 @@ import { useMemo } from "react";
*/ */
export const useNonEmptyQueryParam = (param: string) => { export const useNonEmptyQueryParam = (param: string) => {
const searchParams = useSearchParams(); const searchParams = useSearchParams();
const inviteId = useMemo(() => { const paramValue = useMemo(() => {
return getSearchParam(param, searchParams); return getSearchParam(param, searchParams);
}, [param, searchParams]); }, [param, searchParams]);
return inviteId; return paramValue;
}; };
/** /**