feat(FN-1937): track skipped onboarding steps in modal progress

- Add skippedSteps persistence to onboarding state with new markStepSkipped/getSkippedSteps helpers and legacy defaults
- Update onboarding navigation so skip actions mark steps as skipped, while completed actions clear skipped state
- Render skipped progress indicators as clickable steps with a dash marker and dedicated skipped styling
- Expand onboarding state/modal tests and update App test mocks for the new onboarding-state exports
This commit is contained in:
Fusion
2026-04-17 02:38:29 -07:00
committed by gsxdsm
parent 07ddb1ca1f
commit 0a9fba6f81
6 changed files with 384 additions and 36 deletions

View File

@@ -353,6 +353,8 @@ import {
saveOnboardingState, saveOnboardingState,
clearOnboardingState, clearOnboardingState,
markOnboardingCompleted, markOnboardingCompleted,
markStepSkipped,
getSkippedSteps,
getStepData, getStepData,
type OnboardingStep, type OnboardingStep,
} from "./model-onboarding-state"; } from "./model-onboarding-state";
@@ -400,12 +402,14 @@ export function ModelOnboardingModal({
const initialStep: OnboardingStep = persistedState && persistedState.currentStep !== "complete" const initialStep: OnboardingStep = persistedState && persistedState.currentStep !== "complete"
? persistedState.currentStep as OnboardingStep ? persistedState.currentStep as OnboardingStep
: "ai-setup"; : "ai-setup";
// Restore completed steps from persisted state // Restore completed/skipped steps from persisted state
const persistedCompletedSteps = persistedState?.completedSteps ?? []; const persistedCompletedSteps = persistedState?.completedSteps ?? [];
const persistedSkippedSteps = persistedState?.skippedSteps ?? getSkippedSteps();
const [isOpen, setIsOpen] = useState(true); const [isOpen, setIsOpen] = useState(true);
const [step, setStep] = useState<OnboardingStep>(initialStep); const [step, setStep] = useState<OnboardingStep>(initialStep);
const [completedSteps, setCompletedSteps] = useState<OnboardingStep[]>(persistedCompletedSteps); const [completedSteps, setCompletedSteps] = useState<OnboardingStep[]>(persistedCompletedSteps);
const [skippedSteps, setSkippedSteps] = useState<OnboardingStep[]>(persistedSkippedSteps);
const [authProviders, setAuthProviders] = useState<AuthProvider[]>([]); const [authProviders, setAuthProviders] = useState<AuthProvider[]>([]);
const [authLoading, setAuthLoading] = useState(true); const [authLoading, setAuthLoading] = useState(true);
const [authActionInProgress, setAuthActionInProgress] = useState<string | null>(null); const [authActionInProgress, setAuthActionInProgress] = useState<string | null>(null);
@@ -446,9 +450,9 @@ export function ModelOnboardingModal({
// Persist step state whenever it changes (for resume functionality) // Persist step state whenever it changes (for resume functionality)
useEffect(() => { useEffect(() => {
if (step !== "complete") { if (step !== "complete") {
saveOnboardingState(step, { completedSteps }); saveOnboardingState(step, { completedSteps, skippedSteps });
} }
}, [step, completedSteps]); }, [step, completedSteps, skippedSteps]);
// Auto-mark unconnected providers as skipped when leaving ai-setup step // Auto-mark unconnected providers as skipped when leaving ai-setup step
// Only skip if NO providers are connected (if at least one is connected, others remain "Not connected") // Only skip if NO providers are connected (if at least one is connected, others remain "Not connected")
@@ -673,29 +677,6 @@ export function ModelOnboardingModal({
}; };
}, []); }, []);
// Navigate to next step
const handleNext = useCallback(() => {
// Mark current step as completed before moving forward
setCompletedSteps(prev => [...new Set([...prev, step])]);
if (step === "ai-setup") {
setStep("github");
} else if (step === "github") {
setStep("first-task");
}
}, [step]);
// Navigate to previous step
const handleBack = useCallback(() => {
// Remove current step from completedSteps when going back (undoing progress)
const currentStepKey = step;
setCompletedSteps(prev => prev.filter(s => s !== currentStepKey));
if (step === "github") {
setStep("ai-setup");
} else if (step === "first-task") {
setStep("github");
}
}, [step]);
const setGitHubSkippedState = useCallback((skipped: boolean) => { const setGitHubSkippedState = useCallback((skipped: boolean) => {
setIsGithubSkipped(skipped); setIsGithubSkipped(skipped);
saveOnboardingState(step, { saveOnboardingState(step, {
@@ -708,12 +689,61 @@ export function ModelOnboardingModal({
}); });
}, [step, completedSteps]); }, [step, completedSteps]);
const handleSkipGitHubStep = useCallback(() => { // Navigate to next step
if (!isGithubAuthenticated) { const handleNext = useCallback(() => {
// Mark current step as completed before moving forward
setCompletedSteps((prev) => [...new Set([...prev, step])]);
// Completing a step clears any prior skipped status
setSkippedSteps((prev) => prev.filter((s) => s !== step));
if (step === "github" && !isGithubAuthenticated) {
setGitHubSkippedState(false);
}
if (step === "ai-setup") {
setStep("github");
} else if (step === "github") {
setStep("first-task");
}
}, [step, isGithubAuthenticated, setGitHubSkippedState]);
// Navigate forward without marking completion
const handleSkip = useCallback(() => {
setSkippedSteps((prev) => [...new Set([...prev, step])]);
markStepSkipped(step);
if (step === "github" && !isGithubAuthenticated) {
setGitHubSkippedState(true); setGitHubSkippedState(true);
} }
handleNext();
}, [isGithubAuthenticated, setGitHubSkippedState, handleNext]); if (step === "ai-setup") {
setStep("github");
} else if (step === "github") {
setStep("first-task");
}
}, [step, isGithubAuthenticated, setGitHubSkippedState]);
// Navigate to previous step
const handleBack = useCallback(() => {
// Remove current step from completed/skipped when going back (undoing progress)
const currentStepKey = step;
setCompletedSteps((prev) => prev.filter((s) => s !== currentStepKey));
setSkippedSteps((prev) => prev.filter((s) => s !== currentStepKey));
if (currentStepKey === "github" && !isGithubAuthenticated) {
setGitHubSkippedState(false);
}
if (step === "github") {
setStep("ai-setup");
} else if (step === "first-task") {
setStep("github");
}
}, [step, isGithubAuthenticated, setGitHubSkippedState]);
const handleSkipGitHubStep = useCallback(() => {
handleSkip();
}, [handleSkip]);
// OAuth login handler // OAuth login handler
const handleLogin = useCallback( const handleLogin = useCallback(
@@ -1192,8 +1222,9 @@ export function ModelOnboardingModal({
{steps.map((s, index) => { {steps.map((s, index) => {
// A step is done if it's in completedSteps AND is before current position // A step is done if it's in completedSteps AND is before current position
const isDone = completedSteps.includes(s.key) && currentStepIndex > index; const isDone = completedSteps.includes(s.key) && currentStepIndex > index;
// Clickable if it's a completed step (can review) or we're on a future step const isSkipped = skippedSteps.includes(s.key) && !completedSteps.includes(s.key) && currentStepIndex > index;
const isClickable = isDone; // Clickable if it's a completed/skipped step (can review)
const isClickable = isDone || isSkipped;
return ( return (
<div key={s.key} className="onboarding-step-wrapper"> <div key={s.key} className="onboarding-step-wrapper">
{index > 0 && ( {index > 0 && (
@@ -1207,7 +1238,7 @@ export function ModelOnboardingModal({
<button <button
className={`model-onboarding-step-indicator ${ className={`model-onboarding-step-indicator ${
step === s.key ? "active" : "" step === s.key ? "active" : ""
} ${isDone ? "done" : ""}`} } ${isDone ? "done" : ""} ${isSkipped ? "skipped" : ""}`}
onClick={() => setStep(s.key)} onClick={() => setStep(s.key)}
aria-label={`Go back to ${s.label}`} aria-label={`Go back to ${s.label}`}
title={`Review ${s.label}`} title={`Review ${s.label}`}
@@ -1215,6 +1246,8 @@ export function ModelOnboardingModal({
<span className="step-number"> <span className="step-number">
{isDone ? ( {isDone ? (
<CheckCircle size={14} /> <CheckCircle size={14} />
) : isSkipped ? (
<span className="onboarding-step-skip-mark">–</span>
) : ( ) : (
index + 1 index + 1
)} )}
@@ -1225,11 +1258,13 @@ export function ModelOnboardingModal({
<div <div
className={`model-onboarding-step-indicator ${ className={`model-onboarding-step-indicator ${
step === s.key ? "active" : "" step === s.key ? "active" : ""
} ${isDone ? "done" : ""}`} } ${isDone ? "done" : ""} ${isSkipped ? "skipped" : ""}`}
> >
<span className="step-number"> <span className="step-number">
{isDone ? ( {isDone ? (
<CheckCircle size={14} /> <CheckCircle size={14} />
) : isSkipped ? (
<span className="onboarding-step-skip-mark">–</span>
) : ( ) : (
index + 1 index + 1
)} )}
@@ -1751,7 +1786,7 @@ export function ModelOnboardingModal({
> >
Skip for now Skip for now
</button> </button>
<button className="onboarding-skip-step-link" onClick={handleNext}> <button className="onboarding-skip-step-link" onClick={handleSkip}>
Skip setup → Skip setup →
</button> </button>
<button className="btn btn-primary" onClick={handleNext}> <button className="btn btn-primary" onClick={handleNext}>
@@ -1765,7 +1800,7 @@ export function ModelOnboardingModal({
<button className="btn btn-sm" onClick={handleBack}> <button className="btn btn-sm" onClick={handleBack}>
← Back ← Back
</button> </button>
<button className="onboarding-skip-step-link" onClick={handleSkipGitHubStep}> <button className="onboarding-skip-step-link" onClick={handleSkip}>
Skip GitHub → Skip GitHub →
</button> </button>
<button className="btn btn-primary" onClick={handleNext}> <button className="btn btn-primary" onClick={handleNext}>

View File

@@ -107,7 +107,9 @@ const mockSaveOnboardingState = vi.fn();
const mockClearOnboardingState = vi.fn(); const mockClearOnboardingState = vi.fn();
const mockIsOnboardingCompleted = vi.fn(); const mockIsOnboardingCompleted = vi.fn();
const mockMarkOnboardingCompleted = vi.fn(); const mockMarkOnboardingCompleted = vi.fn();
const mockMarkStepSkipped = vi.fn();
const mockGetOnboardingCompletedAt = vi.fn(); const mockGetOnboardingCompletedAt = vi.fn();
const mockGetSkippedSteps = vi.fn();
const mockGetStepData = vi.fn(); const mockGetStepData = vi.fn();
vi.mock("../../components/model-onboarding-state", () => ({ vi.mock("../../components/model-onboarding-state", () => ({
@@ -118,7 +120,9 @@ vi.mock("../../components/model-onboarding-state", () => ({
clearOnboardingState: (...args: unknown[]) => mockClearOnboardingState(...args), clearOnboardingState: (...args: unknown[]) => mockClearOnboardingState(...args),
isOnboardingCompleted: (...args: unknown[]) => mockIsOnboardingCompleted(...args), isOnboardingCompleted: (...args: unknown[]) => mockIsOnboardingCompleted(...args),
markOnboardingCompleted: (...args: unknown[]) => mockMarkOnboardingCompleted(...args), markOnboardingCompleted: (...args: unknown[]) => mockMarkOnboardingCompleted(...args),
markStepSkipped: (...args: unknown[]) => mockMarkStepSkipped(...args),
getOnboardingCompletedAt: (...args: unknown[]) => mockGetOnboardingCompletedAt(...args), getOnboardingCompletedAt: (...args: unknown[]) => mockGetOnboardingCompletedAt(...args),
getSkippedSteps: (...args: unknown[]) => mockGetSkippedSteps(...args),
getStepData: (...args: unknown[]) => mockGetStepData(...args), getStepData: (...args: unknown[]) => mockGetStepData(...args),
})); }));
@@ -262,8 +266,13 @@ beforeEach(() => {
mockIsOnboardingCompleted.mockReset(); mockIsOnboardingCompleted.mockReset();
mockIsOnboardingCompleted.mockReturnValue(false); mockIsOnboardingCompleted.mockReturnValue(false);
mockMarkOnboardingCompleted.mockReset(); mockMarkOnboardingCompleted.mockReset();
mockMarkStepSkipped.mockReset();
mockGetOnboardingCompletedAt.mockReset(); mockGetOnboardingCompletedAt.mockReset();
mockGetOnboardingCompletedAt.mockReturnValue(null); mockGetOnboardingCompletedAt.mockReturnValue(null);
mockGetSkippedSteps.mockReset();
mockGetSkippedSteps.mockReturnValue([]);
mockGetStepData.mockReset();
mockGetStepData.mockReturnValue(null);
}); });
describe("App deep link handling", () => { describe("App deep link handling", () => {

View File

@@ -44,6 +44,8 @@ const mockGetOnboardingState = vi.fn();
const mockSaveOnboardingState = vi.fn(); const mockSaveOnboardingState = vi.fn();
const mockClearOnboardingState = vi.fn(); const mockClearOnboardingState = vi.fn();
const mockMarkOnboardingCompleted = vi.fn(); const mockMarkOnboardingCompleted = vi.fn();
const mockMarkStepSkipped = vi.fn();
const mockGetSkippedSteps = vi.fn();
const mockGetStepData = vi.fn(); const mockGetStepData = vi.fn();
vi.mock("../model-onboarding-state", () => ({ vi.mock("../model-onboarding-state", () => ({
@@ -51,6 +53,8 @@ vi.mock("../model-onboarding-state", () => ({
saveOnboardingState: (...args: unknown[]) => mockSaveOnboardingState(...args), saveOnboardingState: (...args: unknown[]) => mockSaveOnboardingState(...args),
clearOnboardingState: (...args: unknown[]) => mockClearOnboardingState(...args), clearOnboardingState: (...args: unknown[]) => mockClearOnboardingState(...args),
markOnboardingCompleted: (...args: unknown[]) => mockMarkOnboardingCompleted(...args), markOnboardingCompleted: (...args: unknown[]) => mockMarkOnboardingCompleted(...args),
markStepSkipped: (...args: unknown[]) => mockMarkStepSkipped(...args),
getSkippedSteps: (...args: unknown[]) => mockGetSkippedSteps(...args),
getStepData: (...args: unknown[]) => mockGetStepData(...args), getStepData: (...args: unknown[]) => mockGetStepData(...args),
})); }));
@@ -123,6 +127,8 @@ beforeEach(() => {
mockSaveOnboardingState.mockImplementation(() => {}); mockSaveOnboardingState.mockImplementation(() => {});
mockClearOnboardingState.mockImplementation(() => {}); mockClearOnboardingState.mockImplementation(() => {});
mockMarkOnboardingCompleted.mockImplementation(() => {}); mockMarkOnboardingCompleted.mockImplementation(() => {});
mockMarkStepSkipped.mockImplementation(() => {});
mockGetSkippedSteps.mockReturnValue([]);
mockGetStepData.mockReturnValue(null); mockGetStepData.mockReturnValue(null);
// Reset mockFetchAuthStatus to default - use mockImplementation for clear control // Reset mockFetchAuthStatus to default - use mockImplementation for clear control
mockFetchAuthStatus.mockReset(); mockFetchAuthStatus.mockReset();
@@ -189,6 +195,155 @@ describe("ModelOnboardingModal", () => {
}); });
}); });
describe("skip vs complete step tracking (FN-1937)", () => {
it("does not mark AI Setup step as completed when Skip setup is clicked", async () => {
render(<ModelOnboardingModal onComplete={vi.fn()} addToast={vi.fn()} />);
await waitFor(() => {
expect(screen.getByRole("button", { name: "Skip setup →" })).toBeTruthy();
});
fireEvent.click(screen.getByRole("button", { name: "Skip setup →" }));
await waitFor(() => {
expect(screen.getByText("Connect GitHub")).toBeTruthy();
});
const aiSetupIndicator = screen.getByRole("button", { name: "Go back to AI Setup" });
expect(aiSetupIndicator).toHaveClass("skipped");
expect(aiSetupIndicator).not.toHaveClass("done");
expect(aiSetupIndicator.querySelector('[data-testid="icon-check-circle"]')).toBeNull();
});
it("marks AI Setup step as completed when Next is clicked", async () => {
render(<ModelOnboardingModal onComplete={vi.fn()} addToast={vi.fn()} />);
await waitFor(() => {
expect(screen.getByRole("button", { name: "Next →" })).toBeTruthy();
});
fireEvent.click(screen.getByRole("button", { name: "Next →" }));
await waitFor(() => {
expect(screen.getByText("Connect GitHub")).toBeTruthy();
});
const aiSetupIndicator = screen.getByRole("button", { name: "Go back to AI Setup" });
expect(aiSetupIndicator).toHaveClass("done");
expect(aiSetupIndicator).not.toHaveClass("skipped");
});
it("does not add step to completedSteps when skipped", async () => {
render(<ModelOnboardingModal onComplete={vi.fn()} addToast={vi.fn()} />);
await waitFor(() => {
expect(screen.getByRole("button", { name: "Skip setup →" })).toBeTruthy();
});
mockSaveOnboardingState.mockClear();
fireEvent.click(screen.getByRole("button", { name: "Skip setup →" }));
await waitFor(() => {
const hasSkipPersistence = mockSaveOnboardingState.mock.calls.some((call) => {
const options = call[1] as { completedSteps?: string[]; skippedSteps?: string[] } | undefined;
return !options?.completedSteps?.includes("ai-setup") && options?.skippedSteps?.includes("ai-setup");
});
expect(hasSkipPersistence).toBe(true);
});
});
it("shows dash icon for skipped step in progress indicator", async () => {
render(<ModelOnboardingModal onComplete={vi.fn()} addToast={vi.fn()} />);
await waitFor(() => {
expect(screen.getByRole("button", { name: "Skip setup →" })).toBeTruthy();
});
fireEvent.click(screen.getByRole("button", { name: "Skip setup →" }));
await waitFor(() => {
const skipMark = document.querySelector(".onboarding-step-skip-mark");
expect(skipMark).toBeTruthy();
expect(skipMark).toHaveTextContent("–");
});
});
it("allows going back to a skipped step", async () => {
render(<ModelOnboardingModal onComplete={vi.fn()} addToast={vi.fn()} />);
await waitFor(() => {
expect(screen.getByRole("button", { name: "Skip setup →" })).toBeTruthy();
});
fireEvent.click(screen.getByRole("button", { name: "Skip setup →" }));
await waitFor(() => {
expect(screen.getByText("Connect GitHub")).toBeTruthy();
});
fireEvent.click(screen.getByRole("button", { name: "← Back" }));
await waitFor(() => {
expect(screen.getByText("Set Up AI")).toBeTruthy();
});
});
it("removes skipped status when step is completed after going back", async () => {
render(<ModelOnboardingModal onComplete={vi.fn()} addToast={vi.fn()} />);
await waitFor(() => {
expect(screen.getByRole("button", { name: "Skip setup →" })).toBeTruthy();
});
fireEvent.click(screen.getByRole("button", { name: "Skip setup →" }));
await waitFor(() => {
expect(screen.getByText("Connect GitHub")).toBeTruthy();
});
fireEvent.click(screen.getByRole("button", { name: "Go back to AI Setup" }));
await waitFor(() => {
expect(screen.getByText("Set Up AI")).toBeTruthy();
});
mockSaveOnboardingState.mockClear();
fireEvent.click(screen.getByRole("button", { name: "Next →" }));
await waitFor(() => {
expect(screen.getByText("Connect GitHub")).toBeTruthy();
});
const aiSetupIndicator = screen.getByRole("button", { name: "Go back to AI Setup" });
expect(aiSetupIndicator).toHaveClass("done");
expect(aiSetupIndicator).not.toHaveClass("skipped");
const hasCompletedPersistence = mockSaveOnboardingState.mock.calls.some((call) => {
const options = call[1] as { completedSteps?: string[]; skippedSteps?: string[] } | undefined;
return options?.completedSteps?.includes("ai-setup") && !options?.skippedSteps?.includes("ai-setup");
});
expect(hasCompletedPersistence).toBe(true);
});
it("skipped step is clickable in progress indicator", async () => {
render(<ModelOnboardingModal onComplete={vi.fn()} addToast={vi.fn()} />);
await waitFor(() => {
expect(screen.getByRole("button", { name: "Skip setup →" })).toBeTruthy();
});
fireEvent.click(screen.getByRole("button", { name: "Skip setup →" }));
await waitFor(() => {
expect(screen.getByText("Connect GitHub")).toBeTruthy();
});
const aiSetupIndicator = screen.getByRole("button", { name: "Go back to AI Setup" });
expect(aiSetupIndicator).toHaveClass("skipped");
fireEvent.click(aiSetupIndicator);
await waitFor(() => {
expect(screen.getByText("Set Up AI")).toBeTruthy();
});
});
});
describe("AI Setup step", () => { describe("AI Setup step", () => {
it("shows OAuth providers with Login button", async () => { it("shows OAuth providers with Login button", async () => {
render(<ModelOnboardingModal onComplete={vi.fn()} addToast={vi.fn()} />); render(<ModelOnboardingModal onComplete={vi.fn()} addToast={vi.fn()} />);

View File

@@ -9,7 +9,9 @@ import {
isOnboardingCompleted, isOnboardingCompleted,
getOnboardingCompletedAt, getOnboardingCompletedAt,
markStepCompleted, markStepCompleted,
markStepSkipped,
getCompletedSteps, getCompletedSteps,
getSkippedSteps,
getStepData, getStepData,
ONBOARDING_STEP_LABELS, ONBOARDING_STEP_LABELS,
} from "../model-onboarding-state"; } from "../model-onboarding-state";
@@ -72,6 +74,7 @@ describe("model-onboarding-state", () => {
currentStep: "ai-setup", currentStep: "ai-setup",
updatedAt: "2024-01-01T00:00:00.000Z", updatedAt: "2024-01-01T00:00:00.000Z",
completedSteps: [], completedSteps: [],
skippedSteps: [],
dismissed: false, dismissed: false,
completed: false, completed: false,
stepData: {}, stepData: {},
@@ -86,6 +89,7 @@ describe("model-onboarding-state", () => {
currentStep: "unknown-step", currentStep: "unknown-step",
updatedAt: "2024-01-01T00:00:00.000Z", updatedAt: "2024-01-01T00:00:00.000Z",
completedSteps: [], completedSteps: [],
skippedSteps: [],
dismissed: false, dismissed: false,
completed: false, completed: false,
stepData: {}, stepData: {},
@@ -114,6 +118,7 @@ describe("model-onboarding-state", () => {
currentStep: "github", currentStep: "github",
updatedAt: "2024-01-01T00:00:00.000Z", updatedAt: "2024-01-01T00:00:00.000Z",
completedSteps: [], completedSteps: [],
skippedSteps: [],
dismissed: false, dismissed: false,
completed: false, completed: false,
stepData: {}, stepData: {},
@@ -125,6 +130,7 @@ describe("model-onboarding-state", () => {
currentStep: "first-task" as const, currentStep: "first-task" as const,
updatedAt: "2024-01-01T00:00:00.000Z", updatedAt: "2024-01-01T00:00:00.000Z",
completedSteps: ["ai-setup", "github"] as const, completedSteps: ["ai-setup", "github"] as const,
skippedSteps: [],
dismissed: true, dismissed: true,
completed: false, completed: false,
stepData: { "ai-setup": { someData: "value" } }, stepData: { "ai-setup": { someData: "value" } },
@@ -231,6 +237,7 @@ describe("model-onboarding-state", () => {
const parsed = JSON.parse(stored); const parsed = JSON.parse(stored);
expect(parsed.currentStep).toBe("ai-setup"); expect(parsed.currentStep).toBe("ai-setup");
expect(parsed.completedSteps).toEqual(["ai-setup"]); expect(parsed.completedSteps).toEqual(["ai-setup"]);
expect(parsed.skippedSteps).toEqual([]);
expect(parsed.dismissed).toBe(false); expect(parsed.dismissed).toBe(false);
expect(parsed.completed).toBe(false); expect(parsed.completed).toBe(false);
}); });
@@ -258,6 +265,27 @@ describe("model-onboarding-state", () => {
const parsed = JSON.parse(stored); const parsed = JSON.parse(stored);
expect(parsed.completedSteps).toEqual(["ai-setup", "github", "first-task"]); expect(parsed.completedSteps).toEqual(["ai-setup", "github", "first-task"]);
}); });
it("markStepCompleted removes step from skippedSteps", () => {
markStepSkipped("ai-setup");
markStepCompleted("ai-setup");
const state = getOnboardingState();
expect(state?.completedSteps).toContain("ai-setup");
expect(state?.skippedSteps).not.toContain("ai-setup");
});
});
describe("markStepSkipped", () => {
it("markStepSkipped adds step to skippedSteps", () => {
markStepSkipped("ai-setup");
expect(getOnboardingState()?.skippedSteps).toContain("ai-setup");
});
it("markStepSkipped does not add to completedSteps", () => {
markStepSkipped("ai-setup");
expect(getOnboardingState()?.completedSteps).not.toContain("ai-setup");
});
}); });
describe("clearOnboardingState", () => { describe("clearOnboardingState", () => {
@@ -266,6 +294,7 @@ describe("model-onboarding-state", () => {
currentStep: "ai-setup" as const, currentStep: "ai-setup" as const,
updatedAt: "2024-01-01T00:00:00.000Z", updatedAt: "2024-01-01T00:00:00.000Z",
completedSteps: ["ai-setup"], completedSteps: ["ai-setup"],
skippedSteps: [],
dismissed: false, dismissed: false,
completed: false, completed: false,
stepData: {}, stepData: {},
@@ -280,6 +309,7 @@ describe("model-onboarding-state", () => {
currentStep: "github" as const, currentStep: "github" as const,
updatedAt: "2024-01-01T00:00:00.000Z", updatedAt: "2024-01-01T00:00:00.000Z",
completedSteps: ["ai-setup", "github"] as const, completedSteps: ["ai-setup", "github"] as const,
skippedSteps: [],
dismissed: true, dismissed: true,
completed: false, completed: false,
stepData: { "ai-setup": { someData: "value" } }, stepData: { "ai-setup": { someData: "value" } },
@@ -314,6 +344,7 @@ describe("model-onboarding-state", () => {
currentStep: "ai-setup" as const, currentStep: "ai-setup" as const,
updatedAt: "2024-01-01T00:00:00.000Z", updatedAt: "2024-01-01T00:00:00.000Z",
completedSteps: ["ai-setup"], completedSteps: ["ai-setup"],
skippedSteps: [],
dismissed: false, dismissed: false,
completed: false, completed: false,
stepData: {}, stepData: {},
@@ -336,6 +367,7 @@ describe("model-onboarding-state", () => {
currentStep: "first-task" as const, currentStep: "first-task" as const,
updatedAt: "2024-01-01T00:00:00.000Z", updatedAt: "2024-01-01T00:00:00.000Z",
completedSteps: [], completedSteps: [],
skippedSteps: [],
dismissed: false, dismissed: false,
completed: false, completed: false,
stepData: {}, stepData: {},
@@ -349,6 +381,7 @@ describe("model-onboarding-state", () => {
currentStep: "complete" as const, currentStep: "complete" as const,
updatedAt: "2024-01-01T00:00:00.000Z", updatedAt: "2024-01-01T00:00:00.000Z",
completedSteps: ["ai-setup", "github", "first-task"], completedSteps: ["ai-setup", "github", "first-task"],
skippedSteps: [],
dismissed: false, dismissed: false,
completed: true, completed: true,
stepData: {}, stepData: {},
@@ -363,6 +396,7 @@ describe("model-onboarding-state", () => {
updatedAt: "2024-01-01T00:00:00.000Z", updatedAt: "2024-01-01T00:00:00.000Z",
completedAt: "2024-01-02T00:00:00.000Z", completedAt: "2024-01-02T00:00:00.000Z",
completedSteps: [], completedSteps: [],
skippedSteps: [],
dismissed: false, dismissed: false,
completed: false, completed: false,
stepData: {}, stepData: {},
@@ -377,6 +411,7 @@ describe("model-onboarding-state", () => {
updatedAt: "2024-01-01T00:00:00.000Z", updatedAt: "2024-01-01T00:00:00.000Z",
completedAt: "2024-01-02T00:00:00.000Z", completedAt: "2024-01-02T00:00:00.000Z",
completedSteps: [], completedSteps: [],
skippedSteps: [],
dismissed: false, dismissed: false,
completed: false, completed: false,
stepData: {}, stepData: {},
@@ -390,6 +425,7 @@ describe("model-onboarding-state", () => {
currentStep: "first-task" as const, currentStep: "first-task" as const,
updatedAt: "2024-01-01T00:00:00.000Z", updatedAt: "2024-01-01T00:00:00.000Z",
completedSteps: [], completedSteps: [],
skippedSteps: [],
dismissed: false, dismissed: false,
stepData: {}, stepData: {},
// Note: no completed field // Note: no completed field
@@ -404,6 +440,7 @@ describe("model-onboarding-state", () => {
updatedAt: "2024-01-01T00:00:00.000Z", updatedAt: "2024-01-01T00:00:00.000Z",
completedAt: "", completedAt: "",
completedSteps: [], completedSteps: [],
skippedSteps: [],
dismissed: false, dismissed: false,
completed: false, completed: false,
stepData: {}, stepData: {},
@@ -437,6 +474,26 @@ describe("model-onboarding-state", () => {
}); });
}); });
describe("getSkippedSteps", () => {
it("getSkippedSteps returns persisted skipped steps", () => {
saveOnboardingState("github", { skippedSteps: ["ai-setup"] });
expect(getSkippedSteps()).toEqual(["ai-setup"]);
});
it("skippedSteps defaults to empty array for legacy state", () => {
const legacyState = {
currentStep: "github" as const,
updatedAt: "2024-01-01T00:00:00.000Z",
completedSteps: ["ai-setup"],
dismissed: false,
completed: false,
stepData: {},
};
mockStore[STORAGE_KEY] = JSON.stringify(legacyState);
expect(getSkippedSteps()).toEqual([]);
});
});
describe("getStepData", () => { describe("getStepData", () => {
it("returns null when no state exists", () => { it("returns null when no state exists", () => {
expect(getStepData("ai-setup")).toBeNull(); expect(getStepData("ai-setup")).toBeNull();
@@ -459,6 +516,7 @@ describe("model-onboarding-state", () => {
currentStep: "first-task" as const, currentStep: "first-task" as const,
updatedAt: "2024-01-01T00:00:00.000Z", updatedAt: "2024-01-01T00:00:00.000Z",
completedSteps: [], completedSteps: [],
skippedSteps: [],
dismissed: false, dismissed: false,
completed: false, completed: false,
// Note: no stepData field // Note: no stepData field
@@ -478,6 +536,7 @@ describe("model-onboarding-state", () => {
currentStep: "complete" as const, currentStep: "complete" as const,
updatedAt: "2024-01-01T00:00:00.000Z", updatedAt: "2024-01-01T00:00:00.000Z",
completedSteps: [], completedSteps: [],
skippedSteps: [],
dismissed: false, dismissed: false,
completed: false, completed: false,
stepData: {}, stepData: {},
@@ -507,6 +566,7 @@ describe("model-onboarding-state", () => {
currentStep: "first-task" as const, currentStep: "first-task" as const,
updatedAt: "2024-01-01T00:00:00.000Z", updatedAt: "2024-01-01T00:00:00.000Z",
completedSteps: ["ai-setup", "github"], completedSteps: ["ai-setup", "github"],
skippedSteps: [],
dismissed: false, dismissed: false,
completed: true, completed: true,
stepData: {}, stepData: {},
@@ -521,6 +581,7 @@ describe("model-onboarding-state", () => {
updatedAt: "2024-01-01T00:00:00.000Z", updatedAt: "2024-01-01T00:00:00.000Z",
completedAt: "2024-01-02T00:00:00.000Z", completedAt: "2024-01-02T00:00:00.000Z",
completedSteps: [], completedSteps: [],
skippedSteps: [],
dismissed: false, dismissed: false,
completed: false, completed: false,
stepData: {}, stepData: {},
@@ -534,6 +595,7 @@ describe("model-onboarding-state", () => {
currentStep: "github" as const, currentStep: "github" as const,
updatedAt: "2024-01-01T00:00:00.000Z", updatedAt: "2024-01-01T00:00:00.000Z",
completedSteps: ["ai-setup"], completedSteps: ["ai-setup"],
skippedSteps: [],
dismissed: true, dismissed: true,
completed: false, completed: false,
stepData: {}, stepData: {},
@@ -554,6 +616,7 @@ describe("model-onboarding-state", () => {
currentStep: "complete" as const, currentStep: "complete" as const,
updatedAt: "2024-01-01T00:00:00.000Z", updatedAt: "2024-01-01T00:00:00.000Z",
completedSteps: [], completedSteps: [],
skippedSteps: [],
dismissed: false, dismissed: false,
completed: false, completed: false,
stepData: {}, stepData: {},
@@ -616,6 +679,7 @@ describe("model-onboarding-state", () => {
currentStep: "first-task" as const, currentStep: "first-task" as const,
updatedAt: "2024-01-01T00:00:00.000Z", updatedAt: "2024-01-01T00:00:00.000Z",
completedSteps: ["ai-setup", "github"], completedSteps: ["ai-setup", "github"],
skippedSteps: [],
dismissed: false, dismissed: false,
completed: true, completed: true,
stepData: {}, stepData: {},
@@ -630,6 +694,7 @@ describe("model-onboarding-state", () => {
updatedAt: "2024-01-01T00:00:00.000Z", updatedAt: "2024-01-01T00:00:00.000Z",
completedAt: "2024-01-02T00:00:00.000Z", completedAt: "2024-01-02T00:00:00.000Z",
completedSteps: [], completedSteps: [],
skippedSteps: [],
dismissed: false, dismissed: false,
completed: false, completed: false,
stepData: {}, stepData: {},
@@ -643,6 +708,7 @@ describe("model-onboarding-state", () => {
currentStep: "custom-step" as const, currentStep: "custom-step" as const,
updatedAt: "2024-01-01T00:00:00.000Z", updatedAt: "2024-01-01T00:00:00.000Z",
completedSteps: [], completedSteps: [],
skippedSteps: [],
dismissed: false, dismissed: false,
completed: false, completed: false,
stepData: {}, stepData: {},
@@ -661,6 +727,7 @@ describe("model-onboarding-state", () => {
currentStep: "my-custom-step" as const, currentStep: "my-custom-step" as const,
updatedAt: "2024-01-01T00:00:00.000Z", updatedAt: "2024-01-01T00:00:00.000Z",
completedSteps: [], completedSteps: [],
skippedSteps: [],
dismissed: false, dismissed: false,
completed: false, completed: false,
stepData: {}, stepData: {},
@@ -675,6 +742,7 @@ describe("model-onboarding-state", () => {
currentStep: "my_custom_step" as const, currentStep: "my_custom_step" as const,
updatedAt: "2024-01-01T00:00:00.000Z", updatedAt: "2024-01-01T00:00:00.000Z",
completedSteps: [], completedSteps: [],
skippedSteps: [],
dismissed: false, dismissed: false,
completed: false, completed: false,
stepData: {}, stepData: {},
@@ -752,6 +820,7 @@ describe("model-onboarding-state", () => {
updatedAt: "2024-01-01T00:00:00.000Z", updatedAt: "2024-01-01T00:00:00.000Z",
completedAt: timestamp, completedAt: timestamp,
completedSteps: [], completedSteps: [],
skippedSteps: [],
dismissed: false, dismissed: false,
completed: true, completed: true,
stepData: {}, stepData: {},
@@ -765,6 +834,7 @@ describe("model-onboarding-state", () => {
currentStep: "first-task" as const, currentStep: "first-task" as const,
updatedAt: "2024-01-01T00:00:00.000Z", updatedAt: "2024-01-01T00:00:00.000Z",
completedSteps: [], completedSteps: [],
skippedSteps: [],
dismissed: false, dismissed: false,
completed: false, completed: false,
stepData: {}, stepData: {},
@@ -788,6 +858,7 @@ describe("model-onboarding-state", () => {
currentStep: "github", currentStep: "github",
updatedAt: "2024-01-01T00:00:00.000Z", updatedAt: "2024-01-01T00:00:00.000Z",
completedSteps: [], completedSteps: [],
skippedSteps: [],
dismissed: false, dismissed: false,
completed: false, completed: false,
stepData: {}, stepData: {},

View File

@@ -12,6 +12,8 @@ interface OnboardingState {
updatedAt: string; // ISO-8601 timestamp updatedAt: string; // ISO-8601 timestamp
/** Steps that have been completed (visited and passed) */ /** Steps that have been completed (visited and passed) */
completedSteps: OnboardingStep[]; completedSteps: OnboardingStep[];
/** Steps that were intentionally skipped and can be completed later */
skippedSteps: OnboardingStep[];
/** Whether the user explicitly dismissed the modal without finishing */ /** Whether the user explicitly dismissed the modal without finishing */
dismissed: boolean; dismissed: boolean;
/** Whether the user finished all steps and completed onboarding */ /** Whether the user finished all steps and completed onboarding */
@@ -28,6 +30,7 @@ const STORAGE_KEY = "fusion_model_onboarding_state";
* Default values for backward compatibility with partial state objects * Default values for backward compatibility with partial state objects
*/ */
const DEFAULT_COMPLETED_STEPS: OnboardingStep[] = []; const DEFAULT_COMPLETED_STEPS: OnboardingStep[] = [];
const DEFAULT_SKIPPED_STEPS: OnboardingStep[] = [];
const DEFAULT_DISMISSED = false; const DEFAULT_DISMISSED = false;
const DEFAULT_COMPLETED = false; const DEFAULT_COMPLETED = false;
const DEFAULT_STEP_DATA: Partial<Record<OnboardingStep, Record<string, unknown>>> = {}; const DEFAULT_STEP_DATA: Partial<Record<OnboardingStep, Record<string, unknown>>> = {};
@@ -79,6 +82,7 @@ function applyStateDefaults(state: OnboardingState): OnboardingState {
return { return {
...state, ...state,
completedSteps: state.completedSteps ?? DEFAULT_COMPLETED_STEPS, completedSteps: state.completedSteps ?? DEFAULT_COMPLETED_STEPS,
skippedSteps: state.skippedSteps ?? DEFAULT_SKIPPED_STEPS,
dismissed: state.dismissed ?? DEFAULT_DISMISSED, dismissed: state.dismissed ?? DEFAULT_DISMISSED,
completed: state.completed ?? DEFAULT_COMPLETED, completed: state.completed ?? DEFAULT_COMPLETED,
stepData: state.stepData ?? DEFAULT_STEP_DATA, stepData: state.stepData ?? DEFAULT_STEP_DATA,
@@ -91,6 +95,7 @@ function applyStateDefaults(state: OnboardingState): OnboardingState {
* @param step - The current step (known OnboardingStep or unknown string for future steps) * @param step - The current step (known OnboardingStep or unknown string for future steps)
* @param options - Optional rich payload for extended state tracking * @param options - Optional rich payload for extended state tracking
* @param options.completedSteps - Array of steps that have been completed (for resume functionality) * @param options.completedSteps - Array of steps that have been completed (for resume functionality)
* @param options.skippedSteps - Array of steps intentionally skipped (for resume functionality)
* @param options.dismissed - Whether the user explicitly dismissed without finishing * @param options.dismissed - Whether the user explicitly dismissed without finishing
* @param options.completed - Whether the user finished all steps * @param options.completed - Whether the user finished all steps
* @param options.stepData - Per-step data for restoring UI state on reopen * @param options.stepData - Per-step data for restoring UI state on reopen
@@ -99,6 +104,7 @@ export function saveOnboardingState(
step: OnboardingStep | string, step: OnboardingStep | string,
options?: { options?: {
completedSteps?: OnboardingStep[]; completedSteps?: OnboardingStep[];
skippedSteps?: OnboardingStep[];
dismissed?: boolean; dismissed?: boolean;
completed?: boolean; completed?: boolean;
stepData?: Partial<Record<OnboardingStep, Record<string, unknown>>>; stepData?: Partial<Record<OnboardingStep, Record<string, unknown>>>;
@@ -112,6 +118,7 @@ export function saveOnboardingState(
currentStep: step, currentStep: step,
updatedAt: new Date().toISOString(), updatedAt: new Date().toISOString(),
completedSteps: DEFAULT_COMPLETED_STEPS, completedSteps: DEFAULT_COMPLETED_STEPS,
skippedSteps: DEFAULT_SKIPPED_STEPS,
dismissed: DEFAULT_DISMISSED, dismissed: DEFAULT_DISMISSED,
completed: DEFAULT_COMPLETED, completed: DEFAULT_COMPLETED,
stepData: DEFAULT_STEP_DATA, stepData: DEFAULT_STEP_DATA,
@@ -141,6 +148,7 @@ export function saveOnboardingState(
// Merge completedSteps // Merge completedSteps
const completedSteps = options.completedSteps ?? existing?.completedSteps ?? DEFAULT_COMPLETED_STEPS; const completedSteps = options.completedSteps ?? existing?.completedSteps ?? DEFAULT_COMPLETED_STEPS;
const skippedSteps = options.skippedSteps ?? existing?.skippedSteps ?? DEFAULT_SKIPPED_STEPS;
// Merge stepData per-step key // Merge stepData per-step key
const stepData: Partial<Record<OnboardingStep, Record<string, unknown>>> = { const stepData: Partial<Record<OnboardingStep, Record<string, unknown>>> = {
@@ -161,6 +169,7 @@ export function saveOnboardingState(
currentStep: step, currentStep: step,
updatedAt: now, updatedAt: now,
completedSteps, completedSteps,
skippedSteps,
dismissed, dismissed,
completed, completed,
stepData, stepData,
@@ -191,6 +200,7 @@ export function markStepCompleted(step: OnboardingStep): void {
currentStep: step, currentStep: step,
updatedAt: now, updatedAt: now,
completedSteps: [step], completedSteps: [step],
skippedSteps: DEFAULT_SKIPPED_STEPS,
dismissed: DEFAULT_DISMISSED, dismissed: DEFAULT_DISMISSED,
completed: DEFAULT_COMPLETED, completed: DEFAULT_COMPLETED,
stepData: DEFAULT_STEP_DATA, stepData: DEFAULT_STEP_DATA,
@@ -207,6 +217,51 @@ export function markStepCompleted(step: OnboardingStep): void {
const state: OnboardingState = { const state: OnboardingState = {
...existing, ...existing,
completedSteps, completedSteps,
skippedSteps: existing.skippedSteps.filter((s) => s !== step),
updatedAt: now,
};
localStorage.setItem(STORAGE_KEY, JSON.stringify(state));
} catch {
// Storage quota exceeded or private browsing - fail silently
}
}
/**
* Mark a specific step as skipped.
* Reads current state, adds step to skippedSteps (deduped), and saves.
* If no state exists, initializes a fresh state with skippedSteps containing only this step.
* @param step - The step to mark as skipped
*/
export function markStepSkipped(step: OnboardingStep): void {
if (typeof window === "undefined") return;
try {
const existing = getOnboardingState();
const now = new Date().toISOString();
if (!existing) {
const state: OnboardingState = {
currentStep: step,
updatedAt: now,
completedSteps: DEFAULT_COMPLETED_STEPS,
skippedSteps: [step],
dismissed: DEFAULT_DISMISSED,
completed: DEFAULT_COMPLETED,
stepData: DEFAULT_STEP_DATA,
};
localStorage.setItem(STORAGE_KEY, JSON.stringify(state));
return;
}
const skippedSteps = existing.skippedSteps.includes(step)
? existing.skippedSteps
: [...existing.skippedSteps, step];
const state: OnboardingState = {
...existing,
skippedSteps,
completedSteps: existing.completedSteps.filter((s) => s !== step),
updatedAt: now, updatedAt: now,
}; };
@@ -242,6 +297,7 @@ export function clearOnboardingState(options?: { preserveProgress?: boolean }):
currentStep: "complete", currentStep: "complete",
updatedAt: now, updatedAt: now,
completedSteps: DEFAULT_COMPLETED_STEPS, completedSteps: DEFAULT_COMPLETED_STEPS,
skippedSteps: DEFAULT_SKIPPED_STEPS,
dismissed: false, dismissed: false,
completed: true, completed: true,
stepData: DEFAULT_STEP_DATA, stepData: DEFAULT_STEP_DATA,
@@ -285,6 +341,7 @@ export function markOnboardingCompleted(): void {
updatedAt: now, updatedAt: now,
completedAt: now, completedAt: now,
completedSteps: DEFAULT_COMPLETED_STEPS, completedSteps: DEFAULT_COMPLETED_STEPS,
skippedSteps: DEFAULT_SKIPPED_STEPS,
dismissed: false, dismissed: false,
completed: true, completed: true,
stepData: DEFAULT_STEP_DATA, stepData: DEFAULT_STEP_DATA,
@@ -377,6 +434,16 @@ export function getCompletedSteps(): OnboardingStep[] {
return state.completedSteps; return state.completedSteps;
} }
/**
* Get the list of skipped steps from stored state.
* Returns empty array if no state exists or skippedSteps is missing.
*/
export function getSkippedSteps(): OnboardingStep[] {
const state = getOnboardingState();
if (!state) return DEFAULT_SKIPPED_STEPS;
return state.skippedSteps;
}
/** /**
* Get stored per-step data for a specific step. * Get stored per-step data for a specific step.
* Returns null if no state exists or the step has no data. * Returns null if no state exists or the step has no data.

View File

@@ -23742,6 +23742,11 @@ html .column.drag-over * {
border-radius: var(--radius-sm); border-radius: var(--radius-sm);
} }
.model-onboarding-step-indicator.skipped {
opacity: 0.7;
border-color: var(--color-muted);
}
.model-onboarding-step-indicator .step-number { .model-onboarding-step-indicator .step-number {
width: 24px; width: 24px;
height: 24px; height: 24px;
@@ -23758,6 +23763,12 @@ html .column.drag-over * {
font-weight: 500; font-weight: 500;
} }
.onboarding-step-skip-mark {
font-size: var(--space-lg);
color: var(--text-muted);
line-height: 1;
}
.model-onboarding-step-connector { .model-onboarding-step-connector {
width: 40px; width: 40px;
height: 2px; height: 2px;