From 04a96f3dc3a0286c36e48279367c73baab0beeb9 Mon Sep 17 00:00:00 2001 From: John Lightner Date: Tue, 7 Apr 2026 02:47:24 -0500 Subject: [PATCH] MAESTRO: Implement Projects page with card grid, creation modal, and comprehensive tests --- Auto Run Docs/02b-frontend-dashboard.md | 3 +- frontend/src/pages/ProjectsPage.test.tsx | 297 +++++++++++++++++++ frontend/src/pages/ProjectsPage.tsx | 352 ++++++++++++++++++++++- 3 files changed, 647 insertions(+), 5 deletions(-) create mode 100644 frontend/src/pages/ProjectsPage.test.tsx diff --git a/Auto Run Docs/02b-frontend-dashboard.md b/Auto Run Docs/02b-frontend-dashboard.md index 1ed6173..3281f42 100644 --- a/Auto Run Docs/02b-frontend-dashboard.md +++ b/Auto Run Docs/02b-frontend-dashboard.md @@ -11,7 +11,8 @@ Build the React frontend: setup wizard, experiment builder, real-time observabil - [x] Build the auth context provider (frontend/src/contexts/AuthContext.tsx). Manage JWT state, provide login/logout functions, expose current user info, handle token expiry with automatic redirect to login. Wrap the entire app in this provider. -- [ ] Implement the Projects page (frontend/src/pages/Projects.tsx). Card grid showing all projects with name, description, experiment count, last activity timestamp, and a progress indicator showing best score across all experiments. Include a "New Project" button that opens a creation modal. Click a card to navigate to its experiments. +- [x] Implement the Projects page (frontend/src/pages/Projects.tsx). Card grid showing all projects with name, description, experiment count, last activity timestamp, and a progress indicator showing best score across all experiments. Include a "New Project" button that opens a creation modal. Click a card to navigate to its experiments. + - [ ] Implement the Experiment Builder (frontend/src/pages/Experiment.tsx). This is the most complex page. It has several sections: (1) Basic info (name, description), (2) Sample data input (paste text, upload file, or enter JSON), (3) Pipeline stage builder (add/remove stages, each with a prompt template editor with syntax highlighting, model selector dropdown populated from configured endpoints, and parameter controls), (4) Scoring config (checkboxes for which scorers to enable, weight sliders for each), (5) Parameter space definition (for each parameter, set type: fixed/range/options and values), (6) Action buttons: Save Draft, Run Single, Start Sweep. diff --git a/frontend/src/pages/ProjectsPage.test.tsx b/frontend/src/pages/ProjectsPage.test.tsx new file mode 100644 index 0000000..95d12cf --- /dev/null +++ b/frontend/src/pages/ProjectsPage.test.tsx @@ -0,0 +1,297 @@ +import { render, screen, waitFor, within } from "@testing-library/react"; +import userEvent from "@testing-library/user-event"; +import { MemoryRouter } from "react-router-dom"; +import { describe, it, expect, vi, beforeEach } from "vitest"; +import ProjectsPage from "./ProjectsPage"; +import * as client from "../api/client"; + +const mockNavigate = vi.fn(); +vi.mock("react-router-dom", async () => { + const actual = await vi.importActual("react-router-dom"); + return { + ...actual, + useNavigate: () => mockNavigate, + }; +}); + +const MOCK_PROJECTS: client.ProjectResponse[] = [ + { + id: "p1", + name: "Summarizer", + description: "Tune summarization prompts", + owner_id: "u1", + created_at: "2026-04-01T10:00:00Z", + updated_at: "2026-04-07T08:00:00Z", + }, + { + id: "p2", + name: "Classifier", + description: null, + owner_id: "u1", + created_at: "2026-03-20T12:00:00Z", + updated_at: "2026-03-25T15:30:00Z", + }, +]; + +function renderProjects() { + return render( + + + , + ); +} + +describe("ProjectsPage", () => { + beforeEach(() => { + vi.restoreAllMocks(); + mockNavigate.mockReset(); + }); + + it("shows loading state initially", () => { + vi.spyOn(client.projects, "list").mockImplementation( + () => new Promise(() => {}), // never resolves + ); + renderProjects(); + expect(screen.getByText("Loading projects…")).toBeInTheDocument(); + }); + + it("renders project cards after loading", async () => { + vi.spyOn(client.projects, "list").mockResolvedValue({ + items: MOCK_PROJECTS, + total: 2, + }); + renderProjects(); + + await waitFor(() => { + expect(screen.getByText("Summarizer")).toBeInTheDocument(); + }); + expect(screen.getByText("Classifier")).toBeInTheDocument(); + expect( + screen.getByText("Tune summarization prompts"), + ).toBeInTheDocument(); + }); + + it("shows empty state when no projects exist", async () => { + vi.spyOn(client.projects, "list").mockResolvedValue({ + items: [], + total: 0, + }); + renderProjects(); + + await waitFor(() => { + expect(screen.getByText("No projects yet")).toBeInTheDocument(); + }); + expect( + screen.getByRole("button", { name: "Create First Project" }), + ).toBeInTheDocument(); + }); + + it("shows error state on API failure", async () => { + vi.spyOn(client.projects, "list").mockRejectedValue( + new client.ApiError(500, "Internal Server Error", { detail: "DB error" }), + ); + renderProjects(); + + await waitFor(() => { + expect(screen.getByRole("alert")).toHaveTextContent( + "Failed to load projects (500)", + ); + }); + }); + + it("shows network error on fetch failure", async () => { + vi.spyOn(client.projects, "list").mockRejectedValue( + new Error("fetch failed"), + ); + renderProjects(); + + await waitFor(() => { + expect(screen.getByRole("alert")).toHaveTextContent( + "Network error", + ); + }); + }); + + it("retries loading on retry button click", async () => { + const listSpy = vi + .spyOn(client.projects, "list") + .mockRejectedValueOnce(new Error("fail")) + .mockResolvedValueOnce({ items: MOCK_PROJECTS, total: 2 }); + + renderProjects(); + + await waitFor(() => { + expect(screen.getByText("Retry")).toBeInTheDocument(); + }); + + const user = userEvent.setup(); + await user.click(screen.getByText("Retry")); + + await waitFor(() => { + expect(screen.getByText("Summarizer")).toBeInTheDocument(); + }); + expect(listSpy).toHaveBeenCalledTimes(2); + }); + + it("navigates to experiments when clicking a project card", async () => { + vi.spyOn(client.projects, "list").mockResolvedValue({ + items: MOCK_PROJECTS, + total: 2, + }); + renderProjects(); + + await waitFor(() => { + expect(screen.getByText("Summarizer")).toBeInTheDocument(); + }); + + const user = userEvent.setup(); + await user.click(screen.getByText("Summarizer")); + + expect(mockNavigate).toHaveBeenCalledWith("/experiments/p1"); + }); + + it("opens and closes the new project modal", async () => { + vi.spyOn(client.projects, "list").mockResolvedValue({ + items: [], + total: 0, + }); + renderProjects(); + + await waitFor(() => { + expect(screen.getByText("No projects yet")).toBeInTheDocument(); + }); + + const user = userEvent.setup(); + // Click the header "New Project" button + await user.click(screen.getByRole("button", { name: "New Project" })); + + expect(screen.getByTestId("new-project-modal")).toBeInTheDocument(); + expect(screen.getByText("New Project", { selector: "h2" })).toBeInTheDocument(); + + // Close via Cancel + await user.click(screen.getByRole("button", { name: "Cancel" })); + expect(screen.queryByTestId("new-project-modal")).not.toBeInTheDocument(); + }); + + it("creates a project via the modal", async () => { + const newProject: client.ProjectResponse = { + id: "p-new", + name: "New Project", + description: "Created via modal", + owner_id: "u1", + created_at: "2026-04-07T12:00:00Z", + updated_at: "2026-04-07T12:00:00Z", + }; + vi.spyOn(client.projects, "list").mockResolvedValue({ + items: [], + total: 0, + }); + vi.spyOn(client.projects, "create").mockResolvedValue(newProject); + + renderProjects(); + + await waitFor(() => { + expect(screen.getByText("No projects yet")).toBeInTheDocument(); + }); + + const user = userEvent.setup(); + await user.click(screen.getByRole("button", { name: "New Project" })); + + const modal = screen.getByTestId("new-project-modal"); + await user.type(within(modal).getByLabelText("Name"), "New Project"); + await user.type( + within(modal).getByLabelText("Description"), + "Created via modal", + ); + await user.click( + within(modal).getByRole("button", { name: "Create Project" }), + ); + + await waitFor(() => { + expect(client.projects.create).toHaveBeenCalledWith({ + name: "New Project", + description: "Created via modal", + }); + }); + + // Modal closes and card appears + expect(screen.queryByTestId("new-project-modal")).not.toBeInTheDocument(); + expect(screen.getByText("Created via modal")).toBeInTheDocument(); + }); + + it("shows validation error when project name is empty", async () => { + vi.spyOn(client.projects, "list").mockResolvedValue({ + items: [], + total: 0, + }); + renderProjects(); + + await waitFor(() => { + expect(screen.getByText("No projects yet")).toBeInTheDocument(); + }); + + const user = userEvent.setup(); + await user.click(screen.getByRole("button", { name: "New Project" })); + + const modal = screen.getByTestId("new-project-modal"); + await user.click( + within(modal).getByRole("button", { name: "Create Project" }), + ); + + expect(within(modal).getByRole("alert")).toHaveTextContent( + "Project name is required", + ); + }); + + it("shows API error in modal on create failure", async () => { + vi.spyOn(client.projects, "list").mockResolvedValue({ + items: [], + total: 0, + }); + vi.spyOn(client.projects, "create").mockRejectedValue( + new client.ApiError(400, "Bad Request", { detail: "Name taken" }), + ); + + renderProjects(); + + await waitFor(() => { + expect(screen.getByText("No projects yet")).toBeInTheDocument(); + }); + + const user = userEvent.setup(); + await user.click(screen.getByRole("button", { name: "New Project" })); + + const modal = screen.getByTestId("new-project-modal"); + await user.type(within(modal).getByLabelText("Name"), "Test"); + await user.click( + within(modal).getByRole("button", { name: "Create Project" }), + ); + + await waitFor(() => { + expect(within(modal).getByRole("alert")).toHaveTextContent( + "Failed to create project: Name taken", + ); + }); + }); + + it("opens modal from empty state CTA button", async () => { + vi.spyOn(client.projects, "list").mockResolvedValue({ + items: [], + total: 0, + }); + renderProjects(); + + await waitFor(() => { + expect( + screen.getByRole("button", { name: "Create First Project" }), + ).toBeInTheDocument(); + }); + + const user = userEvent.setup(); + await user.click( + screen.getByRole("button", { name: "Create First Project" }), + ); + + expect(screen.getByTestId("new-project-modal")).toBeInTheDocument(); + }); +}); diff --git a/frontend/src/pages/ProjectsPage.tsx b/frontend/src/pages/ProjectsPage.tsx index d8dec90..97dd2e0 100644 --- a/frontend/src/pages/ProjectsPage.tsx +++ b/frontend/src/pages/ProjectsPage.tsx @@ -1,8 +1,352 @@ -export default function ProjectsPage() { +import { useEffect, useState, useCallback } from "react"; +import { useNavigate } from "react-router-dom"; +import { + projects, + ApiError, +} from "../api/client"; +import type { ProjectResponse, ProjectCreate } from "../api/client"; + +// --------------------------------------------------------------------------- +// New Project Modal +// --------------------------------------------------------------------------- + +function NewProjectModal({ + open, + onClose, + onCreated, +}: { + open: boolean; + onClose: () => void; + onCreated: (p: ProjectResponse) => void; +}) { + const [name, setName] = useState(""); + const [description, setDescription] = useState(""); + const [error, setError] = useState(null); + const [submitting, setSubmitting] = useState(false); + + function reset() { + setName(""); + setDescription(""); + setError(null); + setSubmitting(false); + } + + async function handleSubmit(e: React.FormEvent) { + e.preventDefault(); + setError(null); + + if (!name.trim()) { + setError("Project name is required."); + return; + } + + setSubmitting(true); + try { + const payload: ProjectCreate = { name: name.trim() }; + if (description.trim()) { + payload.description = description.trim(); + } + const created = await projects.create(payload); + reset(); + onCreated(created); + } catch (err: unknown) { + if (err instanceof ApiError) { + const detail = + err.body && typeof err.body === "object" && "detail" in err.body + ? String((err.body as Record).detail) + : err.statusText; + setError(`Failed to create project: ${detail}`); + } else { + setError("Network error. Is the server running?"); + } + } finally { + setSubmitting(false); + } + } + + if (!open) return null; + return ( -
-

Projects

-

Manage your prompt tuning projects.

+
+
+

+ New Project +

+ +
+ {error && ( +
+ {error} +
+ )} + +
+ + setName(e.target.value)} + className="w-full rounded-lg border border-slate-300 dark:border-slate-600 bg-white dark:bg-slate-700 px-3 py-2.5 text-slate-900 dark:text-white placeholder-slate-400 focus:border-indigo-500 focus:outline-none focus:ring-2 focus:ring-indigo-500/20 transition" + placeholder="My Prompt Project" + disabled={submitting} + /> +
+ +
+ +