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