mirror of
https://github.com/sourcebot-dev/sourcebot.git
synced 2025-12-12 12:25:22 +00:00
Embed filter selection in query params (#276)
This commit is contained in:
parent
78ec512770
commit
ceb8b3ab2e
4 changed files with 77 additions and 55 deletions
|
|
@ -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
|
||||||
|
|
|
||||||
|
|
@ -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>
|
||||||
|
|
|
||||||
|
|
@ -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>
|
||||||
|
|
|
||||||
|
|
@ -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;
|
||||||
};
|
};
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue