feat(FN-2403): add project setup step to onboarding flow
- Introduce a new "project-setup" onboarding step between GitHub and first-task actions - Gate first-task navigation on project selection and add contextual setup-wizard guidance - Centralize ordered onboarding step definitions in shared state and reuse them for navigation/progress labels - Extract ModelOnboardingModal styles into a dedicated component CSS file and update tests/mocks for the new step order
This commit is contained in:
1307
packages/dashboard/app/components/ModelOnboardingModal.css
Normal file
1307
packages/dashboard/app/components/ModelOnboardingModal.css
Normal file
File diff suppressed because it is too large
Load Diff
@@ -1,3 +1,4 @@
|
||||
import "./ModelOnboardingModal.css";
|
||||
import { useState, useEffect, useCallback, useRef } from "react";
|
||||
import { X, Loader2, CheckCircle, Key, Zap, GitPullRequest, Rocket, Plus, ChevronRight } from "lucide-react";
|
||||
import type { AuthProvider, ModelInfo } from "../api";
|
||||
@@ -496,6 +497,7 @@ import {
|
||||
markStepSkipped,
|
||||
getSkippedSteps,
|
||||
getStepData,
|
||||
ONBOARDING_FLOW_STEPS,
|
||||
type OnboardingStep,
|
||||
} from "./model-onboarding-state";
|
||||
import { trackOnboardingEvent } from "./onboarding-events";
|
||||
@@ -541,7 +543,8 @@ const MAX_POLL_CYCLES = 150;
|
||||
* Multi-step onboarding modal that guides users through:
|
||||
* 1. AI Setup - Provider credential setup (OAuth login or API key entry) and default model selection
|
||||
* 2. GitHub (Optional) - GitHub connection status and login
|
||||
* 3. First Task - CTA to create first task or import from GitHub
|
||||
* 3. Project Setup - Register a project directory (or clone a repository URL via setup wizard)
|
||||
* 4. First Task - CTA to create first task or import from GitHub
|
||||
*
|
||||
* Dismissing the modal marks onboarding as complete to prevent repeated popups.
|
||||
*/
|
||||
@@ -557,9 +560,13 @@ export function ModelOnboardingModal({
|
||||
}: ModelOnboardingModalProps) {
|
||||
// Initialize from persisted state if available (allows resume from last step)
|
||||
const persistedState = getOnboardingState();
|
||||
const initialStep: OnboardingStep = persistedState && persistedState.currentStep !== "complete"
|
||||
? persistedState.currentStep as OnboardingStep
|
||||
: "ai-setup";
|
||||
const persistedStep = persistedState?.currentStep;
|
||||
const initialStep: OnboardingStep =
|
||||
persistedStep === "complete"
|
||||
? "ai-setup"
|
||||
: ONBOARDING_FLOW_STEPS.includes(persistedStep as (typeof ONBOARDING_FLOW_STEPS)[number])
|
||||
? (persistedStep as OnboardingStep)
|
||||
: "ai-setup";
|
||||
// Restore completed/skipped steps from persisted state
|
||||
const persistedCompletedSteps = persistedState?.completedSteps ?? [];
|
||||
const persistedSkippedSteps = persistedState?.skippedSteps ?? getSkippedSteps();
|
||||
@@ -607,10 +614,12 @@ export function ModelOnboardingModal({
|
||||
}
|
||||
);
|
||||
|
||||
// Step definitions for progress indicator
|
||||
// Step definitions for progress indicator.
|
||||
// Keep labels aligned with the ordered ONBOARDING_FLOW_STEPS state contract.
|
||||
const steps = [
|
||||
{ key: "ai-setup" as const, label: "AI Setup" },
|
||||
{ key: "github" as const, label: "GitHub" },
|
||||
{ key: "project-setup" as const, label: "Project" },
|
||||
{ key: "first-task" as const, label: "First Task" },
|
||||
];
|
||||
|
||||
@@ -903,6 +912,13 @@ export function ModelOnboardingModal({
|
||||
});
|
||||
}, [step, completedSteps]);
|
||||
|
||||
const getFlowIndex = useCallback((value: OnboardingStep) => {
|
||||
if (value === "complete") {
|
||||
return -1;
|
||||
}
|
||||
return ONBOARDING_FLOW_STEPS.indexOf(value);
|
||||
}, []);
|
||||
|
||||
// Navigate to next step
|
||||
const handleNext = useCallback(() => {
|
||||
// Mark current step as completed before moving forward
|
||||
@@ -915,12 +931,11 @@ export function ModelOnboardingModal({
|
||||
setGitHubSkippedState(false);
|
||||
}
|
||||
|
||||
if (step === "ai-setup") {
|
||||
setStep("github");
|
||||
} else if (step === "github") {
|
||||
setStep("first-task");
|
||||
const currentIndex = getFlowIndex(step);
|
||||
if (currentIndex >= 0 && currentIndex < ONBOARDING_FLOW_STEPS.length - 1) {
|
||||
setStep(ONBOARDING_FLOW_STEPS[currentIndex + 1]);
|
||||
}
|
||||
}, [step, isGithubAuthenticated, setGitHubSkippedState]);
|
||||
}, [step, isGithubAuthenticated, setGitHubSkippedState, getFlowIndex]);
|
||||
|
||||
// Navigate forward without marking completion
|
||||
const handleSkip = useCallback(() => {
|
||||
@@ -932,12 +947,11 @@ export function ModelOnboardingModal({
|
||||
setGitHubSkippedState(true);
|
||||
}
|
||||
|
||||
if (step === "ai-setup") {
|
||||
setStep("github");
|
||||
} else if (step === "github") {
|
||||
setStep("first-task");
|
||||
const currentIndex = getFlowIndex(step);
|
||||
if (currentIndex >= 0 && currentIndex < ONBOARDING_FLOW_STEPS.length - 1) {
|
||||
setStep(ONBOARDING_FLOW_STEPS[currentIndex + 1]);
|
||||
}
|
||||
}, [step, isGithubAuthenticated, setGitHubSkippedState]);
|
||||
}, [step, isGithubAuthenticated, setGitHubSkippedState, getFlowIndex]);
|
||||
|
||||
// Navigate to previous step
|
||||
const handleBack = useCallback(() => {
|
||||
@@ -950,12 +964,11 @@ export function ModelOnboardingModal({
|
||||
setGitHubSkippedState(false);
|
||||
}
|
||||
|
||||
if (step === "github") {
|
||||
setStep("ai-setup");
|
||||
} else if (step === "first-task") {
|
||||
setStep("github");
|
||||
const currentIndex = getFlowIndex(step);
|
||||
if (currentIndex > 0) {
|
||||
setStep(ONBOARDING_FLOW_STEPS[currentIndex - 1]);
|
||||
}
|
||||
}, [step, isGithubAuthenticated, setGitHubSkippedState]);
|
||||
}, [step, isGithubAuthenticated, setGitHubSkippedState, getFlowIndex]);
|
||||
|
||||
const handleSkipGitHubStep = useCallback(() => {
|
||||
handleSkip();
|
||||
@@ -1737,6 +1750,11 @@ export function ModelOnboardingModal({
|
||||
<GitPullRequest size={24} /> Connect GitHub <span className="onboarding-optional-badge">Optional</span>
|
||||
</>
|
||||
)}
|
||||
{step === "project-setup" && (
|
||||
<>
|
||||
<Rocket size={24} /> Set Up Your Project
|
||||
</>
|
||||
)}
|
||||
{step === "first-task" && (
|
||||
<>
|
||||
<Rocket size={24} /> Create Your First Task
|
||||
@@ -1760,7 +1778,7 @@ export function ModelOnboardingModal({
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Step indicator - 3 progress steps + complete */}
|
||||
{/* Step indicator - 4 progress steps + complete */}
|
||||
<div className="model-onboarding-steps">
|
||||
{steps.map((s, index) => {
|
||||
// A step is done/skipped only once we have progressed beyond it.
|
||||
@@ -2057,7 +2075,7 @@ export function ModelOnboardingModal({
|
||||
)}
|
||||
<button
|
||||
className="btn btn-primary btn-sm"
|
||||
onClick={() => setStep("first-task")}
|
||||
onClick={() => setStep("project-setup")}
|
||||
>
|
||||
Continue without GitHub →
|
||||
</button>
|
||||
@@ -2182,12 +2200,49 @@ export function ModelOnboardingModal({
|
||||
</div>
|
||||
)}
|
||||
|
||||
{step === "project-setup" && (
|
||||
<div className="model-onboarding-project-setup">
|
||||
<p className="model-onboarding-description">
|
||||
Choose your first project before creating or importing tasks.
|
||||
You can register an existing local directory or clone a GitHub repository URL through the setup wizard.
|
||||
</p>
|
||||
|
||||
{!hasProjectSelected ? (
|
||||
<div className="onboarding-project-prerequisite" data-testid="onboarding-project-prerequisite">
|
||||
<p className="onboarding-helper-text">
|
||||
A project is required before first-task actions are available.
|
||||
</p>
|
||||
<button
|
||||
type="button"
|
||||
className="btn btn-primary"
|
||||
onClick={onOpenSetupWizard}
|
||||
data-testid="onboarding-open-setup-wizard"
|
||||
>
|
||||
Set Up Project
|
||||
</button>
|
||||
<p className="onboarding-helper-text">
|
||||
In the setup wizard, pick an existing directory or paste a GitHub clone URL.
|
||||
</p>
|
||||
</div>
|
||||
) : (
|
||||
<div className="onboarding-project-ready" data-testid="onboarding-project-ready" role="status">
|
||||
<p>Project selected — task creation and imports are available.</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<OnboardingDisclosure summary="What does project setup do?">
|
||||
<p className="onboarding-helper-text">
|
||||
Project setup registers a workspace so Fusion knows where to read files,
|
||||
run commands, and track task changes.
|
||||
</p>
|
||||
</OnboardingDisclosure>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{step === "first-task" && (
|
||||
<div className="model-onboarding-first-task">
|
||||
<p className="model-onboarding-description">
|
||||
{hasProjectSelected
|
||||
? "Your workspace is ready. Here's how to get started:"
|
||||
: "Before creating your first task, register a project directory."}
|
||||
Create your first task to start the board and launch AI execution.
|
||||
</p>
|
||||
|
||||
{showTaskCreated && createdTaskForDisplay ? (
|
||||
@@ -2383,6 +2438,17 @@ export function ModelOnboardingModal({
|
||||
</>
|
||||
)}
|
||||
|
||||
{step === "project-setup" && (
|
||||
<>
|
||||
<button className="btn btn-sm" onClick={handleBack}>
|
||||
← Back
|
||||
</button>
|
||||
<button className="btn btn-primary" onClick={handleNext} disabled={!hasProjectSelected}>
|
||||
Next →
|
||||
</button>
|
||||
</>
|
||||
)}
|
||||
|
||||
{step === "first-task" && !showTaskCreated && (
|
||||
<>
|
||||
<button className="btn btn-sm" onClick={handleBack}>
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import "./OnboardingResumeCard.css";
|
||||
import { Play, Sparkles } from "lucide-react";
|
||||
import { getOnboardingResumeStep } from "./model-onboarding-state";
|
||||
import { getOnboardingResumeStep, ONBOARDING_FLOW_STEPS } from "./model-onboarding-state";
|
||||
import { trackOnboardingEvent } from "./onboarding-events";
|
||||
|
||||
interface OnboardingResumeCardProps {
|
||||
@@ -22,7 +22,7 @@ export function OnboardingResumeCard({ onResume }: OnboardingResumeCardProps) {
|
||||
}
|
||||
|
||||
const completedCount = resumeStep.completedSteps.length;
|
||||
const totalSteps = 3; // ai-setup, github, first-task
|
||||
const totalSteps = ONBOARDING_FLOW_STEPS.length;
|
||||
const progressText = completedCount > 0
|
||||
? `${completedCount} of ${totalSteps} step${completedCount !== 1 ? "s" : ""} complete — You're on the `
|
||||
: "You're on the ";
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -132,6 +132,7 @@ vi.mock("../../components/model-onboarding-state", () => ({
|
||||
getOnboardingCompletedAt: (...args: unknown[]) => mockGetOnboardingCompletedAt(...args),
|
||||
getSkippedSteps: (...args: unknown[]) => mockGetSkippedSteps(...args),
|
||||
getStepData: (...args: unknown[]) => mockGetStepData(...args),
|
||||
ONBOARDING_FLOW_STEPS: ["ai-setup", "github", "project-setup", "first-task"],
|
||||
}));
|
||||
|
||||
// Mock CustomModelDropdown for onboarding modal tests
|
||||
|
||||
@@ -60,6 +60,7 @@ vi.mock("../model-onboarding-state", () => ({
|
||||
markStepSkipped: (...args: unknown[]) => mockMarkStepSkipped(...args),
|
||||
getSkippedSteps: (...args: unknown[]) => mockGetSkippedSteps(...args),
|
||||
getStepData: (...args: unknown[]) => mockGetStepData(...args),
|
||||
ONBOARDING_FLOW_STEPS: ["ai-setup", "github", "project-setup", "first-task"],
|
||||
}));
|
||||
|
||||
const mockTrackOnboardingEvent = vi.fn();
|
||||
@@ -122,9 +123,17 @@ async function navigateToGitHubStep() {
|
||||
});
|
||||
}
|
||||
|
||||
async function navigateToFirstTaskStep() {
|
||||
async function navigateToProjectSetupStep() {
|
||||
await navigateToGitHubStep();
|
||||
fireEvent.click(screen.getByText("Next →"));
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText("Set Up Your Project")).toBeTruthy();
|
||||
});
|
||||
}
|
||||
|
||||
async function navigateToFirstTaskStep() {
|
||||
await navigateToProjectSetupStep();
|
||||
fireEvent.click(screen.getByText("Next →"));
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText("Create Your First Task")).toBeTruthy();
|
||||
});
|
||||
@@ -1394,6 +1403,12 @@ describe("ModelOnboardingModal", () => {
|
||||
|
||||
fireEvent.click(screen.getByText("Continue without GitHub →"));
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText("Set Up Your Project")).toBeTruthy();
|
||||
});
|
||||
|
||||
fireEvent.click(screen.getByText("Next →"));
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText("Create Your First Task")).toBeTruthy();
|
||||
});
|
||||
@@ -1462,10 +1477,10 @@ describe("ModelOnboardingModal", () => {
|
||||
/>,
|
||||
);
|
||||
|
||||
await navigateToFirstTaskStep();
|
||||
await navigateToProjectSetupStep();
|
||||
|
||||
expect(screen.getByTestId("onboarding-project-prerequisite")).toBeTruthy();
|
||||
expect(screen.getByText(/A project must be selected before you can create tasks or import from GitHub/)).toBeTruthy();
|
||||
expect(screen.getByText(/A project is required before first-task actions are available/)).toBeTruthy();
|
||||
expect(screen.queryByTestId("onboarding-first-task-input")).toBeNull();
|
||||
expect(screen.queryByText("Create a New Task")).toBeNull();
|
||||
expect(screen.queryByText("Import from GitHub")).toBeNull();
|
||||
@@ -1482,7 +1497,7 @@ describe("ModelOnboardingModal", () => {
|
||||
/>,
|
||||
);
|
||||
|
||||
await navigateToFirstTaskStep();
|
||||
await navigateToProjectSetupStep();
|
||||
|
||||
fireEvent.click(screen.getByTestId("onboarding-open-setup-wizard"));
|
||||
expect(onOpenSetupWizard).toHaveBeenCalledTimes(1);
|
||||
@@ -1975,6 +1990,12 @@ describe("ModelOnboardingModal", () => {
|
||||
|
||||
fireEvent.click(screen.getByText("← Back"));
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText("Set Up Your Project")).toBeTruthy();
|
||||
});
|
||||
|
||||
fireEvent.click(screen.getByText("← Back"));
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText("Connect GitHub")).toBeTruthy();
|
||||
});
|
||||
@@ -2101,7 +2122,7 @@ describe("ModelOnboardingModal", () => {
|
||||
expect(aiSetupIndicator).toHaveClass("done");
|
||||
expect(githubIndicator).toHaveClass("done");
|
||||
expect(firstTaskIndicator).toHaveClass("done");
|
||||
expect(document.querySelectorAll(".model-onboarding-step-connector.done")).toHaveLength(2);
|
||||
expect(document.querySelectorAll(".model-onboarding-step-connector.done")).toHaveLength(3);
|
||||
|
||||
// Click Get Started to close
|
||||
fireEvent.click(screen.getByText("Get Started"));
|
||||
@@ -2396,6 +2417,12 @@ describe("ModelOnboardingModal", () => {
|
||||
// Navigate back to see if the model dropdown has the saved value
|
||||
fireEvent.click(screen.getByText("← Back"));
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText("Set Up Your Project")).toBeTruthy();
|
||||
});
|
||||
|
||||
fireEvent.click(screen.getByText("← Back"));
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText("Connect GitHub")).toBeTruthy();
|
||||
});
|
||||
@@ -2715,7 +2742,14 @@ describe("ModelOnboardingModal", () => {
|
||||
// Click Next without connecting GitHub
|
||||
fireEvent.click(screen.getByText("Next →"));
|
||||
|
||||
// Should advance to First Task step
|
||||
// Should advance to Project Setup first
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText("Set Up Your Project")).toBeTruthy();
|
||||
});
|
||||
|
||||
fireEvent.click(screen.getByText("Next →"));
|
||||
|
||||
// Then advance to First Task step
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText("Create Your First Task")).toBeTruthy();
|
||||
});
|
||||
@@ -2772,9 +2806,13 @@ describe("ModelOnboardingModal", () => {
|
||||
expect(screen.getByText("Optional")).toBeTruthy();
|
||||
});
|
||||
|
||||
// Navigate to First Task step
|
||||
// Navigate through Project Setup to First Task step
|
||||
fireEvent.click(screen.getByText("Next →"));
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText("Set Up Your Project")).toBeTruthy();
|
||||
});
|
||||
|
||||
fireEvent.click(screen.getByText("Next →"));
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText("Create Your First Task")).toBeTruthy();
|
||||
});
|
||||
@@ -2815,7 +2853,14 @@ describe("ModelOnboardingModal", () => {
|
||||
// Click Skip GitHub
|
||||
fireEvent.click(screen.getByText("Skip GitHub →"));
|
||||
|
||||
// Should advance to First Task step
|
||||
// Should advance to Project Setup first
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText("Set Up Your Project")).toBeTruthy();
|
||||
});
|
||||
|
||||
fireEvent.click(screen.getByText("Next →"));
|
||||
|
||||
// Then advance to First Task step
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText("Create Your First Task")).toBeTruthy();
|
||||
});
|
||||
|
||||
@@ -5,6 +5,7 @@ import { OnboardingResumeCard } from "../OnboardingResumeCard";
|
||||
// Mock the model-onboarding-state module
|
||||
vi.mock("../model-onboarding-state", () => ({
|
||||
getOnboardingResumeStep: vi.fn(),
|
||||
ONBOARDING_FLOW_STEPS: ["ai-setup", "github", "project-setup", "first-task"],
|
||||
}));
|
||||
|
||||
const mockTrackOnboardingEvent = vi.fn();
|
||||
@@ -183,7 +184,7 @@ describe("OnboardingResumeCard", () => {
|
||||
});
|
||||
render(<OnboardingResumeCard onResume={vi.fn()} />);
|
||||
// Uses singular "step" for 1 completed
|
||||
expect(screen.getByText(/1 of 3 step complete/)).toBeInTheDocument();
|
||||
expect(screen.getByText(/1 of 4 step complete/)).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("shows completed step count text with 2 completed steps (plural)", () => {
|
||||
@@ -194,7 +195,7 @@ describe("OnboardingResumeCard", () => {
|
||||
});
|
||||
render(<OnboardingResumeCard onResume={vi.fn()} />);
|
||||
// Uses plural "steps" for 2 completed
|
||||
expect(screen.getByText(/2 of 3 steps complete/)).toBeInTheDocument();
|
||||
expect(screen.getByText(/2 of 4 steps complete/)).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
@@ -18,6 +18,7 @@ vi.mock("../model-onboarding-state", () => ({
|
||||
isOnboardingCompleted: (...args: unknown[]) => mockIsOnboardingCompleted(...args),
|
||||
isPostOnboardingDismissed: (...args: unknown[]) => mockIsPostOnboardingDismissed(...args),
|
||||
dismissPostOnboardingRecommendations: (...args: unknown[]) => mockDismissPostOnboardingRecommendations(...args),
|
||||
ONBOARDING_FLOW_STEPS: ["ai-setup", "github", "project-setup", "first-task"],
|
||||
}));
|
||||
|
||||
vi.mock("lucide-react", async (importOriginal) => {
|
||||
|
||||
@@ -1123,8 +1123,11 @@ describe("QuickChatFAB", () => {
|
||||
expect(screen.getByTestId("quick-chat-model-select")).toBeDefined();
|
||||
});
|
||||
|
||||
// Open the model dropdown
|
||||
// Open the model dropdown after model loading completes
|
||||
const trigger = screen.getByRole("button", { name: "Select model override" });
|
||||
await waitFor(() => {
|
||||
expect(trigger).not.toBeDisabled();
|
||||
});
|
||||
fireEvent.click(trigger);
|
||||
|
||||
const portalDropdown = await screen.findByTestId("model-combobox-portal");
|
||||
|
||||
@@ -803,7 +803,7 @@ describe("TaskForm preset selection (FN-819)", () => {
|
||||
expect(fetchSettings).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
const overrideButton = screen.getByRole("button", { name: "Override" });
|
||||
const overrideButton = await screen.findByRole("button", { name: "Override" });
|
||||
fireEvent.click(overrideButton);
|
||||
|
||||
expect(onPresetModeChange).toHaveBeenCalledWith("custom");
|
||||
|
||||
@@ -56,6 +56,7 @@ vi.mock("../model-onboarding-state", () => ({
|
||||
isOnboardingCompleted: (...args: unknown[]) => mockIsOnboardingCompleted(...args),
|
||||
isPostOnboardingDismissed: (...args: unknown[]) => mockIsPostOnboardingDismissed(...args),
|
||||
dismissPostOnboardingRecommendations: (...args: unknown[]) => mockDismissPostOnboardingRecommendations(...args),
|
||||
ONBOARDING_FLOW_STEPS: ["ai-setup", "github", "project-setup", "first-task"],
|
||||
}));
|
||||
|
||||
vi.mock("../CustomModelDropdown", () => ({
|
||||
@@ -526,6 +527,11 @@ describe("onboarding flow integration", () => {
|
||||
expect(screen.getByText("Connect GitHub")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
fireEvent.click(screen.getByRole("button", { name: "Next →" }));
|
||||
await waitFor(() => {
|
||||
expect(screen.getByRole("button", { name: "Next →" })).toBeInTheDocument();
|
||||
});
|
||||
|
||||
fireEvent.click(screen.getByRole("button", { name: "Next →" }));
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText("Create Your First Task")).toBeInTheDocument();
|
||||
@@ -574,7 +580,7 @@ describe("onboarding flow integration", () => {
|
||||
it("dismissal flow: dismissing on first-task step saves skipped GitHub state", async () => {
|
||||
const renderResult = renderModal();
|
||||
|
||||
await advanceThroughSteps(renderResult, ["next", "skip"]);
|
||||
await advanceThroughSteps(renderResult, ["next", "skip", "next"]);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText("Create Your First Task")).toBeInTheDocument();
|
||||
@@ -614,7 +620,7 @@ describe("onboarding flow integration", () => {
|
||||
|
||||
expect(screen.getByText("Continue Setup")).toBeInTheDocument();
|
||||
expect(screen.getByText(/GitHub/)).toBeInTheDocument();
|
||||
expect(screen.getByText(/1 of 3 step complete/)).toBeInTheDocument();
|
||||
expect(screen.getByText(/1 of 4 step complete/)).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -670,7 +676,7 @@ describe("onboarding flow integration", () => {
|
||||
expect(screen.getByText("Connect GitHub")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
await advanceThroughSteps(renderResult, ["next"]);
|
||||
await advanceThroughSteps(renderResult, ["next", "next"]);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText("Create Your First Task")).toBeInTheDocument();
|
||||
@@ -790,7 +796,7 @@ describe("onboarding flow integration", () => {
|
||||
|
||||
const renderResult = renderModal();
|
||||
|
||||
await advanceThroughSteps(renderResult, ["next", "next"]);
|
||||
await advanceThroughSteps(renderResult, ["next", "next", "next"]);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText("Create Your First Task")).toBeInTheDocument();
|
||||
@@ -809,7 +815,7 @@ describe("onboarding flow integration", () => {
|
||||
expect(screen.getByText("Anthropic")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
await advanceThroughSteps(renderResult, ["skip", "skip"]);
|
||||
await advanceThroughSteps(renderResult, ["skip", "skip", "next"]);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText("Create Your First Task")).toBeInTheDocument();
|
||||
@@ -830,7 +836,7 @@ describe("onboarding flow integration", () => {
|
||||
|
||||
const renderResult = renderModal();
|
||||
|
||||
await advanceThroughSteps(renderResult, ["next", "next"]);
|
||||
await advanceThroughSteps(renderResult, ["next", "next", "next"]);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText("Create Your First Task")).toBeInTheDocument();
|
||||
@@ -857,7 +863,7 @@ describe("onboarding flow integration", () => {
|
||||
|
||||
const renderResult = renderModal();
|
||||
|
||||
await advanceThroughSteps(renderResult, ["next", "next"]);
|
||||
await advanceThroughSteps(renderResult, ["next", "next", "next"]);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText("Create Your First Task")).toBeInTheDocument();
|
||||
@@ -869,7 +875,7 @@ describe("onboarding flow integration", () => {
|
||||
it("skip warnings: Import from GitHub CTA shows connection requirement note when GitHub not connected", async () => {
|
||||
const renderResult = renderModal();
|
||||
|
||||
await advanceThroughSteps(renderResult, ["next", "next"]);
|
||||
await advanceThroughSteps(renderResult, ["next", "next", "next"]);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText("Create Your First Task")).toBeInTheDocument();
|
||||
@@ -888,7 +894,7 @@ describe("onboarding flow integration", () => {
|
||||
|
||||
const renderResult = renderModal();
|
||||
|
||||
await advanceThroughSteps(renderResult, ["next", "next"]);
|
||||
await advanceThroughSteps(renderResult, ["next", "next", "next"]);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText("Create Your First Task")).toBeInTheDocument();
|
||||
@@ -906,7 +912,7 @@ describe("onboarding flow integration", () => {
|
||||
it("task creation flow: inline task creation shows success state and marks onboarding complete", async () => {
|
||||
const renderResult = renderModal();
|
||||
|
||||
await advanceThroughSteps(renderResult, ["next", "next"]);
|
||||
await advanceThroughSteps(renderResult, ["next", "next", "next"]);
|
||||
await simulateFirstTaskCreation(renderResult, "Ship onboarding telemetry");
|
||||
|
||||
await waitFor(() => {
|
||||
@@ -926,7 +932,7 @@ describe("onboarding flow integration", () => {
|
||||
it("task creation flow: View Task button navigates and completes onboarding", async () => {
|
||||
const renderResult = renderModal();
|
||||
|
||||
await advanceThroughSteps(renderResult, ["next", "next"]);
|
||||
await advanceThroughSteps(renderResult, ["next", "next", "next"]);
|
||||
await simulateFirstTaskCreation(renderResult, "View task flow");
|
||||
|
||||
await waitFor(() => {
|
||||
@@ -971,6 +977,12 @@ describe("onboarding flow integration", () => {
|
||||
|
||||
fireEvent.click(screen.getByRole("button", { name: "Next →" }));
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByRole("button", { name: "Next →" })).toBeInTheDocument();
|
||||
});
|
||||
|
||||
fireEvent.click(screen.getByRole("button", { name: "Next →" }));
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText("Create Your First Task")).toBeInTheDocument();
|
||||
});
|
||||
@@ -995,7 +1007,7 @@ describe("onboarding flow integration", () => {
|
||||
it("task creation flow: Finish Setup button (without creating task) completes onboarding", async () => {
|
||||
const renderResult = renderModal();
|
||||
|
||||
await advanceThroughSteps(renderResult, ["next", "next"]);
|
||||
await advanceThroughSteps(renderResult, ["next", "next", "next"]);
|
||||
|
||||
fireEvent.click(screen.getByRole("button", { name: "Finish Setup" }));
|
||||
|
||||
@@ -1009,7 +1021,7 @@ describe("onboarding flow integration", () => {
|
||||
it("task creation flow: Create a New Task CTA completes onboarding and triggers external task dialog", async () => {
|
||||
const renderResult = renderModal();
|
||||
|
||||
await advanceThroughSteps(renderResult, ["next", "next"]);
|
||||
await advanceThroughSteps(renderResult, ["next", "next", "next"]);
|
||||
|
||||
fireEvent.click(screen.getByRole("button", { name: /Create a New Task/i }));
|
||||
|
||||
@@ -1031,7 +1043,7 @@ describe("onboarding flow integration", () => {
|
||||
|
||||
const renderResult = renderModal();
|
||||
|
||||
await advanceThroughSteps(renderResult, ["next", "next"]);
|
||||
await advanceThroughSteps(renderResult, ["next", "next", "next"]);
|
||||
|
||||
fireEvent.click(screen.getByRole("button", { name: /Import from GitHub/i }));
|
||||
|
||||
@@ -1046,7 +1058,7 @@ describe("onboarding flow integration", () => {
|
||||
it("task creation flow: validation error on empty description does not call createTask", async () => {
|
||||
const renderResult = renderModal();
|
||||
|
||||
await advanceThroughSteps(renderResult, ["next", "next"]);
|
||||
await advanceThroughSteps(renderResult, ["next", "next", "next"]);
|
||||
|
||||
fireEvent.click(screen.getByTestId("onboarding-first-task-submit"));
|
||||
|
||||
@@ -1058,7 +1070,7 @@ describe("onboarding flow integration", () => {
|
||||
mockCreateTask.mockRejectedValueOnce(new Error("Task API unavailable"));
|
||||
const renderResult = renderModal();
|
||||
|
||||
await advanceThroughSteps(renderResult, ["next", "next"]);
|
||||
await advanceThroughSteps(renderResult, ["next", "next", "next"]);
|
||||
await simulateFirstTaskCreation(renderResult, "Retryable task");
|
||||
|
||||
await waitFor(() => {
|
||||
@@ -1072,7 +1084,7 @@ describe("onboarding flow integration", () => {
|
||||
it("task creation flow: Get Started button on complete step closes the modal", async () => {
|
||||
const renderResult = renderModal();
|
||||
|
||||
await advanceThroughSteps(renderResult, ["next", "next"]);
|
||||
await advanceThroughSteps(renderResult, ["next", "next", "next"]);
|
||||
|
||||
fireEvent.click(screen.getByRole("button", { name: "Finish Setup" }));
|
||||
|
||||
|
||||
@@ -5,7 +5,7 @@
|
||||
* from where they left off if they dismiss the modal without completing.
|
||||
*/
|
||||
|
||||
export type OnboardingStep = "ai-setup" | "github" | "first-task" | "complete";
|
||||
export type OnboardingStep = "ai-setup" | "github" | "project-setup" | "first-task" | "complete";
|
||||
|
||||
interface OnboardingState {
|
||||
currentStep: OnboardingStep | string; // string allows for future unknown steps
|
||||
@@ -38,6 +38,12 @@ const DEFAULT_COMPLETED = false;
|
||||
const DEFAULT_STEP_DATA: Partial<Record<OnboardingStep, Record<string, unknown>>> = {};
|
||||
const DEFAULT_POST_ONBOARDING_DISMISSED_AT: string | undefined = undefined;
|
||||
|
||||
/**
|
||||
* Ordered onboarding flow steps before completion.
|
||||
* Keep this list in sync with ModelOnboardingModal's stepper rendering and navigation.
|
||||
*/
|
||||
export const ONBOARDING_FLOW_STEPS = ["ai-setup", "github", "project-setup", "first-task"] as const;
|
||||
|
||||
/**
|
||||
* Step labels for display in the resume card.
|
||||
* Fallback for unknown step IDs uses the raw key with title-case formatting.
|
||||
@@ -45,6 +51,7 @@ const DEFAULT_POST_ONBOARDING_DISMISSED_AT: string | undefined = undefined;
|
||||
export const ONBOARDING_STEP_LABELS: Record<OnboardingStep, string> = {
|
||||
"ai-setup": "AI Setup",
|
||||
github: "GitHub",
|
||||
"project-setup": "Project",
|
||||
"first-task": "First Task",
|
||||
complete: "Complete",
|
||||
};
|
||||
|
||||
@@ -14,6 +14,7 @@ vi.mock("../../api", () => ({
|
||||
|
||||
vi.mock("../../components/model-onboarding-state", () => ({
|
||||
isOnboardingCompleted: (...args: unknown[]) => mockIsOnboardingCompleted(...args),
|
||||
ONBOARDING_FLOW_STEPS: ["ai-setup", "github", "project-setup", "first-task"],
|
||||
}));
|
||||
|
||||
vi.mock("../../components/onboarding-events", () => ({
|
||||
|
||||
Reference in New Issue
Block a user