feat(KB-040): add break-into-subtasks toggle for task creation
- Add breakIntoSubtasks flag to core Task model and persist to store - Plumb subtask flag through dashboard API routes with validation - Add subtask toggle UI to inline task creation card - Update triage agent to handle automatic subtask breakdown when enabled - Add comprehensive tests for store, API routes, and UI components - Include changeset for patch release documenting the new feature
This commit is contained in:
@@ -32,9 +32,16 @@ export async function fetchTaskDetail(id: string): Promise<TaskDetail> {
|
||||
}
|
||||
|
||||
export function createTask(input: TaskCreateInput): Promise<Task> {
|
||||
const { title, description, column, dependencies, breakIntoSubtasks } = input;
|
||||
return api<Task>("/tasks", {
|
||||
method: "POST",
|
||||
body: JSON.stringify(input),
|
||||
body: JSON.stringify({
|
||||
title,
|
||||
description,
|
||||
column,
|
||||
dependencies,
|
||||
breakIntoSubtasks,
|
||||
}),
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
@@ -23,6 +23,7 @@ export function InlineCreateCard({ tasks, onSubmit, onCancel, addToast }: Inline
|
||||
const [dependencies, setDependencies] = useState<string[]>([]);
|
||||
const [showDeps, setShowDeps] = useState(false);
|
||||
const [depSearch, setDepSearch] = useState("");
|
||||
const [breakIntoSubtasks, setBreakIntoSubtasks] = useState(false);
|
||||
const [submitting, setSubmitting] = useState(false);
|
||||
const [pendingImages, setPendingImages] = useState<PendingImage[]>([]);
|
||||
const inputRef = useRef<HTMLTextAreaElement>(null);
|
||||
@@ -44,13 +45,19 @@ export function InlineCreateCard({ tasks, onSubmit, onCancel, addToast }: Inline
|
||||
// relatedTarget is the element receiving focus — if it's inside the card, ignore
|
||||
if (e.relatedTarget instanceof Node && card.contains(e.relatedTarget)) return;
|
||||
// Only cancel if empty and dropdown is not open
|
||||
if (description.trim() === "" && pendingImages.length === 0 && dependencies.length === 0 && !showDeps) {
|
||||
if (
|
||||
description.trim() === "" &&
|
||||
pendingImages.length === 0 &&
|
||||
dependencies.length === 0 &&
|
||||
!breakIntoSubtasks &&
|
||||
!showDeps
|
||||
) {
|
||||
onCancel();
|
||||
}
|
||||
};
|
||||
card.addEventListener("focusout", handleFocusOut);
|
||||
return () => card.removeEventListener("focusout", handleFocusOut);
|
||||
}, [description, pendingImages, dependencies, showDeps, onCancel]);
|
||||
}, [description, pendingImages, dependencies, breakIntoSubtasks, showDeps, onCancel]);
|
||||
|
||||
// Clean up object URLs on unmount to prevent memory leaks
|
||||
useEffect(() => {
|
||||
@@ -101,6 +108,7 @@ export function InlineCreateCard({ tasks, onSubmit, onCancel, addToast }: Inline
|
||||
description: description.trim(),
|
||||
column: "triage",
|
||||
dependencies: dependencies.length ? dependencies : undefined,
|
||||
breakIntoSubtasks,
|
||||
});
|
||||
|
||||
// Upload pending images as attachments
|
||||
@@ -128,7 +136,7 @@ export function InlineCreateCard({ tasks, onSubmit, onCancel, addToast }: Inline
|
||||
} finally {
|
||||
setSubmitting(false);
|
||||
}
|
||||
}, [description, dependencies, submitting, pendingImages, onSubmit, addToast]);
|
||||
}, [description, dependencies, breakIntoSubtasks, submitting, pendingImages, onSubmit, addToast]);
|
||||
|
||||
const handleKeyDown = useCallback(
|
||||
async (e: React.KeyboardEvent) => {
|
||||
@@ -199,6 +207,17 @@ export function InlineCreateCard({ tasks, onSubmit, onCancel, addToast }: Inline
|
||||
>
|
||||
<Link size={12} style={{ verticalAlign: 'middle' }} />{dependencies.length > 0 ? ` ${dependencies.length} deps` : " Deps"}
|
||||
</button>
|
||||
{!submitting && (
|
||||
<label className="inline-create-hint" style={{ display: "inline-flex", alignItems: "center", gap: 6, marginLeft: 8 }}>
|
||||
<input
|
||||
type="checkbox"
|
||||
data-testid="break-into-subtasks-toggle"
|
||||
checked={breakIntoSubtasks}
|
||||
onChange={(e) => setBreakIntoSubtasks(e.target.checked)}
|
||||
/>
|
||||
Break into subtasks
|
||||
</label>
|
||||
)}
|
||||
{showDeps && (() => {
|
||||
const term = depSearch.toLowerCase();
|
||||
const filtered = (term
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { describe, it, expect, vi } from "vitest";
|
||||
import { render, screen, fireEvent } from "@testing-library/react";
|
||||
import { render, screen, fireEvent, waitFor } from "@testing-library/react";
|
||||
import { InlineCreateCard } from "../InlineCreateCard";
|
||||
import type { Task, Column } from "@kb/core";
|
||||
|
||||
@@ -187,3 +187,54 @@ describe("InlineCreateCard dependency dropdown search", () => {
|
||||
expect(items[0].querySelector(".dep-dropdown-id")?.textContent).toBe("KB-002");
|
||||
});
|
||||
});
|
||||
|
||||
describe("InlineCreateCard breakIntoSubtasks toggle", () => {
|
||||
it("renders toggle defaulted to off", () => {
|
||||
renderCard();
|
||||
const checkbox = screen.getByTestId("break-into-subtasks-toggle") as HTMLInputElement;
|
||||
expect(checkbox.checked).toBe(false);
|
||||
});
|
||||
|
||||
it("can be toggled on", () => {
|
||||
renderCard();
|
||||
const checkbox = screen.getByTestId("break-into-subtasks-toggle") as HTMLInputElement;
|
||||
fireEvent.click(checkbox);
|
||||
expect(checkbox.checked).toBe(true);
|
||||
});
|
||||
|
||||
it("passes breakIntoSubtasks in submit payload", async () => {
|
||||
const { props } = renderCard();
|
||||
const textarea = screen.getByPlaceholderText("What needs to be done?");
|
||||
const checkbox = screen.getByTestId("break-into-subtasks-toggle") as HTMLInputElement;
|
||||
|
||||
fireEvent.change(textarea, { target: { value: "Split this work" } });
|
||||
fireEvent.click(checkbox);
|
||||
fireEvent.click(screen.getByRole("button", { name: "Save" }));
|
||||
|
||||
await waitFor(() => {
|
||||
expect(props.onSubmit).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
description: "Split this work",
|
||||
breakIntoSubtasks: true,
|
||||
}),
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
it("passes breakIntoSubtasks=false by default", async () => {
|
||||
const { props } = renderCard();
|
||||
const textarea = screen.getByPlaceholderText("What needs to be done?");
|
||||
|
||||
fireEvent.change(textarea, { target: { value: "Simple task" } });
|
||||
fireEvent.click(screen.getByRole("button", { name: "Save" }));
|
||||
|
||||
await waitFor(() => {
|
||||
expect(props.onSubmit).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
description: "Simple task",
|
||||
breakIntoSubtasks: false,
|
||||
}),
|
||||
);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1357,6 +1357,7 @@ describe("ListView Inline Create Card", () => {
|
||||
expect(mockOnCreateTask).toHaveBeenCalledWith({
|
||||
description: "New task description",
|
||||
column: "triage",
|
||||
breakIntoSubtasks: false,
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user