feat(32-01): create OnboardingSummaryStep component and tests
- Read-only summary card with hardware, mode, provider, root dir rows - SummaryRow helper component with optional mono styling - Start chatting CTA with spinner and disabled state - 6 unit tests covering rendering, empty root dir, error, click, loading
This commit is contained in:
parent
2355dac4bd
commit
c0d7ea5a3c
2 changed files with 228 additions and 0 deletions
99
ui/src/components/onboarding/OnboardingSummaryStep.test.tsx
Normal file
99
ui/src/components/onboarding/OnboardingSummaryStep.test.tsx
Normal file
|
|
@ -0,0 +1,99 @@
|
|||
// @vitest-environment jsdom
|
||||
// [nexus] Unit tests for OnboardingSummaryStep component
|
||||
import { describe, it, expect, vi, afterEach } from "vitest";
|
||||
import { render, screen, fireEvent, cleanup } from "@testing-library/react";
|
||||
import { renderToStaticMarkup } from "react-dom/server";
|
||||
import { OnboardingSummaryStep } from "./OnboardingSummaryStep";
|
||||
import type { HardwareInfo } from "@/api/hardware";
|
||||
|
||||
afterEach(() => {
|
||||
cleanup();
|
||||
});
|
||||
|
||||
const mockHardwareInfo: HardwareInfo = {
|
||||
totalGb: 16,
|
||||
freeGb: 8,
|
||||
usableGb: 12,
|
||||
platform: "darwin",
|
||||
gpuName: null,
|
||||
gpuVramGb: null,
|
||||
unifiedMemory: true,
|
||||
hardwareTier: "apple_silicon",
|
||||
cpuModel: "Apple M2",
|
||||
};
|
||||
|
||||
const defaultProps = {
|
||||
hardwareInfo: mockHardwareInfo,
|
||||
selectedMode: "both" as const,
|
||||
providerLabel: "Puter (free, zero-config)",
|
||||
rootDir: "~/projects/test",
|
||||
loading: false,
|
||||
error: null,
|
||||
onStartChat: vi.fn(),
|
||||
onBack: vi.fn(),
|
||||
};
|
||||
|
||||
describe("OnboardingSummaryStep", () => {
|
||||
it("renders all summary rows with provided data", () => {
|
||||
const html = renderToStaticMarkup(
|
||||
<OnboardingSummaryStep {...defaultProps} />
|
||||
);
|
||||
|
||||
expect(html).toContain("Hardware");
|
||||
expect(html).toContain("Apple Silicon");
|
||||
expect(html).toContain("Mode");
|
||||
expect(html).toContain("Both (recommended)");
|
||||
expect(html).toContain("Provider");
|
||||
expect(html).toContain("Puter (free, zero-config)");
|
||||
expect(html).toContain("Root directory");
|
||||
expect(html).toContain("~/projects/test");
|
||||
});
|
||||
|
||||
it("hides root directory row when rootDir is empty string", () => {
|
||||
const html = renderToStaticMarkup(
|
||||
<OnboardingSummaryStep {...defaultProps} rootDir="" />
|
||||
);
|
||||
|
||||
expect(html).not.toContain("Root directory");
|
||||
});
|
||||
|
||||
it("shows error message when error prop is non-null", () => {
|
||||
const html = renderToStaticMarkup(
|
||||
<OnboardingSummaryStep
|
||||
{...defaultProps}
|
||||
error="Setup failed. Please try again."
|
||||
/>
|
||||
);
|
||||
|
||||
expect(html).toContain("Setup failed. Please try again.");
|
||||
});
|
||||
|
||||
it("calls onStartChat when 'Start chatting' button is clicked", () => {
|
||||
const onStartChat = vi.fn();
|
||||
render(<OnboardingSummaryStep {...defaultProps} onStartChat={onStartChat} />);
|
||||
|
||||
const buttons = screen.getAllByRole("button");
|
||||
const startBtn = buttons.find((b) => b.textContent?.includes("Start chatting"));
|
||||
expect(startBtn).toBeTruthy();
|
||||
fireEvent.click(startBtn!);
|
||||
|
||||
expect(onStartChat).toHaveBeenCalledOnce();
|
||||
});
|
||||
|
||||
it("disables 'Start chatting' button when loading is true", () => {
|
||||
render(<OnboardingSummaryStep {...defaultProps} loading={true} />);
|
||||
|
||||
const buttons = screen.getAllByRole("button");
|
||||
const startBtn = buttons.find((b) => b.textContent?.includes("Setting up"));
|
||||
expect(startBtn).toBeTruthy();
|
||||
expect(startBtn).toHaveProperty("disabled", true);
|
||||
});
|
||||
|
||||
it("shows 'Setting up...' text when loading is true", () => {
|
||||
const html = renderToStaticMarkup(
|
||||
<OnboardingSummaryStep {...defaultProps} loading={true} />
|
||||
);
|
||||
|
||||
expect(html).toContain("Setting up...");
|
||||
});
|
||||
});
|
||||
129
ui/src/components/onboarding/OnboardingSummaryStep.tsx
Normal file
129
ui/src/components/onboarding/OnboardingSummaryStep.tsx
Normal file
|
|
@ -0,0 +1,129 @@
|
|||
// [nexus] Summary screen for onboarding wizard step 5 — read-only review before starting
|
||||
import type { HardwareInfo, HardwareTier, NexusMode } from "@/api/hardware";
|
||||
import { Button } from "@/components/ui/button";
|
||||
|
||||
interface OnboardingSummaryStepProps {
|
||||
hardwareInfo: HardwareInfo | undefined;
|
||||
selectedMode: NexusMode;
|
||||
providerLabel: string;
|
||||
rootDir: string;
|
||||
loading: boolean;
|
||||
error: string | null;
|
||||
onStartChat: () => void;
|
||||
onBack: () => void;
|
||||
}
|
||||
|
||||
interface SummaryRowProps {
|
||||
label: string;
|
||||
value: string;
|
||||
mono?: boolean;
|
||||
}
|
||||
|
||||
function SummaryRow({ label, value, mono }: SummaryRowProps) {
|
||||
return (
|
||||
<div className="flex items-start justify-between gap-4">
|
||||
<span className="text-sm text-muted-foreground shrink-0">{label}</span>
|
||||
<span className={mono ? "font-mono text-sm text-right" : "text-sm text-right"}>{value}</span>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
const HARDWARE_TIER_LABELS: Record<HardwareTier, string> = {
|
||||
gpu: "GPU",
|
||||
apple_silicon: "Apple Silicon",
|
||||
cpu_only: "CPU Only",
|
||||
};
|
||||
|
||||
const MODE_LABELS: Record<NexusMode, string> = {
|
||||
personal_ai: "Personal AI Assistant",
|
||||
project_builder: "Project Builder",
|
||||
both: "Both (recommended)",
|
||||
};
|
||||
|
||||
export function OnboardingSummaryStep({
|
||||
hardwareInfo,
|
||||
selectedMode,
|
||||
providerLabel,
|
||||
rootDir,
|
||||
loading,
|
||||
error,
|
||||
onStartChat,
|
||||
onBack,
|
||||
}: OnboardingSummaryStepProps) {
|
||||
const hardwareLabel = hardwareInfo
|
||||
? (HARDWARE_TIER_LABELS[hardwareInfo.hardwareTier] ?? "Unknown")
|
||||
: "Unknown";
|
||||
|
||||
const modeLabel = MODE_LABELS[selectedMode];
|
||||
|
||||
return (
|
||||
<div className="flex flex-col gap-6">
|
||||
<div className="flex flex-col gap-2 text-center">
|
||||
<h1 className="text-2xl font-semibold tracking-tight">Ready to go</h1>
|
||||
<p className="text-sm text-muted-foreground">Review your setup before starting.</p>
|
||||
</div>
|
||||
|
||||
{/* Summary card */}
|
||||
<div className="rounded-lg border border-border p-4 flex flex-col gap-3">
|
||||
<SummaryRow label="Hardware" value={hardwareLabel} />
|
||||
<SummaryRow label="Mode" value={modeLabel} />
|
||||
<SummaryRow label="Provider" value={providerLabel} />
|
||||
{rootDir && <SummaryRow label="Root directory" value={rootDir} mono />}
|
||||
</div>
|
||||
|
||||
{/* Error message */}
|
||||
{error && (
|
||||
<p className="text-sm text-destructive bg-destructive/10 rounded-md px-3 py-2">
|
||||
{error}
|
||||
</p>
|
||||
)}
|
||||
|
||||
{/* Actions */}
|
||||
<div className="flex flex-col gap-2">
|
||||
<Button
|
||||
type="button"
|
||||
onClick={onStartChat}
|
||||
disabled={loading}
|
||||
className="w-full"
|
||||
>
|
||||
{loading ? (
|
||||
<span className="flex items-center gap-2">
|
||||
<svg
|
||||
className="h-4 w-4 animate-spin"
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
aria-hidden="true"
|
||||
>
|
||||
<circle
|
||||
className="opacity-25"
|
||||
cx="12"
|
||||
cy="12"
|
||||
r="10"
|
||||
stroke="currentColor"
|
||||
strokeWidth="4"
|
||||
/>
|
||||
<path
|
||||
className="opacity-75"
|
||||
fill="currentColor"
|
||||
d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4z"
|
||||
/>
|
||||
</svg>
|
||||
Setting up...
|
||||
</span>
|
||||
) : (
|
||||
"Start chatting"
|
||||
)}
|
||||
</Button>
|
||||
<Button
|
||||
type="button"
|
||||
variant="ghost"
|
||||
onClick={onBack}
|
||||
disabled={loading}
|
||||
className="w-full"
|
||||
>
|
||||
Back
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
Loading…
Add table
Reference in a new issue