fix(ui): drive project detail selection from the ?project= url param

Opening a project kept selectedProjectId in useState, so the URL never changed; the detail view could not be linked or reloaded and browser Back skipped past the Projects page entirely.

Selection now lives in the ?project= query param via nuqs with history: push, matching how Teams, Organizations and Virtual Keys already work.
This commit is contained in:
ryan-crabbe-berri 2026-08-05 14:21:13 -07:00
parent c3a8962c00
commit dc178acf5b
2 changed files with 55 additions and 4 deletions

View file

@ -1,5 +1,6 @@
import { describe, it, expect, vi, beforeEach } from "vitest";
import userEvent from "@testing-library/user-event";
import type { UrlUpdateEvent } from "nuqs/adapters/testing";
import { renderWithProviders, screen, waitFor, within } from "../../../../../tests/test-utils";
import { ProjectsPage } from "./ProjectsPage";
import { ProjectResponse } from "@/app/(dashboard)/hooks/projects/useProjects";
@ -20,7 +21,12 @@ vi.mock("./ProjectModals/CreateProjectModal", () => ({
}));
vi.mock("./ProjectDetailsPage", () => ({
ProjectDetail: ({ projectId }: { projectId: string }) => <div data-testid="project-detail">{projectId}</div>,
ProjectDetail: ({ projectId, onBack }: { projectId: string; onBack: () => void }) => (
<div data-testid="project-detail">
{projectId}
<button onClick={onBack}>Back to projects</button>
</div>
),
}));
const mockProjects: ProjectResponse[] = [
@ -200,6 +206,47 @@ describe("ProjectsPage", () => {
});
});
it("should open the detail view directly from a ?project= deep link", () => {
mockUseProjects.mockReturnValue({ data: mockProjects, isLoading: false });
renderWithProviders(<ProjectsPage />, { searchParams: "?project=proj-2" });
expect(screen.getByTestId("project-detail")).toHaveTextContent("proj-2");
expect(screen.queryByRole("heading", { name: /projects/i })).not.toBeInTheDocument();
});
it("should push ?project= as a new history entry when a project is opened", async () => {
const user = userEvent.setup();
const onUrlUpdate = vi.fn<(event: UrlUpdateEvent) => void>();
mockUseProjects.mockReturnValue({ data: mockProjects, isLoading: false });
renderWithProviders(<ProjectsPage />, { onUrlUpdate });
await user.click(screen.getByText("proj-1"));
await waitFor(() => {
expect(onUrlUpdate).toHaveBeenLastCalledWith(
expect.objectContaining({
queryString: "?project=proj-1",
options: expect.objectContaining({ history: "push" }),
}),
);
});
});
it("should clear ?project= and return to the list when the detail view is closed", async () => {
const user = userEvent.setup();
const onUrlUpdate = vi.fn<(event: UrlUpdateEvent) => void>();
mockUseProjects.mockReturnValue({ data: mockProjects, isLoading: false });
renderWithProviders(<ProjectsPage />, { searchParams: "?project=proj-1", onUrlUpdate });
await user.click(screen.getByRole("button", { name: /back to projects/i }));
await waitFor(() => {
expect(onUrlUpdate).toHaveBeenLastCalledWith(expect.objectContaining({ queryString: "" }));
});
expect(screen.queryByTestId("project-detail")).not.toBeInTheDocument();
expect(screen.getByText("Alpha Project")).toBeInTheDocument();
});
it("should resolve team alias from the teams list in the Team column", () => {
mockUseTeams.mockReturnValue({
data: [{ team_id: "team-1", team_alias: "Engineering", models: [] }],

View file

@ -3,6 +3,7 @@ import { useTeams } from "@/app/(dashboard)/hooks/teams/useTeams";
import { PlusOutlined } from "@ant-design/icons";
import { Button, Flex, Input, Layout, Space, theme, Typography } from "antd";
import { SearchIcon } from "lucide-react";
import { parseAsString, useQueryState } from "nuqs";
import { useMemo, useState } from "react";
import { CreateProjectModal } from "./ProjectModals/CreateProjectModal";
import { ProjectDetail } from "./ProjectDetailsPage";
@ -16,7 +17,10 @@ export function ProjectsPage() {
const { data: projects, isLoading } = useProjects();
const { data: teams, isLoading: isTeamsLoading } = useTeams();
const [selectedProjectId, setSelectedProjectId] = useState<string | null>(null);
const [selectedProjectId, setSelectedProjectId] = useQueryState(
"project",
parseAsString.withOptions({ history: "push" }),
);
const [isCreateModalVisible, setIsCreateModalVisible] = useState(false);
const [searchText, setSearchText] = useState("");
@ -44,7 +48,7 @@ export function ProjectsPage() {
}, [projects, searchText, teamAliasMap]);
if (selectedProjectId) {
return <ProjectDetail projectId={selectedProjectId} onBack={() => setSelectedProjectId(null)} />;
return <ProjectDetail projectId={selectedProjectId} onBack={() => void setSelectedProjectId(null)} />;
}
return (
@ -76,7 +80,7 @@ export function ProjectsPage() {
projects={filteredProjects}
isLoading={isLoading}
isFiltered={searchText.trim().length > 0}
onProjectClick={setSelectedProjectId}
onProjectClick={(id) => void setSelectedProjectId(id)}
teamAliasMap={teamAliasMap}
isTeamsLoading={isTeamsLoading}
/>