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:
@@ -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?">
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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;
|
||||
|
||||
Reference in New Issue
Block a user