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
bb4554b2e3
commit
1683e88b9f
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