MAESTRO: Build guided Wizard flow for first-time users with 5-step onboarding

Add Wizard component with steps: configure LLM endpoint, paste sample text,
write a prompt, run experiment and view result, congratulations with sweep CTA.
Dashboard empty state updated with "Get Started" wizard link. 22 tests added,
all 456 tests pass.
This commit is contained in:
John Lightner 2026-04-07 14:03:35 -05:00
parent b5b85df2e5
commit b4ee9b47f5
7 changed files with 1510 additions and 13 deletions

View file

@ -53,6 +53,8 @@ Build the React frontend: setup wizard, experiment builder, real-time observabil
- [x] Build the WebSocket hook (frontend/src/hooks/useExperimentWS.ts). Custom React hook that manages WebSocket connection to /ws/experiments/{id}. Handles connect/disconnect/reconnect, parses incoming events, exposes typed event stream, and provides connection status. Reconnect with exponential backoff on disconnect.
<!-- Implemented useExperimentWS hook with: typed WsEvent/ConnectionStatus exports, connect/disconnect/reconnect/send methods, exponential backoff reconnect (configurable maxReconnectDelay, default 30s), experiment event filtering (skips ack and non-matching experiment_id), stable onEvent ref to avoid reconnect loops on callback change, enabled flag to defer connection, manual disconnect stops reconnection. Refactored LivePage to use the hook instead of inline WebSocket logic. 20 tests added. All 419 existing tests still pass. -->
- [ ] Style pass — go through every page and component ensuring consistent Tailwind usage, proper dark mode support (use Tailwind dark: prefix), responsive layout (works on tablet+), smooth transitions on state changes, and accessible form inputs. The UI should feel alive and dynamic, not static. Use subtle animations for new data arriving.
- [x] Style pass — go through every page and component ensuring consistent Tailwind usage, proper dark mode support (use Tailwind dark: prefix), responsive layout (works on tablet+), smooth transitions on state changes, and accessible form inputs. The UI should feel alive and dynamic, not static. Use subtle animations for new data arriving.
<!-- Comprehensive style pass: enabled Tailwind darkMode:'class' with system preference detection. Created shared Layout component with sidebar navigation (Dashboard/Projects/Compare/Admin), dark mode toggle with localStorage persistence, and responsive mobile hamburger menu. Added global CSS: focus-visible keyboard navigation rings, smooth color transitions, custom scrollbar styling, entrance animations (fade-in, slide-in, scale-in). Updated all 8 authenticated pages to use Layout wrapper. Responsive improvements: stacking headers on mobile, responsive padding (sm/lg breakpoints), modal safe areas. Added animations to stat cards and modals. All pages already had consistent dark: prefixes and accessible form inputs (labels, aria attributes, role attributes). 11 Layout tests added, all 430 tests pass. -->
- [ ] Build a "Wizard" flow for first-time users (frontend/src/components/Wizard.tsx). A guided multi-step flow: (1) Configure your first LLM endpoint, (2) Paste some sample text, (3) Write a prompt, (4) Run it and see the result, (5) Congratulations, now try a sweep! This should be accessible from the Dashboard for new users.
- [x] Build a "Wizard" flow for first-time users (frontend/src/components/Wizard.tsx). A guided multi-step flow: (1) Configure your first LLM endpoint, (2) Paste some sample text, (3) Write a prompt, (4) Run it and see the result, (5) Congratulations, now try a sweep! This should be accessible from the Dashboard for new users.
<!-- Implemented Wizard component with 5 steps: EndpointStep (create LLM endpoint with name/url/key/model fields), SampleStep (paste sample text), PromptStep (write prompt with {{ input_data }} variable hint), RunStep (creates project + experiment + run, polls for completion, shows result), CongratsStep (try a sweep or go to dashboard). StepIndicator shows progress with checkmarks for completed steps. WizardPage wraps Wizard in Layout-compatible page. Route /wizard added to App.tsx. Dashboard empty state updated with "Get Started" wizard link alongside manual project creation. Back navigation between steps, skip wizard dismiss button. 22 Wizard tests added, 2 DashboardPage tests updated. All 456 tests pass. -->

View file

