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

@@ -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", () => {

View File

@@ -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()} />);

View File

@@ -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: {},