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:
@@ -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>
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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%;
|
||||
|
||||
Reference in New Issue
Block a user