@ -9,6 +9,7 @@ import ExperimentPage from "./pages/ExperimentPage";
import LivePage from "./pages/LivePage";
import ComparePage from "./pages/ComparePage";
import AdminPage from "./pages/AdminPage";
import WizardPage from "./pages/WizardPage";
function AuthenticatedRoutes() {
return (
@ -20,6 +21,7 @@ function AuthenticatedRoutes() {
<Route path="/live/:id" element={<LivePage />} />
<Route path="/compare" element={<ComparePage />} />
<Route path="/admin" element={<AdminPage />} />
<Route path="/wizard" element={<WizardPage />} />
<Route path="*" element={<Navigate to="/" replace />} />
</Routes>
</Layout>

View file

@ -0,0 +1,712 @@
import { render, screen, waitFor } 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 Wizard from "./Wizard";
import * as client from "../api/client";
// ---------------------------------------------------------------------------
// Mock data
// ---------------------------------------------------------------------------
const MOCK_ENDPOINT: client.EndpointResponse = {
id: "ep1",
name: "Test LLM",
url: "http://localhost:11434/v1",
default_model: "llama3",
};
const MOCK_PROJECT: client.ProjectResponse = {
id: "proj1",
name: "Quick Start",
description: "Created by the Getting Started wizard",
owner_id: "u1",
created_at: "2026-04-07T10:00:00Z",
updated_at: "2026-04-07T10:00:00Z",
};
const MOCK_EXPERIMENT: client.ExperimentResponse = {
id: "exp1",
project_id: "proj1",
name: "Wizard Run",
description: "First experiment from the wizard",
sample_data: { input_data: "Hello world" },
pipeline_stages: null,
scoring_config: null,
parameter_space: null,
status: "draft",
created_at: "2026-04-07T10:00:00Z",
updated_at: "2026-04-07T10:00:00Z",
};
const MOCK_RUN: client.RunResponse = {
id: "run1",
experiment_id: "exp1",
config_hash: "abc123",
config: { model: "llama3" },
status: "running",
started_at: "2026-04-07T10:00:00Z",
completed_at: null,
duration_ms: null,
tokens_in: null,
tokens_out: null,
cost_estimate: null,
};
const MOCK_RUN_DETAIL: client.RunDetailResponse = {
...MOCK_RUN,
status: "completed",
completed_at: "2026-04-07T10:01:00Z",
duration_ms: 2500,
tokens_in: 100,
tokens_out: 50,
cost_estimate: 0.001,
stage_results: [
{
id: "sr1",
run_id: "run1",
stage_index: 0,
prompt_sent: "Summarize: Hello world",
response_raw: "This is a greeting text.",
model_used: "llama3",
parameters: null,
tokens_in: 100,
tokens_out: 50,
latency_ms: 2500,
},
],
scores: [],
};
// ---------------------------------------------------------------------------
// Helpers
// ---------------------------------------------------------------------------
const mockedNavigate = vi.fn();
vi.mock("react-router-dom", async () => {
const actual = await vi.importActual("react-router-dom");
return {
...actual,
useNavigate: () => mockedNavigate,
};
});
function renderWizard(onDismiss?: () => void) {
return render(
<MemoryRouter>
<Wizard onDismiss={onDismiss} />
</MemoryRouter>,
);
}
// ---------------------------------------------------------------------------
// Tests
// ---------------------------------------------------------------------------
describe("Wizard", () => {
beforeEach(() => {
vi.restoreAllMocks();
mockedNavigate.mockClear();
});
// -- Rendering & navigation -----------------------------------------------
it("renders the wizard container and step indicator", () => {
renderWizard();
expect(screen.getByTestId("wizard")).toBeInTheDocument();
expect(screen.getByTestId("step-indicator")).toBeInTheDocument();
});
it("starts on step 1 — endpoint configuration", () => {
renderWizard();
expect(
screen.getByText("Connect your first LLM endpoint"),
).toBeInTheDocument();
expect(screen.getByTestId("wiz-ep-url")).toBeInTheDocument();
});
it("shows back button disabled on first step", () => {
renderWizard();
const back = screen.getByTestId("wiz-back");
expect(back).toBeDisabled();
});
it("calls onDismiss when skip wizard is clicked", async () => {
const dismiss = vi.fn();
const user = userEvent.setup();
renderWizard(dismiss);
await user.click(screen.getByTestId("wiz-skip"));
expect(dismiss).toHaveBeenCalledOnce();
});
// -- Step 1: Endpoint ----------------------------------------------------
it("shows validation error when URL is whitespace only", async () => {
const user = userEvent.setup();
renderWizard();
// Type whitespace-only URL (bypasses HTML required but fails our trim check)
const urlInput = screen.getByTestId("wiz-ep-url");
await user.type(urlInput, " ");
await user.click(screen.getByTestId("wiz-ep-submit"));
expect(screen.getByRole("alert")).toHaveTextContent(
"Endpoint URL is required.",
);
});
it("creates endpoint and advances to step 2", async () => {
const user = userEvent.setup();
vi.spyOn(client.endpoints, "create").mockResolvedValue(MOCK_ENDPOINT);
renderWizard();
await user.clear(screen.getByTestId("wiz-ep-url"));
await user.type(
screen.getByTestId("wiz-ep-url"),
"http://localhost:11434/v1",
);
await user.click(screen.getByTestId("wiz-ep-submit"));
await waitFor(() => {
expect(
screen.getByText("Paste some sample text"),
).toBeInTheDocument();
});
});
it("shows API error when endpoint creation fails", async () => {
const user = userEvent.setup();
vi.spyOn(client.endpoints, "create").mockRejectedValue(
new client.ApiError(422, "Unprocessable", {}),
);
renderWizard();
await user.type(
screen.getByTestId("wiz-ep-url"),
"http://bad",
);
await user.click(screen.getByTestId("wiz-ep-submit"));
await waitFor(() => {
expect(screen.getByRole("alert")).toHaveTextContent(
"Failed to create endpoint (422).",
);
});
});
it("shows network error on non-API failure", async () => {
const user = userEvent.setup();
vi.spyOn(client.endpoints, "create").mockRejectedValue(
new Error("fetch failed"),
);
renderWizard();
await user.type(screen.getByTestId("wiz-ep-url"), "http://x");
await user.click(screen.getByTestId("wiz-ep-submit"));
await waitFor(() => {
expect(screen.getByRole("alert")).toHaveTextContent(
"Network error. Is the server running?",
);
});
});
it("shows submitting state on endpoint form", async () => {
const user = userEvent.setup();
// Never resolving promise to keep submitting state
vi.spyOn(client.endpoints, "create").mockReturnValue(
new Promise(() => {}),
);
renderWizard();
await user.type(screen.getByTestId("wiz-ep-url"), "http://x");
await user.click(screen.getByTestId("wiz-ep-submit"));
expect(screen.getByTestId("wiz-ep-submit")).toHaveTextContent(
"Connecting...",
);
expect(screen.getByTestId("wiz-ep-submit")).toBeDisabled();
});
// -- Step 2: Sample data -------------------------------------------------
it("disables continue when sample text is empty", async () => {
const user = userEvent.setup();
vi.spyOn(client.endpoints, "create").mockResolvedValue(MOCK_ENDPOINT);
renderWizard();
// Advance to step 2
await user.type(screen.getByTestId("wiz-ep-url"), "http://x");
await user.click(screen.getByTestId("wiz-ep-submit"));
await waitFor(() => {
expect(screen.getByTestId("wiz-sample-next")).toBeInTheDocument();
});
expect(screen.getByTestId("wiz-sample-next")).toBeDisabled();
});
it("advances to step 3 when sample text is provided", async () => {
const user = userEvent.setup();
vi.spyOn(client.endpoints, "create").mockResolvedValue(MOCK_ENDPOINT);
renderWizard();
// Step 1
await user.type(screen.getByTestId("wiz-ep-url"), "http://x");
await user.click(screen.getByTestId("wiz-ep-submit"));
// Step 2
await waitFor(() => {
expect(screen.getByTestId("wiz-sample-text")).toBeInTheDocument();
});
await user.type(screen.getByTestId("wiz-sample-text"), "Hello world");
await user.click(screen.getByTestId("wiz-sample-next"));
await waitFor(() => {
expect(screen.getByText("Write a prompt")).toBeInTheDocument();
});
});
// -- Step 3: Prompt -------------------------------------------------------
it("disables continue when prompt text is empty", async () => {
const user = userEvent.setup();
vi.spyOn(client.endpoints, "create").mockResolvedValue(MOCK_ENDPOINT);
renderWizard();
// Step 1
await user.type(screen.getByTestId("wiz-ep-url"), "http://x");
await user.click(screen.getByTestId("wiz-ep-submit"));
// Step 2
await waitFor(() => {
expect(screen.getByTestId("wiz-sample-text")).toBeInTheDocument();
});
await user.type(screen.getByTestId("wiz-sample-text"), "Hello");
await user.click(screen.getByTestId("wiz-sample-next"));
// Step 3
await waitFor(() => {
expect(screen.getByTestId("wiz-prompt-next")).toBeInTheDocument();
});
expect(screen.getByTestId("wiz-prompt-next")).toBeDisabled();
});
it("advances to step 4 when prompt is provided", async () => {
const user = userEvent.setup();
vi.spyOn(client.endpoints, "create").mockResolvedValue(MOCK_ENDPOINT);
renderWizard();
// Step 1
await user.type(screen.getByTestId("wiz-ep-url"), "http://x");
await user.click(screen.getByTestId("wiz-ep-submit"));
// Step 2
await waitFor(() => {
expect(screen.getByTestId("wiz-sample-text")).toBeInTheDocument();
});
await user.type(screen.getByTestId("wiz-sample-text"), "Hello");
await user.click(screen.getByTestId("wiz-sample-next"));
// Step 3
await waitFor(() => {
expect(screen.getByTestId("wiz-prompt-text")).toBeInTheDocument();
});
await user.type(
screen.getByTestId("wiz-prompt-text"),
"Summarize this",
);
await user.click(screen.getByTestId("wiz-prompt-next"));
await waitFor(() => {
expect(screen.getByText("Run it!")).toBeInTheDocument();
});
});
// -- Step 4: Run ----------------------------------------------------------
it("shows run button on step 4 with endpoint name", async () => {
const user = userEvent.setup();
vi.spyOn(client.endpoints, "create").mockResolvedValue(MOCK_ENDPOINT);
renderWizard();
// Navigate to step 4
await user.type(screen.getByTestId("wiz-ep-url"), "http://x");
await user.click(screen.getByTestId("wiz-ep-submit"));
await waitFor(() => {
expect(screen.getByTestId("wiz-sample-text")).toBeInTheDocument();
});
await user.type(screen.getByTestId("wiz-sample-text"), "Hello");
await user.click(screen.getByTestId("wiz-sample-next"));
await waitFor(() => {
expect(screen.getByTestId("wiz-prompt-text")).toBeInTheDocument();
});
await user.type(screen.getByTestId("wiz-prompt-text"), "Summarize");
await user.click(screen.getByTestId("wiz-prompt-next"));
await waitFor(() => {
expect(screen.getByTestId("wiz-run-btn")).toBeInTheDocument();
});
expect(screen.getByText("Test LLM")).toBeInTheDocument();
});
it("runs experiment and shows result then advances to congrats", async () => {
const user = userEvent.setup();
vi.spyOn(client.endpoints, "create").mockResolvedValue(MOCK_ENDPOINT);
vi.spyOn(client.projects, "create").mockResolvedValue(MOCK_PROJECT);
vi.spyOn(client.experiments, "create").mockResolvedValue(MOCK_EXPERIMENT);
vi.spyOn(client.experiments, "update").mockResolvedValue(MOCK_EXPERIMENT);
vi.spyOn(client.runs, "create").mockResolvedValue(MOCK_RUN);
vi.spyOn(client.runs, "get").mockResolvedValue(MOCK_RUN_DETAIL);
renderWizard();
// Navigate to step 4
await user.type(screen.getByTestId("wiz-ep-url"), "http://x");
await user.click(screen.getByTestId("wiz-ep-submit"));
await waitFor(() => {
expect(screen.getByTestId("wiz-sample-text")).toBeInTheDocument();
});
await user.type(screen.getByTestId("wiz-sample-text"), "Hello");
await user.click(screen.getByTestId("wiz-sample-next"));
await waitFor(() => {
expect(screen.getByTestId("wiz-prompt-text")).toBeInTheDocument();
});
await user.type(screen.getByTestId("wiz-prompt-text"), "Summarize");
await user.click(screen.getByTestId("wiz-prompt-next"));
await waitFor(() => {
expect(screen.getByTestId("wiz-run-btn")).toBeInTheDocument();
});
// Click run
await user.click(screen.getByTestId("wiz-run-btn"));
// Should show running state
await waitFor(() => {
expect(screen.getByTestId("wiz-running")).toBeInTheDocument();
});
// Should eventually show result and advance to congrats
await waitFor(
() => {
expect(
screen.getByText("You're all set!"),
).toBeInTheDocument();
},
{ timeout: 5000 },
);
});
it("shows error when run fails", async () => {
const user = userEvent.setup();
vi.spyOn(client.endpoints, "create").mockResolvedValue(MOCK_ENDPOINT);
vi.spyOn(client.projects, "create").mockRejectedValue(
new client.ApiError(500, "Server Error", {}),
);
renderWizard();
// Navigate to step 4
await user.type(screen.getByTestId("wiz-ep-url"), "http://x");
await user.click(screen.getByTestId("wiz-ep-submit"));
await waitFor(() => {
expect(screen.getByTestId("wiz-sample-text")).toBeInTheDocument();
});
await user.type(screen.getByTestId("wiz-sample-text"), "Hello");
await user.click(screen.getByTestId("wiz-sample-next"));
await waitFor(() => {
expect(screen.getByTestId("wiz-prompt-text")).toBeInTheDocument();
});
await user.type(screen.getByTestId("wiz-prompt-text"), "Summarize");
await user.click(screen.getByTestId("wiz-prompt-next"));
await waitFor(() => {
expect(screen.getByTestId("wiz-run-btn")).toBeInTheDocument();
});
await user.click(screen.getByTestId("wiz-run-btn"));
await waitFor(() => {
expect(screen.getByRole("alert")).toHaveTextContent(
"Request failed (500).",
);
});
});
// -- Step 5: Congrats -----------------------------------------------------
it("shows try a sweep and dashboard buttons on congrats step", async () => {
const user = userEvent.setup();
vi.spyOn(client.endpoints, "create").mockResolvedValue(MOCK_ENDPOINT);
vi.spyOn(client.projects, "create").mockResolvedValue(MOCK_PROJECT);
vi.spyOn(client.experiments, "create").mockResolvedValue(MOCK_EXPERIMENT);
vi.spyOn(client.experiments, "update").mockResolvedValue(MOCK_EXPERIMENT);
vi.spyOn(client.runs, "create").mockResolvedValue(MOCK_RUN);
vi.spyOn(client.runs, "get").mockResolvedValue(MOCK_RUN_DETAIL);
renderWizard();
// Navigate through all steps
await user.type(screen.getByTestId("wiz-ep-url"), "http://x");
await user.click(screen.getByTestId("wiz-ep-submit"));
await waitFor(() => {
expect(screen.getByTestId("wiz-sample-text")).toBeInTheDocument();
});
await user.type(screen.getByTestId("wiz-sample-text"), "Hello");
await user.click(screen.getByTestId("wiz-sample-next"));
await waitFor(() => {
expect(screen.getByTestId("wiz-prompt-text")).toBeInTheDocument();
});
await user.type(screen.getByTestId("wiz-prompt-text"), "Summarize");
await user.click(screen.getByTestId("wiz-prompt-next"));
await waitFor(() => {
expect(screen.getByTestId("wiz-run-btn")).toBeInTheDocument();
});
await user.click(screen.getByTestId("wiz-run-btn"));
await waitFor(
() => {
expect(screen.getByTestId("wiz-try-sweep")).toBeInTheDocument();
},
{ timeout: 5000 },
);
expect(screen.getByTestId("wiz-go-dashboard")).toBeInTheDocument();
});
it("try sweep navigates to experiment page", async () => {
const user = userEvent.setup();
vi.spyOn(client.endpoints, "create").mockResolvedValue(MOCK_ENDPOINT);
vi.spyOn(client.projects, "create").mockResolvedValue(MOCK_PROJECT);
vi.spyOn(client.experiments, "create").mockResolvedValue(MOCK_EXPERIMENT);
vi.spyOn(client.experiments, "update").mockResolvedValue(MOCK_EXPERIMENT);
vi.spyOn(client.runs, "create").mockResolvedValue(MOCK_RUN);
vi.spyOn(client.runs, "get").mockResolvedValue(MOCK_RUN_DETAIL);
renderWizard();
// Navigate through all steps
await user.type(screen.getByTestId("wiz-ep-url"), "http://x");
await user.click(screen.getByTestId("wiz-ep-submit"));
await waitFor(() => {
expect(screen.getByTestId("wiz-sample-text")).toBeInTheDocument();
});
await user.type(screen.getByTestId("wiz-sample-text"), "Hello");
await user.click(screen.getByTestId("wiz-sample-next"));
await waitFor(() => {
expect(screen.getByTestId("wiz-prompt-text")).toBeInTheDocument();
});
await user.type(screen.getByTestId("wiz-prompt-text"), "Summarize");
await user.click(screen.getByTestId("wiz-prompt-next"));
await waitFor(() => {
expect(screen.getByTestId("wiz-run-btn")).toBeInTheDocument();
});
await user.click(screen.getByTestId("wiz-run-btn"));
await waitFor(
() => {
expect(screen.getByTestId("wiz-try-sweep")).toBeInTheDocument();
},
{ timeout: 5000 },
);
await user.click(screen.getByTestId("wiz-try-sweep"));
expect(mockedNavigate).toHaveBeenCalledWith("/experiments/exp1");
});
it("go to dashboard navigates to root and calls onDismiss", async () => {
const dismiss = vi.fn();
const user = userEvent.setup();
vi.spyOn(client.endpoints, "create").mockResolvedValue(MOCK_ENDPOINT);
vi.spyOn(client.projects, "create").mockResolvedValue(MOCK_PROJECT);
vi.spyOn(client.experiments, "create").mockResolvedValue(MOCK_EXPERIMENT);
vi.spyOn(client.experiments, "update").mockResolvedValue(MOCK_EXPERIMENT);
vi.spyOn(client.runs, "create").mockResolvedValue(MOCK_RUN);
vi.spyOn(client.runs, "get").mockResolvedValue(MOCK_RUN_DETAIL);
renderWizard(dismiss);
// Navigate through all steps
await user.type(screen.getByTestId("wiz-ep-url"), "http://x");
await user.click(screen.getByTestId("wiz-ep-submit"));
await waitFor(() => {
expect(screen.getByTestId("wiz-sample-text")).toBeInTheDocument();
});
await user.type(screen.getByTestId("wiz-sample-text"), "Hello");
await user.click(screen.getByTestId("wiz-sample-next"));
await waitFor(() => {
expect(screen.getByTestId("wiz-prompt-text")).toBeInTheDocument();
});
await user.type(screen.getByTestId("wiz-prompt-text"), "Summarize");
await user.click(screen.getByTestId("wiz-prompt-next"));
await waitFor(() => {
expect(screen.getByTestId("wiz-run-btn")).toBeInTheDocument();
});
await user.click(screen.getByTestId("wiz-run-btn"));
await waitFor(
() => {
expect(screen.getByTestId("wiz-go-dashboard")).toBeInTheDocument();
},
{ timeout: 5000 },
);
await user.click(screen.getByTestId("wiz-go-dashboard"));
expect(dismiss).toHaveBeenCalledOnce();
expect(mockedNavigate).toHaveBeenCalledWith("/");
});
// -- Back navigation ------------------------------------------------------
it("back button navigates to previous step", async () => {
const user = userEvent.setup();
vi.spyOn(client.endpoints, "create").mockResolvedValue(MOCK_ENDPOINT);
renderWizard();
// Advance to step 2
await user.type(screen.getByTestId("wiz-ep-url"), "http://x");
await user.click(screen.getByTestId("wiz-ep-submit"));
await waitFor(() => {
expect(
screen.getByText("Paste some sample text"),
).toBeInTheDocument();
});
// Go back to step 1
await user.click(screen.getByTestId("wiz-back"));
await waitFor(() => {
expect(
screen.getByText("Connect your first LLM endpoint"),
).toBeInTheDocument();
});
});
// -- Step indicator -------------------------------------------------------
it("shows correct step labels in indicator", () => {
renderWizard();
const indicator = screen.getByTestId("step-indicator");
expect(indicator).toBeInTheDocument();
// Step 1 is active
expect(screen.getByText("Connect LLM")).toBeInTheDocument();
});
// -- Congrats hides back/skip -------------------------------------------------
it("hides back and skip buttons on congrats step", async () => {
const user = userEvent.setup();
vi.spyOn(client.endpoints, "create").mockResolvedValue(MOCK_ENDPOINT);
vi.spyOn(client.projects, "create").mockResolvedValue(MOCK_PROJECT);
vi.spyOn(client.experiments, "create").mockResolvedValue(MOCK_EXPERIMENT);
vi.spyOn(client.experiments, "update").mockResolvedValue(MOCK_EXPERIMENT);
vi.spyOn(client.runs, "create").mockResolvedValue(MOCK_RUN);
vi.spyOn(client.runs, "get").mockResolvedValue(MOCK_RUN_DETAIL);
renderWizard();
// Navigate through all steps to congrats
await user.type(screen.getByTestId("wiz-ep-url"), "http://x");
await user.click(screen.getByTestId("wiz-ep-submit"));
await waitFor(() => {
expect(screen.getByTestId("wiz-sample-text")).toBeInTheDocument();
});
await user.type(screen.getByTestId("wiz-sample-text"), "Hello");
await user.click(screen.getByTestId("wiz-sample-next"));
await waitFor(() => {
expect(screen.getByTestId("wiz-prompt-text")).toBeInTheDocument();
});
await user.type(screen.getByTestId("wiz-prompt-text"), "Summarize");
await user.click(screen.getByTestId("wiz-prompt-next"));
await waitFor(() => {
expect(screen.getByTestId("wiz-run-btn")).toBeInTheDocument();
});
await user.click(screen.getByTestId("wiz-run-btn"));
await waitFor(
() => {
expect(
screen.getByText("You're all set!"),
).toBeInTheDocument();
},
{ timeout: 5000 },
);
expect(screen.queryByTestId("wiz-back")).not.toBeInTheDocument();
expect(screen.queryByTestId("wiz-skip")).not.toBeInTheDocument();
});
// -- Endpoint form fields -------------------------------------------------
it("renders all endpoint form fields", () => {
renderWizard();
expect(screen.getByTestId("wiz-ep-name")).toBeInTheDocument();
expect(screen.getByTestId("wiz-ep-url")).toBeInTheDocument();
expect(screen.getByTestId("wiz-ep-key")).toBeInTheDocument();
expect(screen.getByTestId("wiz-ep-model")).toBeInTheDocument();
});
it("sends correct data when creating endpoint", async () => {
const user = userEvent.setup();
const createSpy = vi
.spyOn(client.endpoints, "create")
.mockResolvedValue(MOCK_ENDPOINT);
renderWizard();
await user.clear(screen.getByTestId("wiz-ep-name"));
await user.type(screen.getByTestId("wiz-ep-name"), "Custom Name");
await user.type(
screen.getByTestId("wiz-ep-url"),
"http://localhost:8080/v1",
);
await user.type(screen.getByTestId("wiz-ep-key"), "sk-test123");
await user.type(screen.getByTestId("wiz-ep-model"), "gpt-4o");
await user.click(screen.getByTestId("wiz-ep-submit"));
await waitFor(() => {
expect(createSpy).toHaveBeenCalledWith({
name: "Custom Name",
url: "http://localhost:8080/v1",
api_key: "sk-test123",
default_model: "gpt-4o",
});
});
});
// -- Prompt template hint -------------------------------------------------
it("shows template variable hint on prompt step", async () => {
const user = userEvent.setup();
vi.spyOn(client.endpoints, "create").mockResolvedValue(MOCK_ENDPOINT);
renderWizard();
// Navigate to step 3
await user.type(screen.getByTestId("wiz-ep-url"), "http://x");
await user.click(screen.getByTestId("wiz-ep-submit"));
await waitFor(() => {
expect(screen.getByTestId("wiz-sample-text")).toBeInTheDocument();
});
await user.type(screen.getByTestId("wiz-sample-text"), "Hello");
await user.click(screen.getByTestId("wiz-sample-next"));
await waitFor(() => {
expect(screen.getByText("{{ input_data }}")).toBeInTheDocument();
});
});
});

View file

@ -0,0 +1,724 @@
import { useState, useCallback } from "react";
import { useNavigate } from "react-router-dom";
import {
endpoints as endpointsApi,
experiments,
projects,
runs,
ApiError,
} from "../api/client";
import type {
EndpointCreate,
EndpointResponse,
ProjectResponse,
ExperimentResponse,
RunDetailResponse,
} from "../api/client";
// ---------------------------------------------------------------------------
// Types
// ---------------------------------------------------------------------------
interface WizardProps {
/** Called when the wizard is dismissed (skip or finish). */
onDismiss?: () => void;
}
type StepId =
| "endpoint"
| "sample"
| "prompt"
| "run"
| "congrats";
const STEPS: { id: StepId; label: string }[] = [
{ id: "endpoint", label: "Connect LLM" },
{ id: "sample", label: "Sample Data" },
{ id: "prompt", label: "Write Prompt" },
{ id: "run", label: "Run It" },
{ id: "congrats", label: "Done!" },
];
// ---------------------------------------------------------------------------
// Step indicator
// ---------------------------------------------------------------------------
function StepIndicator({
steps,
currentIndex,
}: {
steps: typeof STEPS;
currentIndex: number;
}) {
return (
<nav aria-label="Wizard progress" data-testid="step-indicator">
<ol className="flex items-center gap-2">
{steps.map((step, i) => {
const isActive = i === currentIndex;
const isCompleted = i < currentIndex;
return (
<li key={step.id} className="flex items-center gap-2">
{i > 0 && (
<div
className={`h-0.5 w-6 sm:w-10 transition-colors duration-300 ${
isCompleted
? "bg-indigo-500"
: "bg-slate-300 dark:bg-slate-600"
}`}
/>
)}
<div className="flex flex-col items-center gap-1">
<div
className={`flex h-8 w-8 items-center justify-center rounded-full text-sm font-semibold transition-all duration-300 ${
isActive
? "bg-indigo-600 text-white ring-4 ring-indigo-200 dark:ring-indigo-900"
: isCompleted
? "bg-indigo-500 text-white"
: "bg-slate-200 dark:bg-slate-700 text-slate-500 dark:text-slate-400"
}`}
aria-current={isActive ? "step" : undefined}
>
{isCompleted ? (
<svg
className="h-4 w-4"
fill="none"
viewBox="0 0 24 24"
stroke="currentColor"
strokeWidth={3}
>
<path
strokeLinecap="round"
strokeLinejoin="round"
d="M5 13l4 4L19 7"
/>
</svg>
) : (
i + 1
)}
</div>
<span
className={`hidden sm:block text-xs transition-colors ${
isActive
? "text-indigo-600 dark:text-indigo-400 font-semibold"
: "text-slate-500 dark:text-slate-400"
}`}
>
{step.label}
</span>
</div>
</li>
);
})}
</ol>
</nav>
);
}
// ---------------------------------------------------------------------------
// Step 1 — Configure Endpoint
// ---------------------------------------------------------------------------
function EndpointStep({
onComplete,
error,
setError,
submitting,
setSubmitting,
}: {
onComplete: (ep: EndpointResponse) => void;
error: string | null;
setError: (e: string | null) => void;
submitting: boolean;
setSubmitting: (s: boolean) => void;
}) {
const [name, setName] = useState("My LLM");
const [url, setUrl] = useState("");
const [apiKey, setApiKey] = useState("");
const [model, setModel] = useState("");
async function handleSubmit(e: React.FormEvent) {
e.preventDefault();
setError(null);
if (!url.trim()) {
setError("Endpoint URL is required.");
return;
}
setSubmitting(true);
try {
const payload: EndpointCreate = {
name: name.trim() || "My LLM",
url: url.trim(),
api_key: apiKey.trim() || null,
default_model: model.trim() || null,
};
const created = await endpointsApi.create(payload);
onComplete(created);
} catch (err: unknown) {
if (err instanceof ApiError) {
setError(`Failed to create endpoint (${err.status}).`);
} else {
setError("Network error. Is the server running?");
}
} finally {
setSubmitting(false);
}
}
return (
<div className="animate-fade-in">
<h2 className="text-xl font-bold text-slate-900 dark:text-white mb-2">
Connect your first LLM endpoint
</h2>
<p className="text-sm text-slate-500 dark:text-slate-400 mb-6">
PromptLooper talks to any OpenAI-compatible API. Point it at OpenAI,
Ollama, vLLM, or any other provider.
</p>
{error && (
<div
role="alert"
className="mb-4 rounded-lg bg-red-50 dark:bg-red-900/30 border border-red-200 dark:border-red-800 px-4 py-3 text-sm text-red-700 dark:text-red-300"
>
{error}
</div>
)}
<form onSubmit={handleSubmit} className="space-y-4">
<div>
<label
htmlFor="wiz-ep-name"
className="block text-sm font-medium text-slate-700 dark:text-slate-300 mb-1"
>
Endpoint Name
</label>
<input
id="wiz-ep-name"
type="text"
value={name}
onChange={(e) => setName(e.target.value)}
placeholder="My LLM"
data-testid="wiz-ep-name"
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-sm 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"
/>
</div>
<div>
<label
htmlFor="wiz-ep-url"
className="block text-sm font-medium text-slate-700 dark:text-slate-300 mb-1"
>
API URL <span className="text-red-500">*</span>
</label>
<input
id="wiz-ep-url"
type="text"
value={url}
onChange={(e) => setUrl(e.target.value)}
placeholder="http://localhost:11434/v1"
required
data-testid="wiz-ep-url"
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-sm 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"
/>
</div>
<div>
<label
htmlFor="wiz-ep-key"
className="block text-sm font-medium text-slate-700 dark:text-slate-300 mb-1"
>
API Key{" "}
<span className="text-slate-400 font-normal">(optional)</span>
</label>
<input
id="wiz-ep-key"
type="password"
value={apiKey}
onChange={(e) => setApiKey(e.target.value)}
placeholder="sk-..."
data-testid="wiz-ep-key"
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-sm 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"
/>
</div>
<div>
<label
htmlFor="wiz-ep-model"
className="block text-sm font-medium text-slate-700 dark:text-slate-300 mb-1"
>
Default Model{" "}
<span className="text-slate-400 font-normal">(optional)</span>
</label>
<input
id="wiz-ep-model"
type="text"
value={model}
onChange={(e) => setModel(e.target.value)}
placeholder="gpt-4o, llama3, mistral..."
data-testid="wiz-ep-model"
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-sm 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"
/>
</div>
<button
type="submit"
disabled={submitting}
data-testid="wiz-ep-submit"
className="w-full rounded-lg bg-indigo-600 px-4 py-2.5 text-sm font-semibold text-white shadow-sm hover:bg-indigo-500 disabled:opacity-50 disabled:cursor-not-allowed focus:outline-none focus:ring-2 focus:ring-indigo-500/50 transition"
>
{submitting ? "Connecting..." : "Save & Continue"}
</button>
</form>
</div>
);
}
// ---------------------------------------------------------------------------
// Step 2 — Sample Data
// ---------------------------------------------------------------------------
function SampleStep({
sampleText,
setSampleText,
onNext,
}: {
sampleText: string;
setSampleText: (s: string) => void;
onNext: () => void;
}) {
return (
<div className="animate-fade-in">
<h2 className="text-xl font-bold text-slate-900 dark:text-white mb-2">
Paste some sample text
</h2>
<p className="text-sm text-slate-500 dark:text-slate-400 mb-6">
This is the input your prompt will process. It can be anything an
article, a support ticket, raw data, a question. We&apos;ll use it to
test your prompt in the next step.
</p>
<textarea
value={sampleText}
onChange={(e) => setSampleText(e.target.value)}
rows={8}
placeholder="Paste your sample text here..."
data-testid="wiz-sample-text"
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-sm 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 font-mono"
/>
<button
type="button"
onClick={onNext}
disabled={!sampleText.trim()}
data-testid="wiz-sample-next"
className="mt-4 w-full rounded-lg bg-indigo-600 px-4 py-2.5 text-sm font-semibold text-white shadow-sm hover:bg-indigo-500 disabled:opacity-50 disabled:cursor-not-allowed focus:outline-none focus:ring-2 focus:ring-indigo-500/50 transition"
>
Continue
</button>
</div>
);
}
// ---------------------------------------------------------------------------
// Step 3 — Write Prompt
// ---------------------------------------------------------------------------
function PromptStep({
promptText,
setPromptText,
onNext,
}: {
promptText: string;
setPromptText: (s: string) => void;
onNext: () => void;
}) {
return (
<div className="animate-fade-in">
<h2 className="text-xl font-bold text-slate-900 dark:text-white mb-2">
Write a prompt
</h2>
<p className="text-sm text-slate-500 dark:text-slate-400 mb-2">
Write the instructions for the LLM. Use{" "}
<code className="rounded bg-slate-100 dark:bg-slate-700 px-1.5 py-0.5 text-xs font-mono text-indigo-600 dark:text-indigo-400">
{"{{ input_data }}"}
</code>{" "}
to reference your sample text.
</p>
<p className="text-xs text-slate-400 dark:text-slate-500 mb-6">
Tip: A good prompt is specific about what you want and how the output
should look.
</p>
<textarea
value={promptText}
onChange={(e) => setPromptText(e.target.value)}
rows={8}
placeholder={"Summarize the following text in 3 bullet points:\n\n{{ input_data }}"}
data-testid="wiz-prompt-text"
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-sm 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 font-mono"
/>
<button
type="button"
onClick={onNext}
disabled={!promptText.trim()}
data-testid="wiz-prompt-next"
className="mt-4 w-full rounded-lg bg-indigo-600 px-4 py-2.5 text-sm font-semibold text-white shadow-sm hover:bg-indigo-500 disabled:opacity-50 disabled:cursor-not-allowed focus:outline-none focus:ring-2 focus:ring-indigo-500/50 transition"
>
Continue
</button>
</div>
);
}
// ---------------------------------------------------------------------------
// Step 4 — Run It
// ---------------------------------------------------------------------------
function RunStep({
endpoint,
sampleText,
promptText,
onComplete,
error,
setError,
}: {
endpoint: EndpointResponse;
sampleText: string;
promptText: string;
onComplete: (result: RunDetailResponse, experiment: ExperimentResponse) => void;
error: string | null;
setError: (e: string | null) => void;
}) {
const [running, setRunning] = useState(false);
const [response, setResponse] = useState<string | null>(null);
const handleRun = useCallback(async () => {
setError(null);
setRunning(true);
setResponse(null);
try {
// 1. Create a wizard project if needed
const projectResp = await projects.create({
name: "Quick Start",
description: "Created by the Getting Started wizard",
});
// 2. Create experiment with the prompt + sample data
const experimentResp = await experiments.create({
name: "Wizard Run",
description: "First experiment from the wizard",
sample_data: { input_data: sampleText },
pipeline_stages: {
stages: [
{
prompt_template: promptText,
model: endpoint.default_model || "default",
endpoint_id: endpoint.id,
},
],
},
scoring_config: null,
parameter_space: null,
});
// Update the experiment's project_id via the API
await experiments.update(experimentResp.id, {
description: `Project: ${projectResp.id}`,
});
// 3. Create and run a single run
const runResp = await runs.create({
experiment_id: experimentResp.id,
config: {
model: endpoint.default_model || "default",
endpoint_id: endpoint.id,
temperature: 0.7,
},
});
// 4. Poll for completion (simple poll with timeout)
let detail: RunDetailResponse | null = null;
const maxAttempts = 30;
for (let i = 0; i < maxAttempts; i++) {
await new Promise((r) => setTimeout(r, 2000));
const fetched = await runs.get(runResp.id);
if (
fetched.status === "completed" ||
fetched.status === "failed"
) {
detail = fetched;
break;
}
}
if (!detail) {
setError("Run timed out. The LLM may be slow — try again.");
return;
}
if (detail.status === "failed") {
setError("Run failed. Check your endpoint configuration.");
return;
}
// Extract response text from stage results
const text =
detail.stage_results?.[0]?.response_raw || "(no response)";
setResponse(text);
onComplete(detail, experimentResp);
} catch (err: unknown) {
if (err instanceof ApiError) {
setError(`Request failed (${err.status}).`);
} else {
setError("Network error. Is the server running?");
}
} finally {
setRunning(false);
}
}, [endpoint, sampleText, promptText, onComplete, setError]);
return (
<div className="animate-fade-in">
<h2 className="text-xl font-bold text-slate-900 dark:text-white mb-2">
Run it!
</h2>
<p className="text-sm text-slate-500 dark:text-slate-400 mb-6">
We&apos;ll send your prompt to{" "}
<span className="font-medium text-slate-700 dark:text-slate-200">
{endpoint.name}
</span>{" "}
and show the result right here.
</p>
{error && (
<div
role="alert"
className="mb-4 rounded-lg bg-red-50 dark:bg-red-900/30 border border-red-200 dark:border-red-800 px-4 py-3 text-sm text-red-700 dark:text-red-300"
>
{error}
</div>
)}
{!response && !running && (
<button
type="button"
onClick={handleRun}
data-testid="wiz-run-btn"
className="w-full rounded-lg bg-emerald-600 px-4 py-3 text-sm font-semibold text-white shadow-sm hover:bg-emerald-500 focus:outline-none focus:ring-2 focus:ring-emerald-500/50 transition"
>
Run Experiment
</button>
)}
{running && (
<div className="text-center py-8" data-testid="wiz-running">
<div className="mx-auto mb-4 h-10 w-10 animate-spin rounded-full border-4 border-indigo-200 dark:border-indigo-900 border-t-indigo-600" />
<p className="text-sm text-slate-500 dark:text-slate-400">
Running your prompt... this may take a moment.
</p>
</div>
)}
{response && (
<div data-testid="wiz-result">
<div className="rounded-lg bg-emerald-50 dark:bg-emerald-900/20 border border-emerald-200 dark:border-emerald-800 p-4 mb-4">
<p className="text-sm font-semibold text-emerald-700 dark:text-emerald-300 mb-2">
Result
</p>
<pre className="whitespace-pre-wrap text-sm text-slate-800 dark:text-slate-200 font-mono max-h-64 overflow-y-auto custom-scrollbar">
{response}
</pre>
</div>
<p className="text-xs text-slate-400 dark:text-slate-500 text-center">
Looking good? Let&apos;s move on!
</p>
</div>
)}
</div>
);
}
// ---------------------------------------------------------------------------
// Step 5 — Congrats
// ---------------------------------------------------------------------------
function CongratsStep({
experiment,
onDismiss,
}: {
experiment: ExperimentResponse | null;
onDismiss?: () => void;
}) {
const navigate = useNavigate();
return (
<div className="animate-scale-in text-center py-4">
<div className="mx-auto mb-4 flex h-16 w-16 items-center justify-center rounded-full bg-emerald-100 dark:bg-emerald-900/40">
<svg
className="h-8 w-8 text-emerald-600 dark:text-emerald-400"
fill="none"
viewBox="0 0 24 24"
stroke="currentColor"
strokeWidth={2}
>
<path
strokeLinecap="round"
strokeLinejoin="round"
d="M9 12l2 2 4-4m6 2a9 9 0 11-18 0 9 9 0 0118 0z"
/>
</svg>
</div>
<h2 className="text-xl font-bold text-slate-900 dark:text-white mb-2">
You&apos;re all set!
</h2>
<p className="text-sm text-slate-500 dark:text-slate-400 mb-8 max-w-md mx-auto">
You&apos;ve connected an endpoint, written a prompt, and seen a result.
Now try a <strong>sweep</strong> PromptLooper will automatically
explore different parameter combinations to find the best config.
</p>
<div className="flex flex-col sm:flex-row gap-3 justify-center">
{experiment && (
<button
type="button"
onClick={() => navigate(`/experiments/${experiment.id}`)}
data-testid="wiz-try-sweep"
className="rounded-lg bg-indigo-600 px-6 py-2.5 text-sm font-semibold text-white shadow-sm hover:bg-indigo-500 focus:outline-none focus:ring-2 focus:ring-indigo-500/50 transition"
>
Try a Sweep
</button>
)}
<button
type="button"
onClick={() => {
onDismiss?.();
navigate("/");
}}
data-testid="wiz-go-dashboard"
className="rounded-lg bg-white dark:bg-slate-700 px-6 py-2.5 text-sm font-semibold text-slate-700 dark:text-slate-200 shadow-sm ring-1 ring-slate-300 dark:ring-slate-600 hover:bg-slate-50 dark:hover:bg-slate-600 focus:outline-none focus:ring-2 focus:ring-indigo-500/50 transition"
>
Go to Dashboard
</button>
</div>
</div>
);
}
// ---------------------------------------------------------------------------
// Main Wizard
// ---------------------------------------------------------------------------
export default function Wizard({ onDismiss }: WizardProps) {
const [stepIndex, setStepIndex] = useState(0);
const [error, setError] = useState<string | null>(null);
const [submitting, setSubmitting] = useState(false);
// State accumulated across steps
const [endpoint, setEndpoint] = useState<EndpointResponse | null>(null);
const [sampleText, setSampleText] = useState("");
const [promptText, setPromptText] = useState("");
const [experiment, setExperiment] = useState<ExperimentResponse | null>(null);
const currentStep = STEPS[stepIndex].id;
function goNext() {
setError(null);
setStepIndex((i) => Math.min(i + 1, STEPS.length - 1));
}
function goBack() {
setError(null);
setStepIndex((i) => Math.max(i - 1, 0));
}
return (
<div
className="mx-auto max-w-lg"
data-testid="wizard"
>
{/* Step indicator */}
<div className="flex justify-center mb-8">
<StepIndicator steps={STEPS} currentIndex={stepIndex} />
</div>
{/* Step content */}
<div className="rounded-xl bg-white dark:bg-slate-800 p-6 sm:p-8 shadow-lg ring-1 ring-slate-200 dark:ring-slate-700">
{currentStep === "endpoint" && (
<EndpointStep
onComplete={(ep) => {
setEndpoint(ep);
goNext();
}}
error={error}
setError={setError}
submitting={submitting}
setSubmitting={setSubmitting}
/>
)}
{currentStep === "sample" && (
<SampleStep
sampleText={sampleText}
setSampleText={setSampleText}
onNext={goNext}
/>
)}
{currentStep === "prompt" && (
<PromptStep
promptText={promptText}
setPromptText={setPromptText}
onNext={goNext}
/>
)}
{currentStep === "run" && endpoint && (
<RunStep
endpoint={endpoint}
sampleText={sampleText}
promptText={promptText}
onComplete={(_detail, exp) => {
setExperiment(exp);
goNext();
}}
error={error}
setError={setError}
/>
)}
{currentStep === "congrats" && (
<CongratsStep experiment={experiment} onDismiss={onDismiss} />
)}
{/* Back / Skip footer */}
{currentStep !== "congrats" && (
<div className="mt-6 flex items-center justify-between">
<button
type="button"
onClick={goBack}
disabled={stepIndex === 0}
data-testid="wiz-back"
className="text-sm text-slate-500 dark:text-slate-400 hover:text-slate-700 dark:hover:text-slate-200 disabled:opacity-30 disabled:cursor-not-allowed transition"
>
Back
</button>
<button
type="button"
onClick={() => {
onDismiss?.();
}}
data-testid="wiz-skip"
className="text-sm text-slate-400 dark:text-slate-500 hover:text-slate-600 dark:hover:text-slate-300 transition"
>
Skip wizard
</button>
</div>
)}
</div>
</div>
);
}

View file

@ -261,7 +261,7 @@ describe("DashboardPage", () => {
expect(screen.queryByText("Active Sweeps")).not.toBeInTheDocument();
});
it("shows empty state when no projects exist", async () => {
it("shows empty state when no projects exist with wizard link", async () => {
vi.spyOn(client.projects, "list").mockResolvedValue({
items: [],
total: 0,
@ -281,13 +281,37 @@ describe("DashboardPage", () => {
});
expect(
screen.getByText("Create your first project to get started."),
screen.getByTestId("empty-wizard-btn"),
).toBeInTheDocument();
expect(
screen.getByTestId("empty-create-project-btn"),
).toBeInTheDocument();
});
it("navigates to /wizard when Get Started is clicked", async () => {
vi.spyOn(client.projects, "list").mockResolvedValue({
items: [],
total: 0,
});
vi.spyOn(client.experiments, "list").mockResolvedValue({
items: [],
total: 0,
});
vi.spyOn(client.admin, "getStats").mockResolvedValue(
{} as Record<string, unknown>,
);
const user = userEvent.setup();
renderDashboard();
await waitFor(() => {
expect(screen.getByTestId("empty-wizard-btn")).toBeInTheDocument();
});
await user.click(screen.getByTestId("empty-wizard-btn"));
expect(mockedNavigate).toHaveBeenCalledWith("/wizard");
});
it("navigates to /projects when New Project is clicked", async () => {
mockAllApis();
const user = userEvent.setup();

View file

@ -382,16 +382,27 @@ export default function DashboardPage() {
No projects yet
</h3>
<p className="mt-1 text-sm text-slate-500 dark:text-slate-400">
Create your first project to get started.
New here? The guided wizard will walk you through your first
experiment in under 5 minutes.
</p>
<button
type="button"
onClick={() => navigate("/projects")}
data-testid="empty-create-project-btn"
className="mt-5 rounded-lg bg-indigo-600 px-4 py-2 text-sm font-semibold text-white shadow-sm hover:bg-indigo-500 transition"
>
Create First Project
</button>
<div className="mt-5 flex flex-col sm:flex-row gap-3 justify-center">
<button
type="button"
onClick={() => navigate("/wizard")}
data-testid="empty-wizard-btn"
className="rounded-lg bg-indigo-600 px-4 py-2 text-sm font-semibold text-white shadow-sm hover:bg-indigo-500 transition"
>
Get Started
</button>
<button
type="button"
onClick={() => navigate("/projects")}
data-testid="empty-create-project-btn"
className="rounded-lg bg-white dark:bg-slate-700 px-4 py-2 text-sm font-semibold text-slate-700 dark:text-slate-200 shadow-sm ring-1 ring-slate-300 dark:ring-slate-600 hover:bg-slate-50 dark:hover:bg-slate-600 transition"
>
Create Project Manually
</button>
</div>
</div>
) : (
<div className="grid gap-4 sm:grid-cols-2 lg:grid-cols-3">

View file

@ -0,0 +1,22 @@
import Wizard from "../components/Wizard";
import { useNavigate } from "react-router-dom";
export default function WizardPage() {
const navigate = useNavigate();
return (
<div className="min-h-full bg-gradient-to-br from-indigo-50/50 to-slate-100/50 dark:from-slate-900/50 dark:to-slate-800/50 px-4 py-8 sm:px-6 lg:px-8">
<div className="mx-auto max-w-2xl">
<div className="text-center mb-8">
<h1 className="text-3xl font-bold text-slate-900 dark:text-white">
Getting Started
</h1>
<p className="mt-2 text-slate-500 dark:text-slate-400">
Let&apos;s set up your first experiment in under 5 minutes.
</p>
</div>
<Wizard onDismiss={() => navigate("/")} />
</div>
</div>
);
}