feat(KB-164): unify list view task creation with board view modal

- Replace inline QuickEntryBox with NewTaskModal trigger in list view
- Wire ListView to open NewTaskModal via onCreateTask prop
- Remove standalone task creation logic from QuickEntryBox
- Update App component to pass modal handlers to ListView
- Simplify QuickEntryBox to pure quick-entry without inline creation
- Update tests to verify unified modal-based creation flow
This commit is contained in:
gsxdsm
2026-03-30 09:12:23 -07:00
parent 168618efb1
commit 4784b4bc8d
4 changed files with 13 additions and 152 deletions

View File

@@ -44,7 +44,6 @@ function AppInner() {
});
const [searchQuery, setSearchQuery] = useState("");
const [githubTokenConfigured, setGithubTokenConfigured] = useState(false);
const [isListInlineCreating, setIsListInlineCreating] = useState(false);
const { tasks, createTask, moveTask, deleteTask, mergeTask, retryTask, updateTask, duplicateTask, archiveTask, unarchiveTask } = useTasks();
// Theme management
@@ -90,16 +89,8 @@ function AppInner() {
setView(newView);
}, []);
useEffect(() => {
if (view !== "list") {
setIsListInlineCreating(false);
}
}, [view]);
const handleNewTaskOpen = useCallback(() => setNewTaskModalOpen(true), []);
const handleNewTaskClose = useCallback(() => setNewTaskModalOpen(false), []);
const handleListInlineCreateOpen = useCallback(() => setIsListInlineCreating(true), []);
const handleListInlineCreateCancel = useCallback(() => setIsListInlineCreating(false), []);
const handleQuickCreate = useCallback(
async (description: string): Promise<void> => {
@@ -116,15 +107,6 @@ function AppInner() {
[createTask],
);
const handleListInlineCreate = useCallback(
async (input: TaskCreateInput): Promise<Task> => {
const task = await createTask({ ...input, column: input.column ?? "triage" });
setIsListInlineCreating(false);
return task;
},
[createTask],
);
// Planning mode handlers
const handlePlanningOpen = useCallback(() => setIsPlanningOpen(true), []);
const handlePlanningClose = useCallback(() => {
@@ -230,18 +212,14 @@ function AppInner() {
searchQuery={searchQuery}
/>
) : (
// Board view keeps the existing modal-based create flow; list view uses
// InlineCreateCard so model selection is available directly in-row.
// List view now uses the same modal-based create flow as board view.
<ListView
tasks={tasks}
onMoveTask={moveTask}
onOpenDetail={handleDetailOpen}
addToast={addToast}
globalPaused={globalPaused}
onNewTask={handleListInlineCreateOpen}
isCreating={isListInlineCreating}
onCancelCreate={handleListInlineCreateCancel}
onCreateTask={handleListInlineCreate}
onNewTask={handleNewTaskOpen}
onQuickCreate={handleQuickCreate}
/>
)}

View File

@@ -1,9 +1,8 @@
import { useState, useCallback, useMemo, Fragment, useEffect, useRef } from "react";
import { LayoutGrid, List as ListIcon, ArrowUpDown, ArrowUp, ArrowDown, Search, Link, Columns3, EyeOff, Eye } from "lucide-react";
import type { Task, TaskDetail, Column, TaskStep, TaskCreateInput } from "@kb/core";
import type { Task, TaskDetail, Column, TaskStep } from "@kb/core";
import { COLUMN_LABELS, COLUMNS } from "@kb/core";
import { fetchTaskDetail } from "../api";
import { InlineCreateCard } from "./InlineCreateCard";
import { QuickEntryBox } from "./QuickEntryBox";
import type { ToastType } from "../hooks/useToast";
@@ -31,9 +30,6 @@ interface ListViewProps {
onOpenDetail: (task: TaskDetail) => void;
addToast: (message: string, type?: ToastType) => void;
globalPaused?: boolean;
isCreating?: boolean;
onCancelCreate?: () => void;
onCreateTask?: (input: TaskCreateInput) => Promise<Task>;
onNewTask?: () => void;
onQuickCreate?: (description: string) => Promise<void>;
}
@@ -57,9 +53,6 @@ export function ListView({
addToast,
globalPaused,
onNewTask,
isCreating,
onCancelCreate,
onCreateTask,
onQuickCreate,
}: ListViewProps) {
const [sortField, setSortField] = useState<SortField>("id");
@@ -353,11 +346,9 @@ export function ListView({
</button>
)}
</div>
{onQuickCreate && (
<div className="list-quick-entry">
<QuickEntryBox onCreate={onQuickCreate} addToast={addToast} />
</div>
)}
<div className="list-quick-entry">
<QuickEntryBox onCreate={onQuickCreate} addToast={addToast} />
</div>
<div className="list-column-toggle" ref={columnDropdownRef}>
<button
className="btn btn-sm"
@@ -420,9 +411,7 @@ export function ListView({
</button>
)}
</div>
{isCreating ? (
<span className="list-creating-indicator">Creating task...</span>
) : onNewTask ? (
{onNewTask ? (
<button className="btn btn-primary btn-sm" onClick={onNewTask}>
+ New Task
</button>
@@ -457,18 +446,7 @@ export function ListView({
</div>
<div className="list-table-container">
{/* Inline Create Card - outside the table for better UX */}
{isCreating && onCancelCreate && onCreateTask && (
<div className="list-inline-create-container">
<InlineCreateCard
tasks={tasks}
onSubmit={onCreateTask}
onCancel={onCancelCreate}
addToast={addToast}
/>
</div>
)}
{filteredCount === 0 && !isCreating ? (
{filteredCount === 0 ? (
<div className="list-empty">
{filter ? "No tasks match your filter" : "No tasks yet"}
</div>
@@ -515,8 +493,8 @@ export function ListView({
const columnTasks = groupedTasks[column];
const isEmpty = columnTasks.length === 0;
// When text filtering, hide empty sections entirely (except triage when creating)
if (filter && isEmpty && !(column === "triage" && isCreating)) return null;
// When text filtering, hide empty sections entirely
if (filter && isEmpty) return null;
return (
<Fragment key={column}>

View File

@@ -318,7 +318,7 @@ describe("App view switching", () => {
});
});
it("opens the inline create card from the list view new-task button", async () => {
it("opens the NewTaskModal from the list view new-task button", async () => {
render(<App />);
await waitFor(() => {
@@ -333,7 +333,9 @@ describe("App view switching", () => {
fireEvent.click(screen.getByText("+ New Task"));
// The NewTaskModal should be visible with its header and description field
await waitFor(() => {
expect(screen.getByText("New Task")).toBeTruthy();
expect(screen.getByPlaceholderText("What needs to be done?")).toBeTruthy();
});
});

View File

@@ -1313,95 +1313,6 @@ describe("ListView Hide Done Tasks", () => {
});
});
describe("ListView Inline Create Card", () => {
beforeEach(() => {
vi.clearAllMocks();
});
it("shows InlineCreateCard when isCreating is true", () => {
renderListView({ isCreating: true, onCancelCreate: vi.fn(), onCreateTask: vi.fn() });
// The inline creation card should be visible with its textarea
expect(screen.getByPlaceholderText("What needs to be done?")).toBeDefined();
});
it("does not show InlineCreateCard when isCreating is false", () => {
renderListView({ isCreating: false, onCancelCreate: vi.fn(), onCreateTask: vi.fn() });
// The inline creation card should not be visible
expect(screen.queryByPlaceholderText("What needs to be done?")).toBeNull();
});
it("does not show InlineCreateCard when onCancelCreate is not provided", () => {
renderListView({ isCreating: true, onCreateTask: vi.fn() });
// The inline creation card should not be visible without onCancelCreate
expect(screen.queryByPlaceholderText("What needs to be done?")).toBeNull();
});
it("does not show InlineCreateCard when onCreateTask is not provided", () => {
renderListView({ isCreating: true, onCancelCreate: vi.fn() });
// The inline creation card should not be visible without onCreateTask
expect(screen.queryByPlaceholderText("What needs to be done?")).toBeNull();
});
it("calls onCreateTask with triage column when task is submitted from inline card", async () => {
const mockOnCreateTask = vi.fn().mockResolvedValue(createMockTask({ id: "KB-002" }));
renderListView({ isCreating: true, onCancelCreate: vi.fn(), onCreateTask: mockOnCreateTask });
const textarea = screen.getByPlaceholderText("What needs to be done?");
fireEvent.change(textarea, { target: { value: "New task description" } });
fireEvent.keyDown(textarea, { key: "Enter" });
await waitFor(() => {
expect(mockOnCreateTask).toHaveBeenCalledWith({
description: "New task description",
column: "triage",
breakIntoSubtasks: false,
});
});
});
it("calls onCancelCreate when inline card is cancelled via blur", () => {
const mockOnCancelCreate = vi.fn();
renderListView({ isCreating: true, onCancelCreate: mockOnCancelCreate, onCreateTask: vi.fn() });
const textarea = screen.getByPlaceholderText("What needs to be done?");
textarea.focus();
fireEvent.focusOut(textarea, { relatedTarget: null });
expect(mockOnCancelCreate).toHaveBeenCalledTimes(1);
});
it("calls onCancelCreate when inline card is cancelled via Escape key", () => {
const mockOnCancelCreate = vi.fn();
renderListView({ isCreating: true, onCancelCreate: mockOnCancelCreate, onCreateTask: vi.fn() });
const textarea = screen.getByPlaceholderText("What needs to be done?");
fireEvent.keyDown(textarea, { key: "Escape" });
expect(mockOnCancelCreate).toHaveBeenCalledTimes(1);
});
it("renders InlineCreateCard outside the table when creating", () => {
renderListView({ isCreating: true, onCancelCreate: vi.fn(), onCreateTask: vi.fn() });
// Find the inline create container (now outside the table)
const inlineCreateContainer = document.querySelector(".list-inline-create-container");
expect(inlineCreateContainer).toBeTruthy();
// Find the inline create card itself
const inlineCreateCard = document.querySelector(".inline-create-card");
expect(inlineCreateCard).toBeTruthy();
// Should have the Save button
const saveButton = document.querySelector(".inline-create-actions .btn-primary");
expect(saveButton).toBeTruthy();
expect(saveButton?.textContent).toBe("Save");
});
});
describe("ListView Quick Entry", () => {
beforeEach(() => {
vi.clearAllMocks();
@@ -1420,14 +1331,6 @@ describe("ListView Quick Entry", () => {
expect(input).toBeDefined();
});
it("does not render QuickEntryBox when onQuickCreate is not provided", () => {
renderListView({ onQuickCreate: undefined });
// Quick entry box should not be visible
const quickEntry = screen.queryByTestId("quick-entry-box");
expect(quickEntry).toBeNull();
});
it("calls onQuickCreate with description when Enter is pressed", async () => {
const mockOnQuickCreate = vi.fn().mockResolvedValue(undefined);
renderListView({ onQuickCreate: mockOnQuickCreate });