mirror of
https://github.com/sourcebot-dev/sourcebot.git
synced 2025-12-12 04:15:30 +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]
|
||||
|
||||
### 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
|
||||
|
||||
### Added
|
||||
|
|
|
|||
|
|
@ -1,13 +1,14 @@
|
|||
'use client';
|
||||
|
||||
import { FileIcon } from "@/components/ui/fileIcon";
|
||||
import { Repository, SearchResultFile } from "@/lib/types";
|
||||
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 { Filter } from "./filter";
|
||||
import Image from "next/image";
|
||||
import { LaptopIcon } from "@radix-ui/react-icons";
|
||||
import { FileIcon } from "@/components/ui/fileIcon";
|
||||
|
||||
interface FilePanelProps {
|
||||
matches: SearchResultFile[];
|
||||
|
|
@ -15,16 +16,26 @@ interface FilePanelProps {
|
|||
repoMetadata: Record<string, Repository>;
|
||||
}
|
||||
|
||||
const LANGUAGES_QUERY_PARAM = "langs";
|
||||
const REPOS_QUERY_PARAM = "repos";
|
||||
|
||||
export const FilterPanel = ({
|
||||
matches,
|
||||
onFilterChanged,
|
||||
repoMetadata,
|
||||
}: FilePanelProps) => {
|
||||
const [repos, setRepos] = useState<Record<string, Entry>>({});
|
||||
const [languages, setLanguages] = useState<Record<string, Entry>>({});
|
||||
const router = useRouter();
|
||||
const searchParams = useSearchParams();
|
||||
|
||||
useEffect(() => {
|
||||
const _repos = aggregateMatches(
|
||||
// Helper to parse query params into sets
|
||||
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",
|
||||
matches,
|
||||
(key) => {
|
||||
|
|
@ -44,17 +55,16 @@ export const FilterPanel = ({
|
|||
key,
|
||||
displayName: info?.displayName ?? key,
|
||||
count: 0,
|
||||
isSelected: false,
|
||||
isSelected: selectedRepos.has(key),
|
||||
Icon,
|
||||
};
|
||||
}
|
||||
);
|
||||
)
|
||||
}, [searchParams]);
|
||||
|
||||
setRepos(_repos);
|
||||
}, [matches, repoMetadata, setRepos]);
|
||||
|
||||
useEffect(() => {
|
||||
const _languages = aggregateMatches(
|
||||
const languages = useMemo(() => {
|
||||
const selectedLanguages = getSelectedFromQuery(LANGUAGES_QUERY_PARAM);
|
||||
return aggregateMatches(
|
||||
"Language",
|
||||
matches,
|
||||
(key) => {
|
||||
|
|
@ -66,40 +76,18 @@ export const FilterPanel = ({
|
|||
key,
|
||||
displayName: key,
|
||||
count: 0,
|
||||
isSelected: false,
|
||||
isSelected: selectedLanguages.has(key),
|
||||
Icon: Icon,
|
||||
} satisfies Entry;
|
||||
}
|
||||
)
|
||||
|
||||
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,
|
||||
},
|
||||
}));
|
||||
}, []);
|
||||
);
|
||||
}, [searchParams]);
|
||||
|
||||
// Calls `onFilterChanged` with the filtered list of matches
|
||||
// whenever the filter state changes.
|
||||
useEffect(() => {
|
||||
const selectedRepos = new Set(
|
||||
Object.entries(repos)
|
||||
.filter(([_, { isSelected }]) => isSelected)
|
||||
.map(([key]) => key)
|
||||
);
|
||||
|
||||
const selectedLanguages = new Set(
|
||||
Object.entries(languages)
|
||||
.filter(([_, { isSelected }]) => isSelected)
|
||||
.map(([key]) => key)
|
||||
);
|
||||
const selectedRepos = new Set(Object.keys(repos).filter((key) => repos[key].isSelected));
|
||||
const selectedLanguages = new Set(Object.keys(languages).filter((key) => languages[key].isSelected));
|
||||
|
||||
const filteredMatches = matches.filter((match) =>
|
||||
(
|
||||
|
|
@ -107,26 +95,57 @@ export const FilterPanel = ({
|
|||
(selectedLanguages.size === 0 ? true : selectedLanguages.has(match.Language))
|
||||
)
|
||||
);
|
||||
|
||||
onFilterChanged(filteredMatches);
|
||||
}, [matches, repos, languages, onFilterChanged]);
|
||||
|
||||
const numRepos = Object.keys(repos).length > 100 ? '100+' : Object.keys(repos).length;
|
||||
const numLanguages = Object.keys(languages).length > 100 ? '100+' : Object.keys(languages).length;
|
||||
}, [matches, repos, languages, onFilterChanged, searchParams, router]);
|
||||
|
||||
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 (
|
||||
<div className="p-3 flex flex-col gap-3 h-full">
|
||||
<Filter
|
||||
title="Filter By Repository"
|
||||
searchPlaceholder={`Filter ${numRepos} repositories`}
|
||||
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%]"
|
||||
/>
|
||||
<Filter
|
||||
title="Filter By Language"
|
||||
searchPlaceholder={`Filter ${numLanguages} 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"
|
||||
/>
|
||||
</div>
|
||||
|
|
|
|||
|
|
@ -47,7 +47,7 @@ const SearchPageInternal = () => {
|
|||
const domain = useDomain();
|
||||
const { toast } = useToast();
|
||||
|
||||
const { data: searchResponse, isLoading, error } = useQuery({
|
||||
const { data: searchResponse, isLoading: isSearchLoading, error } = useQuery({
|
||||
queryKey: ["search", searchQuery, maxMatchDisplayCount],
|
||||
queryFn: () => measure(() => unwrapServiceError(search({
|
||||
query: searchQuery,
|
||||
|
|
@ -91,7 +91,7 @@ const SearchPageInternal = () => {
|
|||
// repository metadata (like host type, repo name, etc.)
|
||||
// Convert this into a map of repo name to repo metadata
|
||||
// for easy lookup.
|
||||
const { data: repoMetadata } = useQuery({
|
||||
const { data: repoMetadata, isLoading: isRepoMetadataLoading } = useQuery({
|
||||
queryKey: ["repos"],
|
||||
queryFn: () => getRepos(domain),
|
||||
select: (data): Record<string, Repository> =>
|
||||
|
|
@ -194,7 +194,7 @@ const SearchPageInternal = () => {
|
|||
<Separator />
|
||||
</div>
|
||||
|
||||
{isLoading ? (
|
||||
{(isSearchLoading || isRepoMetadataLoading) ? (
|
||||
<div className="flex flex-col items-center justify-center h-full gap-2">
|
||||
<SymbolIcon className="h-6 w-6 animate-spin" />
|
||||
<p className="font-semibold text-center">Searching...</p>
|
||||
|
|
|
|||
|
|
@ -17,11 +17,11 @@ import { useMemo } from "react";
|
|||
*/
|
||||
export const useNonEmptyQueryParam = (param: string) => {
|
||||
const searchParams = useSearchParams();
|
||||
const inviteId = useMemo(() => {
|
||||
const paramValue = useMemo(() => {
|
||||
return getSearchParam(param, searchParams);
|
||||
}, [param, searchParams]);
|
||||
|
||||
return inviteId;
|
||||
return paramValue;
|
||||
};
|
||||
|
||||
/**
|
||||
|
|
|
|||
Loading…
Reference in a new issue