Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
110 changes: 81 additions & 29 deletions src/app/onboarding/join/page.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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<string | null>(null)
const [requestedProjectIds, setRequestedProjectIds] = useState<Set<string>>(new Set())
const [requestingProjectId, setRequestingProjectId] = useState<string | null>(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()
Expand All @@ -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

Expand All @@ -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 = () => {
Expand Down Expand Up @@ -94,37 +101,82 @@ export default function JoinProjectPage() {
<CardHeader className="px-7">
<CardTitle className="text-2xl font-bold">Join an existing project</CardTitle>
<CardDescription>
Enter a project ID, or connect with GitHub to find projects you&apos;ve contributed to that are registered in the system.
Search for a project by name, or connect with GitHub to find projects you&apos;ve contributed to that are registered in the system.
</CardDescription>
</CardHeader>
<CardContent className="px-7 space-y-6">
<div className="space-y-2">
<Label htmlFor="project-id" className="text-[13px] font-semibold">Project ID or Slug</Label>
<div className="flex gap-2">
<Label htmlFor="project-search" className="text-[13px] font-semibold">Search projects</Label>
<div className="relative">
<Search className="absolute left-3 top-1/2 -translate-y-1/2 w-4 h-4 text-muted-foreground pointer-events-none" />
<Input
id="project-id"
type="text"
placeholder="Enter project ID or slug"
value={projectId}
onChange={(e) => 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"
/>
<Button
onClick={handleGoToProject}
disabled={!projectId.trim()}
variant="lavender"
>
<ExternalLink className="w-4 h-4" />
</Button>
</div>
<p className="text-xs text-muted-foreground">
Or ask a project admin for the project landing page URL
</p>
<div id="project-search-results" aria-live="polite">
{!canSearch ? (
<p className="text-xs text-muted-foreground">
{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"}
</p>
) : searchFailed ? (
<p className="text-xs text-destructive">Search failed. Please try again.</p>
) : searching && searchResults.length === 0 ? (
<div className="flex items-center gap-2 text-xs text-muted-foreground py-2">
<Loader2 className="w-3.5 h-3.5 animate-spin" />
Searching…
</div>
) : searchResults.length === 0 ? (
<p className="text-xs text-muted-foreground py-2">
No projects found matching &quot;{searchTerm}&quot;
</p>
) : (
<ul className="space-y-1 max-h-60 overflow-y-auto rounded-lg border border-border bg-card p-1">
{searchResults.map((project) => (
<li key={project.project_id}>
<button
type="button"
onClick={() => handleGoToProject(project)}
className="w-full flex items-center gap-3 p-2 rounded-md text-left hover:bg-muted focus-visible:bg-muted outline-none transition-colors"
>
{project.logo_url ? (
// eslint-disable-next-line @next/next/no-img-element
<img
src={project.logo_url}
alt=""
className="w-8 h-8 rounded-md object-cover flex-shrink-0"
/>
) : (
<div className="w-8 h-8 rounded-md bg-muted flex items-center justify-center text-sm font-semibold text-muted-foreground flex-shrink-0">
{project.name.charAt(0).toUpperCase()}
</div>
)}
<div className="min-w-0 flex-1">
<div className="font-medium truncate">{project.name}</div>
{project.slug && (
<div className="text-xs text-muted-foreground truncate">{project.slug}</div>
)}
</div>
<ChevronRight className="w-4 h-4 text-muted-foreground flex-shrink-0" />
</button>
</li>
))}
</ul>
)}
</div>
</div>

<div className="space-y-4">
Expand All @@ -148,7 +200,7 @@ export default function JoinProjectPage() {
</div>
) : contributedProjects.length === 0 ? (
<p className="text-sm text-muted-foreground py-4">
No projects found that match repositories you&apos;ve contributed to. You can still join by entering a project ID above.
No projects found that match repositories you&apos;ve contributed to. You can still find a project using the search above.
</p>
) : (
<div className="space-y-2 max-h-60 overflow-y-auto">
Expand Down
102 changes: 102 additions & 0 deletions src/hooks/__tests__/useSearchProjects.test.ts
Original file line number Diff line number Diff line change
@@ -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<typeof supabase.from>);
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));
});
});
53 changes: 53 additions & 0 deletions src/hooks/useProject.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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],
Expand Down