feat(FN-1863): add non-blocking progression indicators to onboarding wizard

- Add 'Optional' badge to AI Setup and GitHub step headers
- Add 'Skip setup →' link on AI Setup step footer
- Add 'Skip GitHub →' link on GitHub step footer
- Add helper text on AI Setup step when no providers authenticated
- Add helper text on GitHub step when GitHub is available but not connected
- Add regression tests for all non-blocking progression behaviors
- Add CSS styles for optional badge, skip links, and helper text
This commit is contained in:
Fusion
2026-04-15 23:18:37 -07:00
committed by gsxdsm
parent 37fdcc99a7
commit dfa5823694
3 changed files with 339 additions and 39 deletions

View File

@@ -451,12 +451,12 @@ export function ModelOnboardingModal({
<h2 id="onboarding-title" className="model-onboarding-title">
{step === "ai-setup" && (
<>
<Zap size={24} /> Set Up AI
<Zap size={24} /> Set Up AI <span className="onboarding-optional-badge">Optional</span>
</>
)}
{step === "github" && (
<>
<GitPullRequest size={24} /> Connect GitHub
<GitPullRequest size={24} /> Connect GitHub <span className="onboarding-optional-badge">Optional</span>
</>
)}
{step === "first-task" && (
@@ -546,6 +546,13 @@ export function ModelOnboardingModal({
authenticate via OAuth or enter an API key.
</p>
{/* Show helper text when providers exist but none are authenticated */}
{authProviders.length > 0 && !authProviders.some((p) => p.authenticated) && (
<p className="onboarding-helper-text">
You can add API keys or log in to providers later from Settings.
</p>
)}
{authLoading ? (
<div className="model-onboarding-loading">
<Loader2 size={24} className="animate-spin" />
@@ -733,45 +740,52 @@ export function ModelOnboardingModal({
</button>
</div>
) : (
<div className="onboarding-provider-row">
<div className="onboarding-provider-info">
<strong>
<GitPullRequest size={16} style={{ marginRight: 8 }} />
GitHub
</strong>
<span
data-testid="onboarding-auth-status-github"
className={`auth-status-badge ${isGithubAuthenticated ? "authenticated" : "not-authenticated"}`}
>
{isGithubAuthenticated
? "✓ Connected"
: "✗ Not connected"}
</span>
</div>
<div>
{authActionInProgress === "github" ? (
<button className="btn btn-sm" disabled>
<>
<div className="onboarding-provider-row">
<div className="onboarding-provider-info">
<strong>
<GitPullRequest size={16} style={{ marginRight: 8 }} />
GitHub
</strong>
<span
data-testid="onboarding-auth-status-github"
className={`auth-status-badge ${isGithubAuthenticated ? "authenticated" : "not-authenticated"}`}
>
{isGithubAuthenticated
? "Logging out…"
: "Waiting for login…"}
</button>
) : isGithubAuthenticated ? (
<button
className="btn btn-sm"
onClick={() => handleLogout("github")}
>
Disconnect
</button>
) : (
<button
className="btn btn-primary btn-sm"
onClick={() => handleLogin("github")}
>
Connect
</button>
)}
? "✓ Connected"
: "✗ Not connected"}
</span>
</div>
<div>
{authActionInProgress === "github" ? (
<button className="btn btn-sm" disabled>
{isGithubAuthenticated
? "Logging out…"
: "Waiting for login…"}
</button>
) : isGithubAuthenticated ? (
<button
className="btn btn-sm"
onClick={() => handleLogout("github")}
>
Disconnect
</button>
) : (
<button
className="btn btn-primary btn-sm"
onClick={() => handleLogin("github")}
>
Connect
</button>
)}
</div>
</div>
</div>
{!isGithubAuthenticated && (
<p className="onboarding-helper-text">
You can connect GitHub later from Settings Authentication.
</p>
)}
</>
)}
</div>
)}
@@ -841,6 +855,9 @@ export function ModelOnboardingModal({
>
Skip for now
</button>
<button className="onboarding-skip-step-link" onClick={handleNext}>
Skip setup
</button>
<button className="btn btn-primary" onClick={handleNext}>
Next
</button>
@@ -852,6 +869,9 @@ export function ModelOnboardingModal({
<button className="btn btn-sm" onClick={handleBack}>
Back
</button>
<button className="onboarding-skip-step-link" onClick={handleNext}>
Skip GitHub
</button>
<button className="btn btn-primary" onClick={handleNext}>
Next
</button>

View File

@@ -985,4 +985,247 @@ describe("ModelOnboardingModal", () => {
expect(mockClearOnboardingState).not.toHaveBeenCalled();
});
});
describe("Non-blocking progression", () => {
it("allows advancing to GitHub step without authenticating any provider", async () => {
// All providers return authenticated: false
mockFetchAuthStatus.mockResolvedValueOnce({
providers: [
{ id: "anthropic", name: "Anthropic", authenticated: false, type: "oauth" },
{ id: "openai", name: "OpenAI", authenticated: false, type: "api_key" },
],
});
render(<ModelOnboardingModal onComplete={vi.fn()} addToast={vi.fn()} />);
await waitFor(() => {
expect(screen.getByText("Set Up AI")).toBeTruthy();
});
// Click Next without any providers authenticated
fireEvent.click(screen.getByText("Next →"));
// Should advance to GitHub step
await waitFor(() => {
expect(screen.getByText("Connect GitHub")).toBeTruthy();
});
// No error messages should appear
expect(screen.queryByText(/error/i)).toBeNull();
});
it("allows advancing to GitHub step without selecting a model", async () => {
// Default mock setup has no model selected
render(<ModelOnboardingModal onComplete={vi.fn()} addToast={vi.fn()} />);
await waitFor(() => {
expect(screen.getByText("Set Up AI")).toBeTruthy();
});
// Verify model dropdown is empty (no model selected)
const dropdown = screen.getByTestId("mock-model-dropdown") as HTMLSelectElement;
expect(dropdown.value).toBe("");
// Click Next without selecting a model
fireEvent.click(screen.getByText("Next →"));
// Should advance to GitHub step successfully
await waitFor(() => {
expect(screen.getByText("Connect GitHub")).toBeTruthy();
});
});
it("allows advancing to First Task step without connecting GitHub", async () => {
// GitHub provider exists but is not authenticated
mockFetchAuthStatus.mockResolvedValueOnce({
providers: [
{ id: "anthropic", name: "Anthropic", authenticated: false, type: "oauth" },
{ id: "github", name: "GitHub", authenticated: false, type: "oauth" },
],
});
render(<ModelOnboardingModal onComplete={vi.fn()} addToast={vi.fn()} />);
// Navigate to GitHub step
await navigateToGitHubStep();
// Click Next without connecting GitHub
fireEvent.click(screen.getByText("Next →"));
// Should advance to First Task step
await waitFor(() => {
expect(screen.getByText("Create Your First Task")).toBeTruthy();
});
});
it("allows completing full onboarding flow without any setup", async () => {
// All providers not authenticated, no model selected
mockFetchAuthStatus.mockResolvedValueOnce({
providers: [
{ id: "anthropic", name: "Anthropic", authenticated: false, type: "oauth" },
],
});
render(<ModelOnboardingModal onComplete={vi.fn()} addToast={vi.fn()} />);
// Navigate AI Setup → GitHub → First Task
await navigateToFirstTaskStep();
// Click Finish Setup
fireEvent.click(screen.getByText("Finish Setup"));
// Should complete onboarding successfully
await waitFor(() => {
expect(screen.getByText("All Set!")).toBeTruthy();
});
// Should call updateGlobalSettings with modelOnboardingComplete: true
expect(mockUpdateGlobalSettings).toHaveBeenCalledWith(
expect.objectContaining({
modelOnboardingComplete: true,
}),
);
});
it("shows Optional badge on AI Setup and GitHub steps", async () => {
mockFetchAuthStatus.mockResolvedValueOnce({
providers: [
{ id: "github", name: "GitHub", authenticated: false, type: "oauth" },
],
});
render(<ModelOnboardingModal onComplete={vi.fn()} addToast={vi.fn()} />);
// On AI Setup step, Optional badge should be visible
await waitFor(() => {
expect(screen.getByText("Optional")).toBeTruthy();
});
// Navigate to GitHub step
await navigateToGitHubStep();
// On GitHub step, Optional badge should be visible
await waitFor(() => {
expect(screen.getByText("Optional")).toBeTruthy();
});
// Navigate to First Task step
fireEvent.click(screen.getByText("Next →"));
await waitFor(() => {
expect(screen.getByText("Create Your First Task")).toBeTruthy();
});
// On First Task step, NO Optional badge should be shown
// (there should be only one "Optional" text if we go back, but at this point it should be 0)
const optionalBadges = screen.queryAllByText("Optional");
expect(optionalBadges.length).toBe(0);
});
it("shows skip-step links on AI Setup and GitHub steps", async () => {
mockFetchAuthStatus.mockResolvedValueOnce({
providers: [
{ id: "github", name: "GitHub", authenticated: false, type: "oauth" },
],
});
render(<ModelOnboardingModal onComplete={vi.fn()} addToast={vi.fn()} />);
await waitFor(() => {
expect(screen.getByText("Set Up AI")).toBeTruthy();
});
// On AI Setup step, Skip setup link should be present
expect(screen.getByText("Skip setup →")).toBeTruthy();
// Click Skip setup
fireEvent.click(screen.getByText("Skip setup →"));
// Should advance to GitHub step
await waitFor(() => {
expect(screen.getByText("Connect GitHub")).toBeTruthy();
});
// On GitHub step, Skip GitHub link should be present
expect(screen.getByText("Skip GitHub →")).toBeTruthy();
// Click Skip GitHub
fireEvent.click(screen.getByText("Skip GitHub →"));
// Should advance to First Task step
await waitFor(() => {
expect(screen.getByText("Create Your First Task")).toBeTruthy();
});
});
it("shows helper text on AI Setup when no providers authenticated", async () => {
// All providers authenticated: false
mockFetchAuthStatus.mockResolvedValueOnce({
providers: [
{ id: "anthropic", name: "Anthropic", authenticated: false, type: "oauth" },
{ id: "openai", name: "OpenAI", authenticated: false, type: "api_key" },
],
});
render(<ModelOnboardingModal onComplete={vi.fn()} addToast={vi.fn()} />);
await waitFor(() => {
expect(screen.getByText("Set Up AI")).toBeTruthy();
});
// Helper text should be visible when no providers are authenticated
expect(screen.getByText("You can add API keys or log in to providers later from Settings.")).toBeTruthy();
});
it("does not show helper text on AI Setup when a provider is authenticated", async () => {
// At least one provider authenticated: true
mockFetchAuthStatus.mockResolvedValueOnce({
providers: [
{ id: "anthropic", name: "Anthropic", authenticated: true, type: "oauth" },
{ id: "openai", name: "OpenAI", authenticated: false, type: "api_key" },
],
});
render(<ModelOnboardingModal onComplete={vi.fn()} addToast={vi.fn()} />);
await waitFor(() => {
expect(screen.getByText("Set Up AI")).toBeTruthy();
});
// Helper text should NOT be visible when a provider is authenticated
expect(screen.queryByText("You can add API keys or log in to providers later from Settings.")).toBeNull();
});
it("shows helper text on GitHub step when GitHub is available but not connected", async () => {
// GitHub provider exists with authenticated: false
mockFetchAuthStatus.mockResolvedValueOnce({
providers: [
{ id: "github", name: "GitHub", authenticated: false, type: "oauth" },
],
});
render(<ModelOnboardingModal onComplete={vi.fn()} addToast={vi.fn()} />);
await navigateToGitHubStep();
// Helper text should be visible when GitHub is available but not connected
expect(screen.getByText("You can connect GitHub later from Settings → Authentication.")).toBeTruthy();
});
it("does not show helper text on GitHub step when GitHub is connected", async () => {
// GitHub provider exists with authenticated: true
mockFetchAuthStatus.mockResolvedValueOnce({
providers: [
{ id: "github", name: "GitHub", authenticated: true, type: "oauth" },
],
});
render(<ModelOnboardingModal onComplete={vi.fn()} addToast={vi.fn()} />);
await navigateToGitHubStep();
// Helper text should NOT be visible when GitHub is connected
expect(screen.queryByText("You can connect GitHub later from Settings → Authentication.")).toBeNull();
});
});
});

View File

@@ -23150,6 +23150,19 @@ html .column.drag-over * {
color: var(--accent);
}
.onboarding-optional-badge {
font-size: 11px;
font-weight: 500;
color: var(--text-muted);
background: var(--bg-hover);
padding: 2px 8px;
border-radius: 10px;
margin-left: 8px;
vertical-align: middle;
text-transform: uppercase;
letter-spacing: 0.5px;
}
/* Step indicator */
.model-onboarding-steps {
display: flex;
@@ -23241,6 +23254,13 @@ html .column.drag-over * {
line-height: 1.5;
}
.onboarding-helper-text {
color: var(--text-dim);
font-size: 12px;
margin: 4px 0 0 0;
line-height: 1.4;
}
.model-onboarding-loading {
display: flex;
align-items: center;
@@ -23619,6 +23639,23 @@ html .column.drag-over * {
gap: 6px;
}
.onboarding-skip-step-link {
background: none;
border: none;
color: var(--text-muted);
cursor: pointer;
font-size: 13px;
padding: 4px 8px;
border-radius: 4px;
text-decoration: none;
transition: color var(--transition-fast), background var(--transition-fast);
}
.onboarding-skip-step-link:hover {
color: var(--text);
background: var(--bg-hover);
}
@media (max-width: 768px) {
.model-onboarding-modal {
max-width: 100%;