feat(ui): keep projects search and project key table state in the URL

The projects list search lives in ?project_search= and its pagination now goes through useUrlTableState, keeping the page and page_size keys. The key table inside a project reads keys_search, keys_page and keys_page_size, resets to its first page on a new search, and no longer snaps a deep-linked page while the key fetch is failing. Closing a project drops its keys_ params so they do not leak into the next project
This commit is contained in:
ryan-crabbe-berri 2026-09-16 09:58:22 -07:00
parent 417cc5c4fb
commit df2dd9b7f2
8 changed files with 223 additions and 35 deletions

View file

@ -1,5 +1,7 @@
import { describe, it, expect, vi } from "vitest";
import { renderWithProviders, screen } from "../../../../../tests/test-utils";
import { describe, it, expect, vi, beforeEach, type Mock } from "vitest";
import userEvent from "@testing-library/user-event";
import type { OnUrlUpdateFunction } from "nuqs/adapters/testing";
import { fireEvent, renderWithProviders, screen, waitFor } from "../../../../../tests/test-utils";
import { ProjectKeysSection } from "./ProjectKeysSection";
const mockUseKeys = vi.fn();
@ -70,3 +72,108 @@ describe("ProjectKeysSection", () => {
);
});
});
describe("ProjectKeysSection URL state (keys_ prefix)", () => {
const fortyTwoKeys = {
data: { keys: [], total_count: 42, current_page: 1, total_pages: 9 },
isLoading: false,
isError: false,
};
const lastSearchParams = (onUrlUpdate: Mock<OnUrlUpdateFunction>) => onUrlUpdate.mock.calls.at(-1)?.[0].searchParams;
beforeEach(() => {
mockUseKeys.mockReset();
});
it("should fetch the page, page size and key name filter named by the keys_ params", () => {
mockUseKeys.mockReturnValue(fortyTwoKeys);
renderWithProviders(<ProjectKeysSection projectId="proj-1" />, {
searchParams: "?page=4&keys_page=2&keys_page_size=10&keys_search=prod",
});
expect(mockUseKeys).toHaveBeenLastCalledWith(
2,
10,
expect.objectContaining({ projectID: "proj-1", selectedKeyAlias: "prod" }),
);
expect(screen.getByPlaceholderText("Filter by key name...")).toHaveValue("prod");
expect(screen.getByTestId("pagination-page")).toHaveTextContent("Page 2 of 5");
});
it("should write the key name filter to ?keys_search= and return the keys to their first page", async () => {
mockUseKeys.mockReturnValue(fortyTwoKeys);
const onUrlUpdate = vi.fn<OnUrlUpdateFunction>();
renderWithProviders(<ProjectKeysSection projectId="proj-1" />, {
searchParams: "?page=4&keys_page=3",
onUrlUpdate,
});
fireEvent.change(screen.getByPlaceholderText("Filter by key name..."), { target: { value: "prod" } });
await waitFor(() => expect(lastSearchParams(onUrlUpdate)?.get("keys_search")).toBe("prod"));
expect(lastSearchParams(onUrlUpdate)?.has("keys_page")).toBe(false);
expect(lastSearchParams(onUrlUpdate)?.get("page")).toBe("4");
expect(mockUseKeys).toHaveBeenLastCalledWith(1, 5, expect.objectContaining({ selectedKeyAlias: "prod" }));
});
it("should remove ?keys_search= when the key filter is cleared", async () => {
const user = userEvent.setup();
mockUseKeys.mockReturnValue(fortyTwoKeys);
const onUrlUpdate = vi.fn<OnUrlUpdateFunction>();
renderWithProviders(<ProjectKeysSection projectId="proj-1" />, { searchParams: "?keys_search=prod", onUrlUpdate });
await user.click(screen.getByRole("button", { name: /clear key filter/i }));
await waitFor(() => expect(lastSearchParams(onUrlUpdate)?.has("keys_search")).toBe(false));
expect(mockUseKeys).toHaveBeenLastCalledWith(1, 5, expect.objectContaining({ selectedKeyAlias: null }));
});
it("should write key pages to ?keys_page= without touching the projects list's ?page=", async () => {
const user = userEvent.setup();
mockUseKeys.mockReturnValue(fortyTwoKeys);
const onUrlUpdate = vi.fn<OnUrlUpdateFunction>();
renderWithProviders(<ProjectKeysSection projectId="proj-1" />, { searchParams: "?page=4", onUrlUpdate });
await user.click(screen.getByTestId("pagination-next"));
await waitFor(() => expect(lastSearchParams(onUrlUpdate)?.get("keys_page")).toBe("2"));
expect(lastSearchParams(onUrlUpdate)?.get("page")).toBe("4");
expect(mockUseKeys).toHaveBeenLastCalledWith(2, 5, expect.anything());
});
it("should snap a ?keys_page= past the last page back to the last page once the keys load", async () => {
mockUseKeys.mockReturnValue({ data: undefined, isLoading: true, isError: false });
const onUrlUpdate = vi.fn<OnUrlUpdateFunction>();
const { rerender } = renderWithProviders(<ProjectKeysSection projectId="proj-1" />, {
searchParams: "?keys_page=9",
onUrlUpdate,
});
expect(mockUseKeys).toHaveBeenLastCalledWith(9, 5, expect.anything());
mockUseKeys.mockReturnValue({
data: { keys: [], total_count: 6, current_page: 9, total_pages: 2 },
isLoading: false,
isError: false,
});
rerender(<ProjectKeysSection projectId="proj-1" />);
await waitFor(() => expect(lastSearchParams(onUrlUpdate)?.get("keys_page")).toBe("2"));
expect(mockUseKeys).toHaveBeenLastCalledWith(2, 5, expect.anything());
});
it("should keep a deep-linked ?keys_page= when the key fetch fails", async () => {
mockUseKeys.mockReturnValue({ data: undefined, isLoading: true, isError: false });
const onUrlUpdate = vi.fn<OnUrlUpdateFunction>();
const { rerender } = renderWithProviders(<ProjectKeysSection projectId="proj-1" />, {
searchParams: "?keys_page=3",
onUrlUpdate,
});
mockUseKeys.mockReturnValue({ data: undefined, isLoading: false, isError: true });
rerender(<ProjectKeysSection projectId="proj-1" />);
await new Promise((resolve) => setTimeout(resolve, 100));
expect(onUrlUpdate).not.toHaveBeenCalled();
expect(mockUseKeys).toHaveBeenLastCalledWith(3, 5, expect.anything());
});
});

