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,
|
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}>
|
||||||
|
|||||||
@@ -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", () => {
|
||||||
|
|||||||
@@ -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()} />);
|
||||||
|
|||||||
@@ -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: {},
|
||||||
|
|||||||
@@ -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.
|
||||||
|
|||||||
@@ -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;
|
||||||
|
|||||||
Reference in New Issue
Block a user