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:
@@ -353,6 +353,8 @@ import {
|
||||
saveOnboardingState,
|
||||
clearOnboardingState,
|
||||
markOnboardingCompleted,
|
||||
markStepSkipped,
|
||||
getSkippedSteps,
|
||||
getStepData,
|
||||
type OnboardingStep,
|
||||
} from "./model-onboarding-state";
|
||||
@@ -400,12 +402,14 @@ export function ModelOnboardingModal({
|
||||
const initialStep: OnboardingStep = persistedState && persistedState.currentStep !== "complete"
|
||||
? persistedState.currentStep as OnboardingStep
|
||||
: "ai-setup";
|
||||
// Restore completed steps from persisted state
|
||||
// Restore completed/skipped steps from persisted state
|
||||
const persistedCompletedSteps = persistedState?.completedSteps ?? [];
|
||||
const persistedSkippedSteps = persistedState?.skippedSteps ?? getSkippedSteps();
|
||||
|
||||
const [isOpen, setIsOpen] = useState(true);
|
||||
const [step, setStep] = useState<OnboardingStep>(initialStep);
|
||||
const [completedSteps, setCompletedSteps] = useState<OnboardingStep[]>(persistedCompletedSteps);
|
||||
const [skippedSteps, setSkippedSteps] = useState<OnboardingStep[]>(persistedSkippedSteps);
|
||||
const [authProviders, setAuthProviders] = useState<AuthProvider[]>([]);
|
||||
const [authLoading, setAuthLoading] = useState(true);
|
||||
const [authActionInProgress, setAuthActionInProgress] = useState<string | null>(null);
|
||||
@@ -446,9 +450,9 @@ export function ModelOnboardingModal({
|
||||
// Persist step state whenever it changes (for resume functionality)
|
||||
useEffect(() => {
|
||||
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
|
||||
// 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) => {
|
||||
setIsGithubSkipped(skipped);
|
||||
saveOnboardingState(step, {
|
||||
@@ -708,12 +689,61 @@ export function ModelOnboardingModal({
|
||||
});
|
||||
}, [step, completedSteps]);
|
||||
|
||||
const handleSkipGitHubStep = useCallback(() => {
|
||||
if (!isGithubAuthenticated) {
|
||||
// Navigate to next step
|
||||
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);
|
||||
}
|
||||
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
|
||||
const handleLogin = useCallback(
|
||||
@@ -1192,8 +1222,9 @@ export function ModelOnboardingModal({
|
||||
{steps.map((s, index) => {
|
||||
// A step is done if it's in completedSteps AND is before current position
|
||||
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 isClickable = isDone;
|
||||
const isSkipped = skippedSteps.includes(s.key) && !completedSteps.includes(s.key) && currentStepIndex > index;
|
||||
// Clickable if it's a completed/skipped step (can review)
|
||||
const isClickable = isDone || isSkipped;
|
||||
return (
|
||||
<div key={s.key} className="onboarding-step-wrapper">
|
||||
{index > 0 && (
|
||||
@@ -1207,7 +1238,7 @@ export function ModelOnboardingModal({
|
||||
<button
|
||||
className={`model-onboarding-step-indicator ${
|
||||
step === s.key ? "active" : ""
|
||||
} ${isDone ? "done" : ""}`}
|
||||
} ${isDone ? "done" : ""} ${isSkipped ? "skipped" : ""}`}
|
||||
onClick={() => setStep(s.key)}
|
||||
aria-label={`Go back to ${s.label}`}
|
||||
title={`Review ${s.label}`}
|
||||
@@ -1215,6 +1246,8 @@ export function ModelOnboardingModal({
|
||||
<span className="step-number">
|
||||
{isDone ? (
|
||||
<CheckCircle size={14} />
|
||||
) : isSkipped ? (
|
||||
<span className="onboarding-step-skip-mark">–</span>
|
||||
) : (
|
||||
index + 1
|
||||
)}
|
||||
@@ -1225,11 +1258,13 @@ export function ModelOnboardingModal({
|
||||
<div
|
||||
className={`model-onboarding-step-indicator ${
|
||||
step === s.key ? "active" : ""
|
||||
} ${isDone ? "done" : ""}`}
|
||||
} ${isDone ? "done" : ""} ${isSkipped ? "skipped" : ""}`}
|
||||
>
|
||||
<span className="step-number">
|
||||
{isDone ? (
|
||||
<CheckCircle size={14} />
|
||||
) : isSkipped ? (
|
||||
<span className="onboarding-step-skip-mark">–</span>
|
||||
) : (
|
||||
index + 1
|
||||
)}
|
||||
@@ -1751,7 +1786,7 @@ export function ModelOnboardingModal({
|
||||
>
|
||||
Skip for now
|
||||
</button>
|
||||
<button className="onboarding-skip-step-link" onClick={handleNext}>
|
||||
<button className="onboarding-skip-step-link" onClick={handleSkip}>
|
||||
Skip setup →
|
||||
</button>
|
||||
<button className="btn btn-primary" onClick={handleNext}>
|
||||
@@ -1765,7 +1800,7 @@ export function ModelOnboardingModal({
|
||||
<button className="btn btn-sm" onClick={handleBack}>
|
||||
← Back
|
||||
</button>
|
||||
<button className="onboarding-skip-step-link" onClick={handleSkipGitHubStep}>
|
||||
<button className="onboarding-skip-step-link" onClick={handleSkip}>
|
||||
Skip GitHub →
|
||||
</button>
|
||||
<button className="btn btn-primary" onClick={handleNext}>
|
||||
|
||||
@@ -107,7 +107,9 @@ const mockSaveOnboardingState = vi.fn();
|
||||
const mockClearOnboardingState = vi.fn();
|
||||
const mockIsOnboardingCompleted = vi.fn();
|
||||
const mockMarkOnboardingCompleted = vi.fn();
|
||||
const mockMarkStepSkipped = vi.fn();
|
||||
const mockGetOnboardingCompletedAt = vi.fn();
|
||||
const mockGetSkippedSteps = vi.fn();
|
||||
const mockGetStepData = vi.fn();
|
||||
|
||||
vi.mock("../../components/model-onboarding-state", () => ({
|
||||
@@ -118,7 +120,9 @@ vi.mock("../../components/model-onboarding-state", () => ({
|
||||
clearOnboardingState: (...args: unknown[]) => mockClearOnboardingState(...args),
|
||||
isOnboardingCompleted: (...args: unknown[]) => mockIsOnboardingCompleted(...args),
|
||||
markOnboardingCompleted: (...args: unknown[]) => mockMarkOnboardingCompleted(...args),
|
||||
markStepSkipped: (...args: unknown[]) => mockMarkStepSkipped(...args),
|
||||
getOnboardingCompletedAt: (...args: unknown[]) => mockGetOnboardingCompletedAt(...args),
|
||||
getSkippedSteps: (...args: unknown[]) => mockGetSkippedSteps(...args),
|
||||
getStepData: (...args: unknown[]) => mockGetStepData(...args),
|
||||
}));
|
||||
|
||||
@@ -262,8 +266,13 @@ beforeEach(() => {
|
||||
mockIsOnboardingCompleted.mockReset();
|
||||
mockIsOnboardingCompleted.mockReturnValue(false);
|
||||
mockMarkOnboardingCompleted.mockReset();
|
||||
mockMarkStepSkipped.mockReset();
|
||||
mockGetOnboardingCompletedAt.mockReset();
|
||||
mockGetOnboardingCompletedAt.mockReturnValue(null);
|
||||
mockGetSkippedSteps.mockReset();
|
||||
mockGetSkippedSteps.mockReturnValue([]);
|
||||
mockGetStepData.mockReset();
|
||||
mockGetStepData.mockReturnValue(null);
|
||||
});
|
||||
|
||||
describe("App deep link handling", () => {
|
||||
|
||||
@@ -44,6 +44,8 @@ const mockGetOnboardingState = vi.fn();
|
||||
const mockSaveOnboardingState = vi.fn();
|
||||
const mockClearOnboardingState = vi.fn();
|
||||
const mockMarkOnboardingCompleted = vi.fn();
|
||||
const mockMarkStepSkipped = vi.fn();
|
||||
const mockGetSkippedSteps = vi.fn();
|
||||
const mockGetStepData = vi.fn();
|
||||
|
||||
vi.mock("../model-onboarding-state", () => ({
|
||||
@@ -51,6 +53,8 @@ vi.mock("../model-onboarding-state", () => ({
|
||||
saveOnboardingState: (...args: unknown[]) => mockSaveOnboardingState(...args),
|
||||
clearOnboardingState: (...args: unknown[]) => mockClearOnboardingState(...args),
|
||||
markOnboardingCompleted: (...args: unknown[]) => mockMarkOnboardingCompleted(...args),
|
||||
markStepSkipped: (...args: unknown[]) => mockMarkStepSkipped(...args),
|
||||
getSkippedSteps: (...args: unknown[]) => mockGetSkippedSteps(...args),
|
||||
getStepData: (...args: unknown[]) => mockGetStepData(...args),
|
||||
}));
|
||||
|
||||
@@ -123,6 +127,8 @@ beforeEach(() => {
|
||||
mockSaveOnboardingState.mockImplementation(() => {});
|
||||
mockClearOnboardingState.mockImplementation(() => {});
|
||||
mockMarkOnboardingCompleted.mockImplementation(() => {});
|
||||
mockMarkStepSkipped.mockImplementation(() => {});
|
||||
mockGetSkippedSteps.mockReturnValue([]);
|
||||
mockGetStepData.mockReturnValue(null);
|
||||
// Reset mockFetchAuthStatus to default - use mockImplementation for clear control
|
||||
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", () => {
|
||||
it("shows OAuth providers with Login button", async () => {
|
||||
render(<ModelOnboardingModal onComplete={vi.fn()} addToast={vi.fn()} />);
|
||||
|
||||
@@ -9,7 +9,9 @@ import {
|
||||
isOnboardingCompleted,
|
||||
getOnboardingCompletedAt,
|
||||
markStepCompleted,
|
||||
markStepSkipped,
|
||||
getCompletedSteps,
|
||||
getSkippedSteps,
|
||||
getStepData,
|
||||
ONBOARDING_STEP_LABELS,
|
||||
} from "../model-onboarding-state";
|
||||
@@ -72,6 +74,7 @@ describe("model-onboarding-state", () => {
|
||||
currentStep: "ai-setup",
|
||||
updatedAt: "2024-01-01T00:00:00.000Z",
|
||||
completedSteps: [],
|
||||
skippedSteps: [],
|
||||
dismissed: false,
|
||||
completed: false,
|
||||
stepData: {},
|
||||
@@ -86,6 +89,7 @@ describe("model-onboarding-state", () => {
|
||||
currentStep: "unknown-step",
|
||||
updatedAt: "2024-01-01T00:00:00.000Z",
|
||||
completedSteps: [],
|
||||
skippedSteps: [],
|
||||
dismissed: false,
|
||||
completed: false,
|
||||
stepData: {},
|
||||
@@ -114,6 +118,7 @@ describe("model-onboarding-state", () => {
|
||||
currentStep: "github",
|
||||
updatedAt: "2024-01-01T00:00:00.000Z",
|
||||
completedSteps: [],
|
||||
skippedSteps: [],
|
||||
dismissed: false,
|
||||
completed: false,
|
||||
stepData: {},
|
||||
@@ -125,6 +130,7 @@ describe("model-onboarding-state", () => {
|
||||
currentStep: "first-task" as const,
|
||||
updatedAt: "2024-01-01T00:00:00.000Z",
|
||||
completedSteps: ["ai-setup", "github"] as const,
|
||||
skippedSteps: [],
|
||||
dismissed: true,
|
||||
completed: false,
|
||||
stepData: { "ai-setup": { someData: "value" } },
|
||||
@@ -231,6 +237,7 @@ describe("model-onboarding-state", () => {
|
||||
const parsed = JSON.parse(stored);
|
||||
expect(parsed.currentStep).toBe("ai-setup");
|
||||
expect(parsed.completedSteps).toEqual(["ai-setup"]);
|
||||
expect(parsed.skippedSteps).toEqual([]);
|
||||
expect(parsed.dismissed).toBe(false);
|
||||
expect(parsed.completed).toBe(false);
|
||||
});
|
||||
@@ -258,6 +265,27 @@ describe("model-onboarding-state", () => {
|
||||
const parsed = JSON.parse(stored);
|
||||
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", () => {
|
||||
@@ -266,6 +294,7 @@ describe("model-onboarding-state", () => {
|
||||
currentStep: "ai-setup" as const,
|
||||
updatedAt: "2024-01-01T00:00:00.000Z",
|
||||
completedSteps: ["ai-setup"],
|
||||
skippedSteps: [],
|
||||
dismissed: false,
|
||||
completed: false,
|
||||
stepData: {},
|
||||
@@ -280,6 +309,7 @@ describe("model-onboarding-state", () => {
|
||||
currentStep: "github" as const,
|
||||
updatedAt: "2024-01-01T00:00:00.000Z",
|
||||
completedSteps: ["ai-setup", "github"] as const,
|
||||
skippedSteps: [],
|
||||
dismissed: true,
|
||||
completed: false,
|
||||
stepData: { "ai-setup": { someData: "value" } },
|
||||
@@ -314,6 +344,7 @@ describe("model-onboarding-state", () => {
|
||||
currentStep: "ai-setup" as const,
|
||||
updatedAt: "2024-01-01T00:00:00.000Z",
|
||||
completedSteps: ["ai-setup"],
|
||||
skippedSteps: [],
|
||||
dismissed: false,
|
||||
completed: false,
|
||||
stepData: {},
|
||||
@@ -336,6 +367,7 @@ describe("model-onboarding-state", () => {
|
||||
currentStep: "first-task" as const,
|
||||
updatedAt: "2024-01-01T00:00:00.000Z",
|
||||
completedSteps: [],
|
||||
skippedSteps: [],
|
||||
dismissed: false,
|
||||
completed: false,
|
||||
stepData: {},
|
||||
@@ -349,6 +381,7 @@ describe("model-onboarding-state", () => {
|
||||
currentStep: "complete" as const,
|
||||
updatedAt: "2024-01-01T00:00:00.000Z",
|
||||
completedSteps: ["ai-setup", "github", "first-task"],
|
||||
skippedSteps: [],
|
||||
dismissed: false,
|
||||
completed: true,
|
||||
stepData: {},
|
||||
@@ -363,6 +396,7 @@ describe("model-onboarding-state", () => {
|
||||
updatedAt: "2024-01-01T00:00:00.000Z",
|
||||
completedAt: "2024-01-02T00:00:00.000Z",
|
||||
completedSteps: [],
|
||||
skippedSteps: [],
|
||||
dismissed: false,
|
||||
completed: false,
|
||||
stepData: {},
|
||||
@@ -377,6 +411,7 @@ describe("model-onboarding-state", () => {
|
||||
updatedAt: "2024-01-01T00:00:00.000Z",
|
||||
completedAt: "2024-01-02T00:00:00.000Z",
|
||||
completedSteps: [],
|
||||
skippedSteps: [],
|
||||
dismissed: false,
|
||||
completed: false,
|
||||
stepData: {},
|
||||
@@ -390,6 +425,7 @@ describe("model-onboarding-state", () => {
|
||||
currentStep: "first-task" as const,
|
||||
updatedAt: "2024-01-01T00:00:00.000Z",
|
||||
completedSteps: [],
|
||||
skippedSteps: [],
|
||||
dismissed: false,
|
||||
stepData: {},
|
||||
// Note: no completed field
|
||||
@@ -404,6 +440,7 @@ describe("model-onboarding-state", () => {
|
||||
updatedAt: "2024-01-01T00:00:00.000Z",
|
||||
completedAt: "",
|
||||
completedSteps: [],
|
||||
skippedSteps: [],
|
||||
dismissed: false,
|
||||
completed: false,
|
||||
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", () => {
|
||||
it("returns null when no state exists", () => {
|
||||
expect(getStepData("ai-setup")).toBeNull();
|
||||
@@ -459,6 +516,7 @@ describe("model-onboarding-state", () => {
|
||||
currentStep: "first-task" as const,
|
||||
updatedAt: "2024-01-01T00:00:00.000Z",
|
||||
completedSteps: [],
|
||||
skippedSteps: [],
|
||||
dismissed: false,
|
||||
completed: false,
|
||||
// Note: no stepData field
|
||||
@@ -478,6 +536,7 @@ describe("model-onboarding-state", () => {
|
||||
currentStep: "complete" as const,
|
||||
updatedAt: "2024-01-01T00:00:00.000Z",
|
||||
completedSteps: [],
|
||||
skippedSteps: [],
|
||||
dismissed: false,
|
||||
completed: false,
|
||||
stepData: {},
|
||||
@@ -507,6 +566,7 @@ describe("model-onboarding-state", () => {
|
||||
currentStep: "first-task" as const,
|
||||
updatedAt: "2024-01-01T00:00:00.000Z",
|
||||
completedSteps: ["ai-setup", "github"],
|
||||
skippedSteps: [],
|
||||
dismissed: false,
|
||||
completed: true,
|
||||
stepData: {},
|
||||
@@ -521,6 +581,7 @@ describe("model-onboarding-state", () => {
|
||||
updatedAt: "2024-01-01T00:00:00.000Z",
|
||||
completedAt: "2024-01-02T00:00:00.000Z",
|
||||
completedSteps: [],
|
||||
skippedSteps: [],
|
||||
dismissed: false,
|
||||
completed: false,
|
||||
stepData: {},
|
||||
@@ -534,6 +595,7 @@ describe("model-onboarding-state", () => {
|
||||
currentStep: "github" as const,
|
||||
updatedAt: "2024-01-01T00:00:00.000Z",
|
||||
completedSteps: ["ai-setup"],
|
||||
skippedSteps: [],
|
||||
dismissed: true,
|
||||
completed: false,
|
||||
stepData: {},
|
||||
@@ -554,6 +616,7 @@ describe("model-onboarding-state", () => {
|
||||
currentStep: "complete" as const,
|
||||
updatedAt: "2024-01-01T00:00:00.000Z",
|
||||
completedSteps: [],
|
||||
skippedSteps: [],
|
||||
dismissed: false,
|
||||
completed: false,
|
||||
stepData: {},
|
||||
@@ -616,6 +679,7 @@ describe("model-onboarding-state", () => {
|
||||
currentStep: "first-task" as const,
|
||||
updatedAt: "2024-01-01T00:00:00.000Z",
|
||||
completedSteps: ["ai-setup", "github"],
|
||||
skippedSteps: [],
|
||||
dismissed: false,
|
||||
completed: true,
|
||||
stepData: {},
|
||||
@@ -630,6 +694,7 @@ describe("model-onboarding-state", () => {
|
||||
updatedAt: "2024-01-01T00:00:00.000Z",
|
||||
completedAt: "2024-01-02T00:00:00.000Z",
|
||||
completedSteps: [],
|
||||
skippedSteps: [],
|
||||
dismissed: false,
|
||||
completed: false,
|
||||
stepData: {},
|
||||
@@ -643,6 +708,7 @@ describe("model-onboarding-state", () => {
|
||||
currentStep: "custom-step" as const,
|
||||
updatedAt: "2024-01-01T00:00:00.000Z",
|
||||
completedSteps: [],
|
||||
skippedSteps: [],
|
||||
dismissed: false,
|
||||
completed: false,
|
||||
stepData: {},
|
||||
@@ -661,6 +727,7 @@ describe("model-onboarding-state", () => {
|
||||
currentStep: "my-custom-step" as const,
|
||||
updatedAt: "2024-01-01T00:00:00.000Z",
|
||||
completedSteps: [],
|
||||
skippedSteps: [],
|
||||
dismissed: false,
|
||||
completed: false,
|
||||
stepData: {},
|
||||
@@ -675,6 +742,7 @@ describe("model-onboarding-state", () => {
|
||||
currentStep: "my_custom_step" as const,
|
||||
updatedAt: "2024-01-01T00:00:00.000Z",
|
||||
completedSteps: [],
|
||||
skippedSteps: [],
|
||||
dismissed: false,
|
||||
completed: false,
|
||||
stepData: {},
|
||||
@@ -752,6 +820,7 @@ describe("model-onboarding-state", () => {
|
||||
updatedAt: "2024-01-01T00:00:00.000Z",
|
||||
completedAt: timestamp,
|
||||
completedSteps: [],
|
||||
skippedSteps: [],
|
||||
dismissed: false,
|
||||
completed: true,
|
||||
stepData: {},
|
||||
@@ -765,6 +834,7 @@ describe("model-onboarding-state", () => {
|
||||
currentStep: "first-task" as const,
|
||||
updatedAt: "2024-01-01T00:00:00.000Z",
|
||||
completedSteps: [],
|
||||
skippedSteps: [],
|
||||
dismissed: false,
|
||||
completed: false,
|
||||
stepData: {},
|
||||
@@ -788,6 +858,7 @@ describe("model-onboarding-state", () => {
|
||||
currentStep: "github",
|
||||
updatedAt: "2024-01-01T00:00:00.000Z",
|
||||
completedSteps: [],
|
||||
skippedSteps: [],
|
||||
dismissed: false,
|
||||
completed: false,
|
||||
stepData: {},
|
||||
|
||||
@@ -12,6 +12,8 @@ interface OnboardingState {
|
||||
updatedAt: string; // ISO-8601 timestamp
|
||||
/** Steps that have been completed (visited and passed) */
|
||||
completedSteps: OnboardingStep[];
|
||||
/** Steps that were intentionally skipped and can be completed later */
|
||||
skippedSteps: OnboardingStep[];
|
||||
/** Whether the user explicitly dismissed the modal without finishing */
|
||||
dismissed: boolean;
|
||||
/** 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
|
||||
*/
|
||||
const DEFAULT_COMPLETED_STEPS: OnboardingStep[] = [];
|
||||
const DEFAULT_SKIPPED_STEPS: OnboardingStep[] = [];
|
||||
const DEFAULT_DISMISSED = false;
|
||||
const DEFAULT_COMPLETED = false;
|
||||
const DEFAULT_STEP_DATA: Partial<Record<OnboardingStep, Record<string, unknown>>> = {};
|
||||
@@ -79,6 +82,7 @@ function applyStateDefaults(state: OnboardingState): OnboardingState {
|
||||
return {
|
||||
...state,
|
||||
completedSteps: state.completedSteps ?? DEFAULT_COMPLETED_STEPS,
|
||||
skippedSteps: state.skippedSteps ?? DEFAULT_SKIPPED_STEPS,
|
||||
dismissed: state.dismissed ?? DEFAULT_DISMISSED,
|
||||
completed: state.completed ?? DEFAULT_COMPLETED,
|
||||
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 options - Optional rich payload for extended state tracking
|
||||
* @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.completed - Whether the user finished all steps
|
||||
* @param options.stepData - Per-step data for restoring UI state on reopen
|
||||
@@ -99,6 +104,7 @@ export function saveOnboardingState(
|
||||
step: OnboardingStep | string,
|
||||
options?: {
|
||||
completedSteps?: OnboardingStep[];
|
||||
skippedSteps?: OnboardingStep[];
|
||||
dismissed?: boolean;
|
||||
completed?: boolean;
|
||||
stepData?: Partial<Record<OnboardingStep, Record<string, unknown>>>;
|
||||
@@ -112,6 +118,7 @@ export function saveOnboardingState(
|
||||
currentStep: step,
|
||||
updatedAt: new Date().toISOString(),
|
||||
completedSteps: DEFAULT_COMPLETED_STEPS,
|
||||
skippedSteps: DEFAULT_SKIPPED_STEPS,
|
||||
dismissed: DEFAULT_DISMISSED,
|
||||
completed: DEFAULT_COMPLETED,
|
||||
stepData: DEFAULT_STEP_DATA,
|
||||
@@ -141,6 +148,7 @@ export function saveOnboardingState(
|
||||
|
||||
// Merge completedSteps
|
||||
const completedSteps = options.completedSteps ?? existing?.completedSteps ?? DEFAULT_COMPLETED_STEPS;
|
||||
const skippedSteps = options.skippedSteps ?? existing?.skippedSteps ?? DEFAULT_SKIPPED_STEPS;
|
||||
|
||||
// Merge stepData per-step key
|
||||
const stepData: Partial<Record<OnboardingStep, Record<string, unknown>>> = {
|
||||
@@ -161,6 +169,7 @@ export function saveOnboardingState(
|
||||
currentStep: step,
|
||||
updatedAt: now,
|
||||
completedSteps,
|
||||
skippedSteps,
|
||||
dismissed,
|
||||
completed,
|
||||
stepData,
|
||||
@@ -191,6 +200,7 @@ export function markStepCompleted(step: OnboardingStep): void {
|
||||
currentStep: step,
|
||||
updatedAt: now,
|
||||
completedSteps: [step],
|
||||
skippedSteps: DEFAULT_SKIPPED_STEPS,
|
||||
dismissed: DEFAULT_DISMISSED,
|
||||
completed: DEFAULT_COMPLETED,
|
||||
stepData: DEFAULT_STEP_DATA,
|
||||
@@ -207,6 +217,51 @@ export function markStepCompleted(step: OnboardingStep): void {
|
||||
const state: OnboardingState = {
|
||||
...existing,
|
||||
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,
|
||||
};
|
||||
|
||||
@@ -242,6 +297,7 @@ export function clearOnboardingState(options?: { preserveProgress?: boolean }):
|
||||
currentStep: "complete",
|
||||
updatedAt: now,
|
||||
completedSteps: DEFAULT_COMPLETED_STEPS,
|
||||
skippedSteps: DEFAULT_SKIPPED_STEPS,
|
||||
dismissed: false,
|
||||
completed: true,
|
||||
stepData: DEFAULT_STEP_DATA,
|
||||
@@ -285,6 +341,7 @@ export function markOnboardingCompleted(): void {
|
||||
updatedAt: now,
|
||||
completedAt: now,
|
||||
completedSteps: DEFAULT_COMPLETED_STEPS,
|
||||
skippedSteps: DEFAULT_SKIPPED_STEPS,
|
||||
dismissed: false,
|
||||
completed: true,
|
||||
stepData: DEFAULT_STEP_DATA,
|
||||
@@ -377,6 +434,16 @@ export function getCompletedSteps(): OnboardingStep[] {
|
||||
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.
|
||||
* Returns null if no state exists or the step has no data.
|
||||
|
||||
@@ -23742,6 +23742,11 @@ html .column.drag-over * {
|
||||
border-radius: var(--radius-sm);
|
||||
}
|
||||
|
||||
.model-onboarding-step-indicator.skipped {
|
||||
opacity: 0.7;
|
||||
border-color: var(--color-muted);
|
||||
}
|
||||
|
||||
.model-onboarding-step-indicator .step-number {
|
||||
width: 24px;
|
||||
height: 24px;
|
||||
@@ -23758,6 +23763,12 @@ html .column.drag-over * {
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.onboarding-step-skip-mark {
|
||||
font-size: var(--space-lg);
|
||||
color: var(--text-muted);
|
||||
line-height: 1;
|
||||
}
|
||||
|
||||
.model-onboarding-step-connector {
|
||||
width: 40px;
|
||||
height: 2px;
|
||||
|
||||
Reference in New Issue
Block a user