feat(FN-1942): harden onboarding first-task creation flow

- Add inline first-task description form in the onboarding final step
- Wire createTask with empty-input validation, loading state, and retry-friendly error handling
- Preserve typed task text and show inline error guidance when task creation fails
- Support success actions with either server-provided or inline-created task data
- Add comprehensive modal tests and token-based styles for new form and error states
This commit is contained in:
Fusion
2026-04-17 05:06:19 -07:00
committed by gsxdsm
parent b2187610cb
commit 77531e8a97
3 changed files with 277 additions and 6 deletions

View File

@@ -10,6 +10,7 @@ import {
clearApiKey,
fetchModels,
updateGlobalSettings,
createTask,
} from "../api";
import type { ToastType } from "../hooks/useToast";
import { CustomModelDropdown } from "./CustomModelDropdown";
@@ -469,6 +470,10 @@ export function ModelOnboardingModal({
const [completedSteps, setCompletedSteps] = useState<OnboardingStep[]>(persistedCompletedSteps);
const [skippedSteps, setSkippedSteps] = useState<OnboardingStep[]>(persistedSkippedSteps);
const [showTaskCreated, setShowTaskCreated] = useState(false);
const [firstTaskDescription, setFirstTaskDescription] = useState("");
const [isCreatingFirstTask, setIsCreatingFirstTask] = useState(false);
const [taskCreationError, setTaskCreationError] = useState<string | null>(null);
const [inlineCreatedTask, setInlineCreatedTask] = useState<Task | null>(null);
const [authProviders, setAuthProviders] = useState<AuthProvider[]>([]);
const [authLoading, setAuthLoading] = useState(true);
const [authActionInProgress, setAuthActionInProgress] = useState<string | null>(null);
@@ -1125,6 +1130,33 @@ export function ModelOnboardingModal({
}
}, [selectedModel, availableModels, addToast]);
const handleCreateFirstTask = useCallback(async () => {
const trimmedDescription = firstTaskDescription.trim();
if (!trimmedDescription) {
setTaskCreationError("Please enter a task description.");
return;
}
setTaskCreationError(null);
setIsCreatingFirstTask(true);
try {
const createdTask = await createTask({ description: trimmedDescription });
setInlineCreatedTask(createdTask);
setShowTaskCreated(true);
addToast("Task created", "success");
} catch (err: unknown) {
const message =
err instanceof Error
? err.message
: "Something went wrong creating your task. Please try again.";
setTaskCreationError(message);
addToast(message, "error");
} finally {
setIsCreatingFirstTask(false);
}
}, [firstTaskDescription, addToast]);
// Handle first task CTA - mark complete, close modal, then open new task
const handleOpenNewTask = useCallback(async () => {
// First complete the onboarding
@@ -1226,13 +1258,14 @@ export function ModelOnboardingModal({
}, [onComplete]);
const handleViewCreatedTask = useCallback(() => {
if (!firstCreatedTask) {
const createdTask = firstCreatedTask ?? inlineCreatedTask;
if (!createdTask) {
return;
}
onViewTask?.(firstCreatedTask);
onViewTask?.(createdTask);
onComplete();
}, [firstCreatedTask, onViewTask, onComplete]);
}, [firstCreatedTask, inlineCreatedTask, onViewTask, onComplete]);
const handleGoToDashboard = useCallback(() => {
onComplete();
@@ -1334,8 +1367,11 @@ export function ModelOnboardingModal({
});
}
const createdTaskForDisplay = firstCreatedTask ?? inlineCreatedTask;
const firstCreatedTaskPreview =
firstCreatedTask?.description?.split("\n")[0]?.trim() || firstCreatedTask?.title || "";
createdTaskForDisplay?.description?.split("\n")[0]?.trim() ||
createdTaskForDisplay?.title ||
"";
return (
<div
@@ -1857,11 +1893,11 @@ export function ModelOnboardingModal({
Your workspace is ready. Here's how to get started:
</p>
{showTaskCreated && firstCreatedTask ? (
{showTaskCreated && createdTaskForDisplay ? (
<div className="onboarding-task-created">
<CheckCircle size={56} className="success-icon" />
<h3 className="onboarding-task-created__title">Your first task is ready!</h3>
<div className="onboarding-task-created__task-id">{firstCreatedTask.id}</div>
<div className="onboarding-task-created__task-id">{createdTaskForDisplay.id}</div>
{firstCreatedTaskPreview && (
<p className="onboarding-task-created__description">{firstCreatedTaskPreview}</p>
)}
@@ -1887,6 +1923,49 @@ export function ModelOnboardingModal({
</div>
) : (
<>
<div className="onboarding-first-task-form">
<label className="onboarding-first-task-form__label" htmlFor="onboarding-first-task-input">
Describe your first task
</label>
<textarea
id="onboarding-first-task-input"
className="input onboarding-first-task-form__input"
data-testid="onboarding-first-task-input"
value={firstTaskDescription}
onChange={(event) => {
setFirstTaskDescription(event.target.value);
setTaskCreationError(null);
}}
placeholder="Example: Build a login page with email and password"
rows={4}
/>
<div className="onboarding-first-task-form__actions">
<button
type="button"
className="btn btn-primary"
onClick={handleCreateFirstTask}
disabled={isCreatingFirstTask}
data-testid="onboarding-first-task-submit"
>
{isCreatingFirstTask ? (
<>
<Loader2 size={16} className="animate-spin" />
<span>Creating task…</span>
</>
) : (
"Create First Task"
)}
</button>
</div>
</div>
{taskCreationError && (
<div className="onboarding-task-error" role="alert" data-testid="onboarding-task-error">
<p className="field-error">{taskCreationError}</p>
<p className="onboarding-helper-text">Your text has been preserved — fix the issue and try again.</p>
</div>
)}
<ReadinessSummary items={readinessItems} />
<OnboardingDisclosure summary="What happens when I create a task?">

View File

@@ -13,6 +13,7 @@ const mockClearApiKey = vi.fn();
const mockFetchModels = vi.fn();
const mockFetchGlobalSettings = vi.fn();
const mockUpdateGlobalSettings = vi.fn();
const mockCreateTask = vi.fn();
vi.mock("../../api", () => ({
fetchAuthStatus: (...args: unknown[]) => mockFetchAuthStatus(...args),
@@ -23,6 +24,7 @@ vi.mock("../../api", () => ({
fetchModels: (...args: unknown[]) => mockFetchModels(...args),
fetchGlobalSettings: (...args: unknown[]) => mockFetchGlobalSettings(...args),
updateGlobalSettings: (...args: unknown[]) => mockUpdateGlobalSettings(...args),
createTask: (...args: unknown[]) => mockCreateTask(...args),
}));
// Mock CustomModelDropdown since it has complex portal behavior
@@ -125,6 +127,7 @@ beforeEach(() => {
mockFetchModels.mockResolvedValue({ models: defaultModels, favoriteProviders: [], favoriteModels: [] });
mockFetchGlobalSettings.mockResolvedValue({});
mockUpdateGlobalSettings.mockResolvedValue({});
mockCreateTask.mockResolvedValue({ id: "FN-TEST", description: "test task" });
mockLoginProvider.mockResolvedValue({ url: "https://auth.example.com/login" });
mockLogoutProvider.mockResolvedValue({ success: true });
mockSaveApiKey.mockResolvedValue({ success: true });
@@ -1232,6 +1235,154 @@ describe("ModelOnboardingModal", () => {
expect(screen.getByText("Import from GitHub")).toBeTruthy();
});
it("shows empty-description validation and does not call createTask", async () => {
render(<ModelOnboardingModal onComplete={vi.fn()} addToast={vi.fn()} />);
await navigateToFirstTaskStep();
fireEvent.click(screen.getByTestId("onboarding-first-task-submit"));
await waitFor(() => {
expect(screen.getByTestId("onboarding-task-error")).toBeTruthy();
});
expect(screen.getByText("Please enter a task description.")).toBeTruthy();
expect(screen.getByText("Create Your First Task")).toBeTruthy();
expect(mockCreateTask).not.toHaveBeenCalled();
});
it("shows server error and preserves typed task description", async () => {
mockCreateTask.mockRejectedValueOnce(new Error("description is required"));
render(<ModelOnboardingModal onComplete={vi.fn()} addToast={vi.fn()} />);
await navigateToFirstTaskStep();
const taskInput = screen.getByTestId("onboarding-first-task-input") as HTMLTextAreaElement;
fireEvent.change(taskInput, { target: { value: "Build a login page" } });
fireEvent.click(screen.getByTestId("onboarding-first-task-submit"));
await waitFor(() => {
expect(screen.getByText("description is required")).toBeTruthy();
});
expect(taskInput.value).toBe("Build a login page");
expect(screen.getByText("Create Your First Task")).toBeTruthy();
});
it("allows retrying after an error and transitions to created-task success", async () => {
mockCreateTask
.mockRejectedValueOnce(new Error("temporary failure"))
.mockResolvedValueOnce({
id: "FN-2000",
title: "Build auth",
description: "Build auth",
} as Task);
render(<ModelOnboardingModal onComplete={vi.fn()} addToast={vi.fn()} />);
await navigateToFirstTaskStep();
const taskInput = screen.getByTestId("onboarding-first-task-input") as HTMLTextAreaElement;
fireEvent.change(taskInput, { target: { value: "Build auth" } });
fireEvent.click(screen.getByTestId("onboarding-first-task-submit"));
await waitFor(() => {
expect(screen.getByText("temporary failure")).toBeTruthy();
});
fireEvent.click(screen.getByTestId("onboarding-first-task-submit"));
await waitFor(() => {
expect(screen.getByText("Your first task is ready!")).toBeTruthy();
});
expect(screen.queryByTestId("onboarding-task-error")).toBeNull();
expect(mockCreateTask).toHaveBeenCalledTimes(2);
});
it("disables first-task submit button while creating the task", async () => {
let resolveCreateTask: ((value: Task) => void) | undefined;
mockCreateTask.mockImplementationOnce(
() =>
new Promise<Task>((resolve) => {
resolveCreateTask = resolve;
}),
);
render(<ModelOnboardingModal onComplete={vi.fn()} addToast={vi.fn()} />);
await navigateToFirstTaskStep();
fireEvent.change(screen.getByTestId("onboarding-first-task-input"), {
target: { value: "Build a login page" },
});
const submitButton = screen.getByTestId("onboarding-first-task-submit") as HTMLButtonElement;
fireEvent.click(submitButton);
expect(submitButton.disabled).toBe(true);
resolveCreateTask?.({ id: "FN-3000", title: "login", description: "Build a login page" } as Task);
await waitFor(() => {
expect(screen.getByText("Your first task is ready!")).toBeTruthy();
});
});
it("clears first-task creation error as input changes", async () => {
mockCreateTask.mockRejectedValueOnce(new Error("description is required"));
render(<ModelOnboardingModal onComplete={vi.fn()} addToast={vi.fn()} />);
await navigateToFirstTaskStep();
const taskInput = screen.getByTestId("onboarding-first-task-input") as HTMLTextAreaElement;
fireEvent.change(taskInput, { target: { value: "Build a login page" } });
fireEvent.click(screen.getByTestId("onboarding-first-task-submit"));
await waitFor(() => {
expect(screen.getByTestId("onboarding-task-error")).toBeTruthy();
});
fireEvent.change(taskInput, { target: { value: "Build a login page with OAuth" } });
await waitFor(() => {
expect(screen.queryByTestId("onboarding-task-error")).toBeNull();
});
});
it("renders network error message when createTask fails with Failed to fetch", async () => {
mockCreateTask.mockRejectedValueOnce(new Error("Failed to fetch"));
render(<ModelOnboardingModal onComplete={vi.fn()} addToast={vi.fn()} />);
await navigateToFirstTaskStep();
fireEvent.change(screen.getByTestId("onboarding-first-task-input"), {
target: { value: "Build a login page" },
});
fireEvent.click(screen.getByTestId("onboarding-first-task-submit"));
await waitFor(() => {
expect(screen.getByText("Failed to fetch")).toBeTruthy();
});
});
it("uses fallback message when createTask throws a non-Error value", async () => {
mockCreateTask.mockRejectedValueOnce("unknown");
render(<ModelOnboardingModal onComplete={vi.fn()} addToast={vi.fn()} />);
await navigateToFirstTaskStep();
fireEvent.change(screen.getByTestId("onboarding-first-task-input"), {
target: { value: "Build a login page" },
});
fireEvent.click(screen.getByTestId("onboarding-first-task-submit"));
await waitFor(() => {
expect(
screen.getByText("Something went wrong creating your task. Please try again."),
).toBeTruthy();
});
});
it("shows task-created success view after firstCreatedTask transitions from null", async () => {
mockGetOnboardingState.mockReturnValue({
currentStep: "first-task",

View File

@@ -24444,6 +24444,47 @@ html .column.drag-over * {
gap: 20px;
}
/* === Onboarding First Task Inline Form === */
.onboarding-first-task-form {
display: flex;
flex-direction: column;
gap: var(--space-sm);
}
.onboarding-first-task-form__label {
font-size: var(--space-md);
font-weight: 600;
color: var(--text);
}
.onboarding-first-task-form__input {
width: 100%;
min-height: calc(var(--space-xl) * 3);
resize: vertical;
}
.onboarding-first-task-form__actions {
display: flex;
justify-content: flex-end;
}
/* === Onboarding Task Error === */
.onboarding-task-error {
margin-top: var(--space-sm);
padding: var(--space-sm) var(--space-md);
background: color-mix(in srgb, var(--color-error) 10%, transparent);
border-radius: var(--radius-sm);
border: 1px solid color-mix(in srgb, var(--color-error) 30%, transparent);
}
.onboarding-task-error .field-error {
margin: 0;
}
.onboarding-task-error .onboarding-helper-text {
margin: var(--space-xs) 0 0;
}
.onboarding-cta-options {
display: flex;
flex-direction: column;