From 3b9461b0e4b12f8b07f2d8d473b1a05c609f16f9 Mon Sep 17 00:00:00 2001 From: bkellam Date: Sat, 26 Apr 2025 16:03:41 -0700 Subject: [PATCH 1/3] update query params when filter panel changes --- .../search/components/filterPanel/index.tsx | 85 ++++++++++++------- packages/web/src/app/[domain]/search/page.tsx | 6 +- .../web/src/hooks/useNonEmptyQueryParam.ts | 4 +- 3 files changed, 61 insertions(+), 34 deletions(-) diff --git a/packages/web/src/app/[domain]/search/components/filterPanel/index.tsx b/packages/web/src/app/[domain]/search/components/filterPanel/index.tsx index c3ce800b..2dfd1133 100644 --- a/packages/web/src/app/[domain]/search/components/filterPanel/index.tsx +++ b/packages/web/src/app/[domain]/search/components/filterPanel/index.tsx @@ -8,6 +8,8 @@ import { Filter } from "./filter"; import Image from "next/image"; import { LaptopIcon } from "@radix-ui/react-icons"; import { FileIcon } from "@/components/ui/fileIcon"; +import { useSearchParams } from "next/navigation"; +import { useRouter } from "next/navigation"; interface FilePanelProps { matches: SearchResultFile[]; @@ -15,16 +17,26 @@ interface FilePanelProps { repoMetadata: Record; } +const LANGUAGES_QUERY_PARAM = "langs"; +const REPOS_QUERY_PARAM = "repos"; + export const FilterPanel = ({ matches, onFilterChanged, repoMetadata, }: FilePanelProps) => { - const [repos, setRepos] = useState>({}); - const [languages, setLanguages] = useState>({}); - - useEffect(() => { - const _repos = aggregateMatches( + const router = useRouter(); + const searchParams = useSearchParams(); + + // 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, setRepos] = useState>(() => { + const selectedRepos = getSelectedFromQuery(REPOS_QUERY_PARAM); + return aggregateMatches( "Repository", matches, (key) => { @@ -44,17 +56,16 @@ export const FilterPanel = ({ key, displayName: info?.displayName ?? key, count: 0, - isSelected: false, + isSelected: selectedRepos.has(key), Icon, }; } ); + }); - setRepos(_repos); - }, [matches, repoMetadata, setRepos]); - - useEffect(() => { - const _languages = aggregateMatches( + const [languages, setLanguages] = useState>(() => { + const selectedLanguages = getSelectedFromQuery(LANGUAGES_QUERY_PARAM); + return aggregateMatches( "Language", matches, (key) => { @@ -66,14 +77,12 @@ 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, @@ -88,18 +97,11 @@ export const FilterPanel = ({ })); }, []); + // 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,9 +109,34 @@ export const FilterPanel = ({ (selectedLanguages.size === 0 ? true : selectedLanguages.has(match.Language)) ) ); - onFilterChanged(filteredMatches); - }, [matches, repos, languages, onFilterChanged]); + + }, [matches, repos, languages, onFilterChanged, searchParams, router]); + + // Updates the query params when the filter state changes + useEffect(() => { + const selectedRepos = Object.keys(repos).filter((key) => repos[key].isSelected); + const selectedLanguages = Object.keys(languages).filter((key) => languages[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 (selectedLanguages.length > 0) { + newParams.set(LANGUAGES_QUERY_PARAM, selectedLanguages.join(',')); + } else { + newParams.delete(LANGUAGES_QUERY_PARAM); + } + + // Only push if params actually changed + if (newParams.toString() !== searchParams.toString()) { + router.replace(`?${newParams.toString()}`, { scroll: false }); + } + }, [repos, languages, searchParams, router]); const numRepos = Object.keys(repos).length > 100 ? '100+' : Object.keys(repos).length; const numLanguages = Object.keys(languages).length > 100 ? '100+' : Object.keys(languages).length; diff --git a/packages/web/src/app/[domain]/search/page.tsx b/packages/web/src/app/[domain]/search/page.tsx index 134b6b02..aab869fa 100644 --- a/packages/web/src/app/[domain]/search/page.tsx +++ b/packages/web/src/app/[domain]/search/page.tsx @@ -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 => @@ -194,7 +194,7 @@ const SearchPageInternal = () => { - {isLoading ? ( + {(isSearchLoading || isRepoMetadataLoading) ? (

Searching...

diff --git a/packages/web/src/hooks/useNonEmptyQueryParam.ts b/packages/web/src/hooks/useNonEmptyQueryParam.ts index d3a20417..0f21ebf6 100644 --- a/packages/web/src/hooks/useNonEmptyQueryParam.ts +++ b/packages/web/src/hooks/useNonEmptyQueryParam.ts @@ -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; }; /** From 44d5e56028045e88bedcf60f34e74b0a63fb6bae Mon Sep 17 00:00:00 2001 From: bkellam Date: Sat, 26 Apr 2025 16:08:04 -0700 Subject: [PATCH 2/3] Changelog --- CHANGELOG.md | 3 +++ 1 file changed, 3 insertions(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index 54979a81..94e4e529 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -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 From 241268357cb9a233d38373f64c856e9d0363218b Mon Sep 17 00:00:00 2001 From: bkellam Date: Mon, 28 Apr 2025 10:35:39 -0700 Subject: [PATCH 3/3] Feedback: better sync URL params and component state --- .../search/components/filterPanel/index.tsx | 98 +++++++++---------- 1 file changed, 45 insertions(+), 53 deletions(-) diff --git a/packages/web/src/app/[domain]/search/components/filterPanel/index.tsx b/packages/web/src/app/[domain]/search/components/filterPanel/index.tsx index 2dfd1133..ffb2b496 100644 --- a/packages/web/src/app/[domain]/search/components/filterPanel/index.tsx +++ b/packages/web/src/app/[domain]/search/components/filterPanel/index.tsx @@ -1,15 +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"; -import { useSearchParams } from "next/navigation"; -import { useRouter } from "next/navigation"; interface FilePanelProps { matches: SearchResultFile[]; @@ -34,7 +33,7 @@ export const FilterPanel = ({ return value ? new Set(value.split(',')) : new Set(); }; - const [repos, setRepos] = useState>(() => { + const repos = useMemo(() => { const selectedRepos = getSelectedFromQuery(REPOS_QUERY_PARAM); return aggregateMatches( "Repository", @@ -60,10 +59,10 @@ export const FilterPanel = ({ Icon, }; } - ); - }); + ) + }, [searchParams]); - const [languages, setLanguages] = useState>(() => { + const languages = useMemo(() => { const selectedLanguages = getSelectedFromQuery(LANGUAGES_QUERY_PARAM); return aggregateMatches( "Language", @@ -82,20 +81,7 @@ export const FilterPanel = ({ } satisfies Entry; } ); - }); - - const onEntryClicked = useCallback(( - key: string, - setter: (value: SetStateAction>) => 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. @@ -113,47 +99,53 @@ export const FilterPanel = ({ }, [matches, repos, languages, onFilterChanged, searchParams, router]); - // Updates the query params when the filter state changes - useEffect(() => { - const selectedRepos = Object.keys(repos).filter((key) => repos[key].isSelected); - const selectedLanguages = Object.keys(languages).filter((key) => languages[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 (selectedLanguages.length > 0) { - newParams.set(LANGUAGES_QUERY_PARAM, selectedLanguages.join(',')); - } else { - newParams.delete(LANGUAGES_QUERY_PARAM); - } - - // Only push if params actually changed - if (newParams.toString() !== searchParams.toString()) { - router.replace(`?${newParams.toString()}`, { scroll: false }); - } - }, [repos, languages, searchParams, router]); - - const numRepos = Object.keys(repos).length > 100 ? '100+' : Object.keys(repos).length; - 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 (
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%]" /> 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" />