View file

@ -1,30 +1,27 @@
import { useKeys } from "@/app/(dashboard)/hooks/keys/useKeys";
import { PaginationState } from "@tanstack/react-table";
import { KeyIcon, SearchIcon, X } from "lucide-react";
import { useEffect, useState } from "react";
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
import { InputGroup, InputGroupAddon, InputGroupButton, InputGroupInput } from "@/components/ui/input-group";
import { ProjectKeysTable } from "./ProjectKeysTable";
import { useProjectKeysTableState } from "./useProjectsUrlState";
interface ProjectKeysSectionProps {
projectId: string;
}
const PAGE_SIZE = 5;
export function ProjectKeysSection({ projectId }: ProjectKeysSectionProps) {
const [pagination, setPagination] = useState<PaginationState>({ pageIndex: 0, pageSize: PAGE_SIZE });
const [keyAlias, setKeyAlias] = useState<string>("");
const {
search: keyAlias,
setSearch: setKeyAlias,
pagination,
onPaginationChange: setPagination,
} = useProjectKeysTableState();
const { data, isLoading } = useKeys(pagination.pageIndex + 1, pagination.pageSize, {
const { data, isLoading, isError } = useKeys(pagination.pageIndex + 1, pagination.pageSize, {
projectID: projectId,
selectedKeyAlias: keyAlias || null,
});
useEffect(() => {
setPagination((current) => ({ ...current, pageIndex: 0 }));
}, [keyAlias]);
const keys = data?.keys ?? [];
const totalCount = data?.total_count ?? 0;
@ -60,6 +57,7 @@ export function ProjectKeysSection({ projectId }: ProjectKeysSectionProps) {
keys={keys}
totalCount={totalCount}
isLoading={isLoading}
isError={isError}
pagination={pagination}
onPaginationChange={setPagination}
/>

View file

@ -8,16 +8,18 @@ import { KeyResponse } from "@/components/key_team_helpers/key_list";
import { DataTable } from "@/components/shared/DataTable";
import { getProjectKeysTableColumns } from "./ProjectKeysTableColumns";
import { PROJECT_KEYS_DEFAULT_PAGE_SIZE } from "./useProjectsUrlState";
interface ProjectKeysTableProps {
keys: KeyResponse[];
totalCount: number;
isLoading: boolean;
isError?: boolean;
pagination: PaginationState;
onPaginationChange: OnChangeFn<PaginationState>;
}
const PAGE_SIZE_OPTIONS = [5, 10, 25];
const PAGE_SIZE_OPTIONS = [PROJECT_KEYS_DEFAULT_PAGE_SIZE, 10, 25];
function EmptyState() {
return (
@ -35,6 +37,7 @@ export function ProjectKeysTable({
keys,
totalCount,
isLoading,
isError = false,
pagination,
onPaginationChange,
}: ProjectKeysTableProps) {
@ -51,6 +54,7 @@ export function ProjectKeysTable({
rowCount={totalCount}
pageSizeOptions={PAGE_SIZE_OPTIONS}
isLoading={isLoading}
isError={isError}
loadingMessage="Loading keys…"
noDataMessage={<EmptyState />}
size="compact"

View file

@ -190,22 +190,47 @@ describe("ProjectsPage", () => {
it("should reset to the first page when the search text changes", async () => {
const user = userEvent.setup();
const onUrlUpdate = vi.fn<(event: UrlUpdateEvent) => void>();
const manyProjects = Array.from({ length: 12 }, (_, i) => ({
...mockProjects[0],
project_id: `proj-${i + 1}`,
project_alias: `Project ${String(i + 1).padStart(2, "0")}`,
}));
mockUseProjects.mockReturnValue({ data: manyProjects, isLoading: false });
renderWithProviders(<ProjectsPage />);
renderWithProviders(<ProjectsPage />, { onUrlUpdate });
await user.click(screen.getByTestId("pagination-next"));
expect(screen.getByTestId("pagination-page")).toHaveTextContent("Page 2 of 2");
await waitFor(() => expect(onUrlUpdate.mock.calls.at(-1)?.[0].searchParams.get("page")).toBe("2"));
fireEvent.change(screen.getByPlaceholderText(/search projects/i), { target: { value: "Project 01" } });
await waitFor(() => {
expect(screen.getByText("Project 01")).toBeInTheDocument();
expect(screen.getByTestId("pagination-page")).toHaveTextContent("Page 1 of 1");
});
await waitFor(() => expect(onUrlUpdate.mock.calls.at(-1)?.[0].queryString).toBe("?project_search=Project+01"));
});
it("should restore the search box and filtered list from a ?project_search= deep link", () => {
mockUseProjects.mockReturnValue({ data: mockProjects, isLoading: false });
renderWithProviders(<ProjectsPage />, { searchParams: "?project_search=Beta" });
expect(screen.getByPlaceholderText(/search projects/i)).toHaveValue("Beta");
expect(screen.getByText("Beta Project")).toBeInTheDocument();
expect(screen.queryByText("Alpha Project")).not.toBeInTheDocument();
});
it("should remove ?project_search= when the search is cleared", async () => {
const user = userEvent.setup();
const onUrlUpdate = vi.fn<(event: UrlUpdateEvent) => void>();
mockUseProjects.mockReturnValue({ data: mockProjects, isLoading: false });
renderWithProviders(<ProjectsPage />, { searchParams: "?project_search=Beta", onUrlUpdate });
await user.click(screen.getByRole("button", { name: /clear search/i }));
await waitFor(() => expect(onUrlUpdate).toHaveBeenLastCalledWith(expect.objectContaining({ queryString: "" })));
expect(screen.getByPlaceholderText(/search projects/i)).toHaveValue("");
expect(screen.getByText("Alpha Project")).toBeInTheDocument();
});
it("should open the detail view directly from a ?project= deep link", () => {
@ -250,6 +275,23 @@ describe("ProjectsPage", () => {
expect(screen.getByText("Alpha Project")).toBeInTheDocument();
});
it("should drop the project's key table state but keep the list's search and page 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: "?page=2&project_search=Project&project=proj-1&keys_page=3&keys_page_size=10&keys_search=prod",
onUrlUpdate,
});
await user.click(screen.getByRole("button", { name: /back to projects/i }));
await waitFor(() => expect(onUrlUpdate).toHaveBeenCalledTimes(1));
const [update] = onUrlUpdate.mock.calls[0];
expect(update.queryString).toBe("?page=2&project_search=Project");
expect(update.options.history).toBe("replace");
});
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

@ -9,6 +9,7 @@ import { InputGroup, InputGroupAddon, InputGroupButton, InputGroupInput } from "
import { CreateProjectModal } from "./ProjectModals/CreateProjectModal";
import { ProjectDetail } from "./ProjectDetailsPage";
import { ProjectsTable } from "./ProjectsTable";
import { useClearProjectKeysTableState, useProjectsTableState } from "./useProjectsUrlState";
export function ProjectsPage() {
const { data: projects, isLoading } = useProjects();
@ -18,8 +19,9 @@ export function ProjectsPage() {
"project",
parseAsString.withOptions({ history: "push" }),
);
const clearProjectKeysTableState = useClearProjectKeysTableState();
const { search: searchText, setSearch: setSearchText } = useProjectsTableState();
const [isCreateModalVisible, setIsCreateModalVisible] = useState(false);
const [searchText, setSearchText] = useState("");
const teamAliasMap = useMemo(() => {
const map = new Map<string, string>();
@ -44,13 +46,13 @@ export function ProjectsPage() {
});
}, [projects, searchText, teamAliasMap]);
const closeProject = () => {
void setSelectedProjectId(null, { history: "replace" });
clearProjectKeysTableState();
};
if (selectedProjectId) {
return (
<ProjectDetail
projectId={selectedProjectId}
onBack={() => void setSelectedProjectId(null, { history: "replace" })}
/>
);
return <ProjectDetail projectId={selectedProjectId} onBack={closeProject} />;
}
return (

View file

@ -73,7 +73,7 @@ describe("ProjectsTable pagination URL state", () => {
expect(screen.getByTestId("pagination-range")).toHaveTextContent("Showing 11-14 of 14");
});
it("should push ?page=2 onto history when the next page control is clicked", async () => {
it("should write ?page=2 to the URL when the next page control is clicked", async () => {
const user = userEvent.setup();
const onUrlUpdate = vi.fn();
renderTable({ onUrlUpdate });
@ -83,7 +83,7 @@ describe("ProjectsTable pagination URL state", () => {
await waitFor(() => expect(onUrlUpdate).toHaveBeenCalled());
const [update] = onUrlUpdate.mock.calls[0];
expect(update.searchParams.get("page")).toBe("2");
expect(update.options.history).toBe("push");
expect(update.searchParams.has("page_size")).toBe(false);
expect(firstDataRow().getByText("Project 11")).toBeInTheDocument();
});

View file

@ -2,13 +2,13 @@
import { SortingState } from "@tanstack/react-table";
import { FolderKanban } from "lucide-react";
import { parseAsInteger, useQueryStates } from "nuqs";
import { useMemo, useState } from "react";
import { ProjectResponse } from "@/app/(dashboard)/hooks/projects/useProjects";
import { DataTable, DataTablePagination } from "@/components/shared/DataTable";
import { getProjectsTableColumns } from "./ProjectsTableColumns";
import { PROJECTS_DEFAULT_PAGE_SIZE, useProjectsTableState } from "./useProjectsUrlState";
interface ProjectsTableProps {
projects: ProjectResponse[];
@ -19,8 +19,7 @@ interface ProjectsTableProps {
isTeamsLoading: boolean;
}
const DEFAULT_PAGE_SIZE = 10;
const PAGE_SIZE_OPTIONS = [DEFAULT_PAGE_SIZE, 25, 50];
const PAGE_SIZE_OPTIONS = [PROJECTS_DEFAULT_PAGE_SIZE, 25, 50];
function EmptyState({ isFiltered }: { isFiltered: boolean }) {
return (
@ -47,11 +46,8 @@ export function ProjectsTable({
isTeamsLoading,
}: ProjectsTableProps) {
const [sorting, setSorting] = useState<SortingState>([]);
const [{ page, page_size }, setPagination] = useQueryStates(
{ page: parseAsInteger.withDefault(1), page_size: parseAsInteger.withDefault(DEFAULT_PAGE_SIZE) },
{ history: "push" },
);
const pageSize = PAGE_SIZE_OPTIONS.includes(page_size) ? page_size : DEFAULT_PAGE_SIZE;
const { pagination, onPaginationChange } = useProjectsTableState();
const pageSize = PAGE_SIZE_OPTIONS.includes(pagination.pageSize) ? pagination.pageSize : PROJECTS_DEFAULT_PAGE_SIZE;
const columns = useMemo(() => {
const deps = { onProjectClick, teamAliasMap, isTeamsLoading };
@ -59,7 +55,7 @@ export function ProjectsTable({
}, [onProjectClick, teamAliasMap, isTeamsLoading]);
const pageCount = Math.max(Math.ceil(projects.length / pageSize), 1);
const pageIndex = page >= 1 && page <= pageCount ? page - 1 : 0;
const pageIndex = pagination.pageIndex < pageCount ? pagination.pageIndex : 0;
return (
<DataTable
@ -77,8 +73,8 @@ export function ProjectsTable({
page={pageIndex}
pageSize={pageSize}
rowCount={projects.length}
onPageChange={(nextPageIndex) => void setPagination({ page: nextPageIndex + 1 })}
onPageSizeChange={(nextPageSize) => void setPagination({ page_size: nextPageSize, page: null })}
onPageChange={(nextPageIndex) => onPaginationChange({ pageIndex: nextPageIndex, pageSize })}
onPageSizeChange={(nextPageSize) => onPaginationChange({ pageIndex: 0, pageSize: nextPageSize })}
pageSizeOptions={PAGE_SIZE_OPTIONS}
isLoading={isLoading}
/>

View file

@ -0,0 +1,39 @@
import { useUrlTableState, type UrlTableState, type UrlTableStateOptions } from "@/components/shared/DataTable";
import { parseAsString, useQueryStates } from "nuqs";
import { useCallback } from "react";
export const PROJECTS_DEFAULT_PAGE_SIZE = 10;
export const PROJECT_KEYS_DEFAULT_PAGE_SIZE = 5;
const PROJECT_KEYS_URL_PREFIX = "keys_";
const TABLE_STATE_URL_KEYS = ["search", "sort_by", "sort_order", "page", "page_size"] as const;
const PROJECTS_TABLE_STATE_OPTIONS: UrlTableStateOptions<never> = {
sortFields: [],
defaultSort: { id: "created_at", desc: true },
defaultPageSize: PROJECTS_DEFAULT_PAGE_SIZE,
filterColumns: [],
urlKeys: { search: "project_search" },
};
const PROJECT_KEYS_TABLE_STATE_OPTIONS: UrlTableStateOptions<never> = {
sortFields: [],
defaultSort: { id: "created_at", desc: true },
defaultPageSize: PROJECT_KEYS_DEFAULT_PAGE_SIZE,
maxPageSize: 25,
filterColumns: [],
keyPrefix: PROJECT_KEYS_URL_PREFIX,
};
const PROJECT_KEYS_URL_STATE = Object.fromEntries(
TABLE_STATE_URL_KEYS.map((key) => [`${PROJECT_KEYS_URL_PREFIX}${key}`, parseAsString]),
);
export const useProjectsTableState = (): UrlTableState => useUrlTableState(PROJECTS_TABLE_STATE_OPTIONS);
export const useProjectKeysTableState = (): UrlTableState => useUrlTableState(PROJECT_KEYS_TABLE_STATE_OPTIONS);
export function useClearProjectKeysTableState(): () => void {
const [, setProjectKeysUrlState] = useQueryStates(PROJECT_KEYS_URL_STATE);
return useCallback(() => void setProjectKeysUrlState(null), [setProjectKeysUrlState]);
}