From caf54e161439b5800f9a8ccc183f02cf2deacddd Mon Sep 17 00:00:00 2001 From: Stian Date: Tue, 25 Aug 2026 13:47:23 +0200 Subject: [PATCH] Psearch --- src/app/onboarding/join/page.tsx | 110 +++++++++++++----- src/hooks/__tests__/useSearchProjects.test.ts | 102 ++++++++++++++++ src/hooks/useProject.ts | 53 +++++++++ 3 files changed, 236 insertions(+), 29 deletions(-) create mode 100644 src/hooks/__tests__/useSearchProjects.test.ts diff --git a/src/app/onboarding/join/page.tsx b/src/app/onboarding/join/page.tsx index a4cc632..d14baf1 100644 --- a/src/app/onboarding/join/page.tsx +++ b/src/app/onboarding/join/page.tsx @@ -6,22 +6,26 @@ import { Button } from "@/components/ui/button" import { Input } from "@/components/ui/input" import { Label } from "@/components/ui/label" import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card" -import { ExternalLink, Github, Loader2, Users, CheckCircle, ArrowLeft } from "lucide-react" +import { Search, Github, Loader2, Users, CheckCircle, ArrowLeft, ChevronRight } from "lucide-react" import { supabase } from "@/lib/supabase/client" import { signInWithGitHub } from "@/lib/supabase/auth" -import { useListContributedProjects } from "@/hooks/useProject" +import { useListContributedProjects, useSearchProjects, PROJECT_SEARCH_MIN_CHARS } from "@/hooks/useProject" import { useCreatePendingRequest } from "@/hooks/usePendingRequests" import { useOnboardingStatus, useCompleteOnboarding } from "@/hooks/useOnboardingStatus" import { toast } from "sonner" export default function JoinProjectPage() { const router = useRouter() - const [projectId, setProjectId] = useState("") + const [searchInput, setSearchInput] = useState("") + const [debouncedSearch, setDebouncedSearch] = useState("") const [githubToken, setGithubToken] = useState(null) const [requestedProjectIds, setRequestedProjectIds] = useState>(new Set()) const [requestingProjectId, setRequestingProjectId] = useState(null) const { data: contributedProjects = [], isLoading: loadingContributed } = useListContributedProjects(githubToken) + const searchTerm = debouncedSearch.trim() + const canSearch = searchTerm.length >= PROJECT_SEARCH_MIN_CHARS + const { data: searchResults = [], isFetching: searching, isError: searchFailed } = useSearchProjects(debouncedSearch) const createRequest = useCreatePendingRequest() const { data: onboardingStatus } = useOnboardingStatus() const completeOnboarding = useCompleteOnboarding() @@ -37,6 +41,11 @@ export default function JoinProjectPage() { checkGithubSession() }, []) + useEffect(() => { + const handle = setTimeout(() => setDebouncedSearch(searchInput), 250) + return () => clearTimeout(handle) + }, [searchInput]) + useEffect(() => { if (!githubToken || contributedProjects.length === 0) return @@ -59,10 +68,8 @@ export default function JoinProjectPage() { checkExistingRequests() }, [githubToken, contributedProjects]) - const handleGoToProject = () => { - if (projectId.trim()) { - router.push(`/projects/${projectId.trim()}`) - } + const handleGoToProject = (project: { project_id: string; slug: string | null }) => { + router.push(`/projects/${project.slug || project.project_id}`) } const handleConnectGitHub = () => { @@ -94,37 +101,82 @@ export default function JoinProjectPage() { Join an existing project - Enter a project ID, or connect with GitHub to find projects you've contributed to that are registered in the system. + Search for a project by name, or connect with GitHub to find projects you've contributed to that are registered in the system.
- -
+ +
+ setProjectId(e.target.value)} + id="project-search" + type="search" + autoComplete="off" + placeholder="Search by project name" + value={searchInput} + onChange={(e) => setSearchInput(e.target.value)} onKeyDown={(e) => { - if (e.key === "Enter" && projectId.trim()) { - handleGoToProject() + if (e.key === "Enter" && canSearch && searchResults.length === 1) { + handleGoToProject(searchResults[0]) } }} - className="flex-1" + className="pl-9" + aria-controls="project-search-results" /> -
-

- Or ask a project admin for the project landing page URL -

+
+ {!canSearch ? ( +

+ {searchInput.trim().length > 0 + ? `Type at least ${PROJECT_SEARCH_MIN_CHARS} characters to search` + : "Or ask a project admin for the project landing page URL"} +

+ ) : searchFailed ? ( +

Search failed. Please try again.

+ ) : searching && searchResults.length === 0 ? ( +
+ + Searching… +
+ ) : searchResults.length === 0 ? ( +

+ No projects found matching "{searchTerm}" +

+ ) : ( +
    + {searchResults.map((project) => ( +
  • + +
  • + ))} +
+ )} +
@@ -148,7 +200,7 @@ export default function JoinProjectPage() {
) : contributedProjects.length === 0 ? (

- No projects found that match repositories you've contributed to. You can still join by entering a project ID above. + No projects found that match repositories you've contributed to. You can still find a project using the search above.

) : (
diff --git a/src/hooks/__tests__/useSearchProjects.test.ts b/src/hooks/__tests__/useSearchProjects.test.ts new file mode 100644 index 0000000..41fbee1 --- /dev/null +++ b/src/hooks/__tests__/useSearchProjects.test.ts @@ -0,0 +1,102 @@ +import { describe, it, expect, vi, beforeEach } from "vitest"; +import { renderHook, waitFor } from "@testing-library/react"; +import { QueryClient, QueryClientProvider } from "@tanstack/react-query"; +import { createElement } from "react"; + +vi.mock("@/lib/supabase/client", () => ({ + supabase: { + from: vi.fn(), + }, +})); + +import { supabase } from "@/lib/supabase/client"; +import { + useSearchProjects, + escapeProjectSearchTerm, + PROJECT_SEARCH_MIN_CHARS, +} from "../useProject"; + +function makeWrapper() { + const queryClient = new QueryClient({ + defaultOptions: { queries: { retry: false } }, + }); + function Wrapper({ children }: { children: React.ReactNode }) { + return createElement(QueryClientProvider, { client: queryClient }, children); + } + return Wrapper; +} + +function mockChain(resolveWith: { data: unknown; error: unknown }) { + const chain = { + select: vi.fn().mockReturnThis(), + is: vi.fn().mockReturnThis(), + eq: vi.fn().mockReturnThis(), + or: vi.fn().mockReturnThis(), + order: vi.fn().mockReturnThis(), + limit: vi.fn().mockResolvedValue(resolveWith), + }; + vi.mocked(supabase.from).mockReturnValue(chain as unknown as ReturnType); + return chain; +} + +describe("escapeProjectSearchTerm", () => { + it("escapes LIKE wildcards and strips filter delimiters", () => { + expect(escapeProjectSearchTerm("50%_off")).toBe("50\\%\\_off"); + expect(escapeProjectSearchTerm("a,b(c)")).toBe("a b c"); + expect(escapeProjectSearchTerm(" padded ")).toBe("padded"); + }); +}); + +describe("useSearchProjects", () => { + beforeEach(() => vi.clearAllMocks()); + + it("does not query when the term is shorter than the minimum", async () => { + mockChain({ data: [], error: null }); + const { result } = renderHook( + () => useSearchProjects("ab"), + { wrapper: makeWrapper() }, + ); + expect(PROJECT_SEARCH_MIN_CHARS).toBe(3); + expect(result.current.fetchStatus).toBe("idle"); + expect(supabase.from).not.toHaveBeenCalled(); + }); + + it("ignores surrounding whitespace when checking the minimum length", () => { + mockChain({ data: [], error: null }); + const { result } = renderHook( + () => useSearchProjects(" ab "), + { wrapper: makeWrapper() }, + ); + expect(result.current.fetchStatus).toBe("idle"); + expect(supabase.from).not.toHaveBeenCalled(); + }); + + it("searches name and slug case-insensitively once the minimum is met", async () => { + const rows = [ + { project_id: "p1", name: "Acme Support", slug: "acme", logo_url: null }, + ]; + const chain = mockChain({ data: rows, error: null }); + + const { result } = renderHook( + () => useSearchProjects("acm"), + { wrapper: makeWrapper() }, + ); + await waitFor(() => expect(result.current.isSuccess).toBe(true)); + + expect(supabase.from).toHaveBeenCalledWith("projects"); + expect(chain.is).toHaveBeenCalledWith("deleted_at", null); + expect(chain.eq).toHaveBeenCalledWith("sandbox", false); + expect(chain.or).toHaveBeenCalledWith("name.ilike.%acm%,slug.ilike.%acm%"); + expect(chain.limit).toHaveBeenCalledWith(10); + expect(result.current.data).toEqual(rows); + }); + + it("surfaces query errors", async () => { + mockChain({ data: null, error: new Error("boom") }); + const { result } = renderHook( + () => useSearchProjects("acme"), + { wrapper: makeWrapper() }, + ); + await waitFor(() => expect(result.current.isError).toBe(true)); + }); +}); diff --git a/src/hooks/useProject.ts b/src/hooks/useProject.ts index cd40087..86dd44a 100644 --- a/src/hooks/useProject.ts +++ b/src/hooks/useProject.ts @@ -118,6 +118,59 @@ export function useProjectBySlug(slug: string) { }); } +export const PROJECT_SEARCH_MIN_CHARS = 3; + +export type ProjectSearchResult = Pick< + Project, + "project_id" | "name" | "slug" | "logo_url" +>; + +/** + * Escape a user-supplied string for use inside a PostgREST `ilike` pattern + * within an `.or()` filter. Strips the characters that delimit `.or()` + * clauses (`,` `(` `)`) and escapes LIKE wildcards so they match literally. + */ +export function escapeProjectSearchTerm(term: string): string { + return term + .replace(/[,()]/g, " ") + .replace(/[\\%_]/g, (c) => `\\${c}`) + .trim(); +} + +/** + * Search projects by name or slug (case-insensitive substring). Disabled until + * the query has at least PROJECT_SEARCH_MIN_CHARS characters. Excludes deleted + * and sandbox projects since those cannot be joined. + */ +export function useSearchProjects(query: string, options?: { limit?: number }) { + const term = query.trim(); + const limit = options?.limit ?? 10; + + return useQuery({ + queryKey: ["project-search", term, limit], + queryFn: async () => { + const pattern = `%${escapeProjectSearchTerm(term)}%`; + const { data, error } = await supabase + .from("projects") + .select("project_id, name, slug, logo_url") + .is("deleted_at", null) + .eq("sandbox", false) + .or(`name.ilike.${pattern},slug.ilike.${pattern}`) + .order("name", { ascending: true }) + .limit(limit); + + if (error) throw error; + return (data ?? []) as ProjectSearchResult[]; + }, + enabled: term.length >= PROJECT_SEARCH_MIN_CHARS, + retry: false, + staleTime: 60000, + placeholderData: (prev) => prev, + refetchOnReconnect: false, + refetchOnWindowFocus: false, + }); +} + export function useProjectResources(projectId: string) { return useQuery({ queryKey: ["project-resources", projectId],