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 [searchQuery, setSearchQuery] = useState("");
|
||||||
const [githubTokenConfigured, setGithubTokenConfigured] = useState(false);
|
const [githubTokenConfigured, setGithubTokenConfigured] = useState(false);
|
||||||
const [isListInlineCreating, setIsListInlineCreating] = useState(false);
|
|
||||||
const { tasks, createTask, moveTask, deleteTask, mergeTask, retryTask, updateTask, duplicateTask, archiveTask, unarchiveTask } = useTasks();
|
const { tasks, createTask, moveTask, deleteTask, mergeTask, retryTask, updateTask, duplicateTask, archiveTask, unarchiveTask } = useTasks();
|
||||||
|
|
||||||
// Theme management
|
// Theme management
|
||||||
@@ -90,16 +89,8 @@ function AppInner() {
|
|||||||
setView(newView);
|
setView(newView);
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
if (view !== "list") {
|
|
||||||
setIsListInlineCreating(false);
|
|
||||||
}
|
|
||||||
}, [view]);
|
|
||||||
|
|
||||||
const handleNewTaskOpen = useCallback(() => setNewTaskModalOpen(true), []);
|
const handleNewTaskOpen = useCallback(() => setNewTaskModalOpen(true), []);
|
||||||
const handleNewTaskClose = useCallback(() => setNewTaskModalOpen(false), []);
|
const handleNewTaskClose = useCallback(() => setNewTaskModalOpen(false), []);
|
||||||
const handleListInlineCreateOpen = useCallback(() => setIsListInlineCreating(true), []);
|
|
||||||
const handleListInlineCreateCancel = useCallback(() => setIsListInlineCreating(false), []);
|
|
||||||
|
|
||||||
const handleQuickCreate = useCallback(
|
const handleQuickCreate = useCallback(
|
||||||
async (description: string): Promise<void> => {
|
async (description: string): Promise<void> => {
|
||||||
@@ -116,15 +107,6 @@ function AppInner() {
|
|||||||
[createTask],
|
[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
|
// Planning mode handlers
|
||||||
const handlePlanningOpen = useCallback(() => setIsPlanningOpen(true), []);
|
const handlePlanningOpen = useCallback(() => setIsPlanningOpen(true), []);
|
||||||
const handlePlanningClose = useCallback(() => {
|
const handlePlanningClose = useCallback(() => {
|
||||||
@@ -230,18 +212,14 @@ function AppInner() {
|
|||||||
searchQuery={searchQuery}
|
searchQuery={searchQuery}
|
||||||
/>
|
/>
|
||||||
) : (
|
) : (
|
||||||
// Board view keeps the existing modal-based create flow; list view uses
|
// List view now uses the same modal-based create flow as board view.
|
||||||
// InlineCreateCard so model selection is available directly in-row.
|
|
||||||
<ListView
|
<ListView
|
||||||
tasks={tasks}
|
tasks={tasks}
|
||||||
onMoveTask={moveTask}
|
onMoveTask={moveTask}
|
||||||
onOpenDetail={handleDetailOpen}
|
onOpenDetail={handleDetailOpen}
|
||||||
addToast={addToast}
|
addToast={addToast}
|
||||||
globalPaused={globalPaused}
|
globalPaused={globalPaused}
|
||||||
onNewTask={handleListInlineCreateOpen}
|
onNewTask={handleNewTaskOpen}
|
||||||
isCreating={isListInlineCreating}
|
|
||||||
onCancelCreate={handleListInlineCreateCancel}
|
|
||||||
onCreateTask={handleListInlineCreate}
|
|
||||||
onQuickCreate={handleQuickCreate}
|
onQuickCreate={handleQuickCreate}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
|
|||||||
@@ -1,9 +1,8 @@
|
|||||||
import { useState, useCallback, useMemo, Fragment, useEffect, useRef } from "react";
|
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 { 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 { COLUMN_LABELS, COLUMNS } from "@kb/core";
|
||||||
import { fetchTaskDetail } from "../api";
|
import { fetchTaskDetail } from "../api";
|
||||||
import { InlineCreateCard } from "./InlineCreateCard";
|
|
||||||
import { QuickEntryBox } from "./QuickEntryBox";
|
import { QuickEntryBox } from "./QuickEntryBox";
|
||||||
import type { ToastType } from "../hooks/useToast";
|
import type { ToastType } from "../hooks/useToast";
|
||||||
|
|
||||||
@@ -31,9 +30,6 @@ interface ListViewProps {
|
|||||||
onOpenDetail: (task: TaskDetail) => void;
|
onOpenDetail: (task: TaskDetail) => void;
|
||||||
addToast: (message: string, type?: ToastType) => void;
|
addToast: (message: string, type?: ToastType) => void;
|
||||||
globalPaused?: boolean;
|
globalPaused?: boolean;
|
||||||
isCreating?: boolean;
|
|
||||||
onCancelCreate?: () => void;
|
|
||||||
onCreateTask?: (input: TaskCreateInput) => Promise<Task>;
|
|
||||||
onNewTask?: () => void;
|
onNewTask?: () => void;
|
||||||
onQuickCreate?: (description: string) => Promise<void>;
|
onQuickCreate?: (description: string) => Promise<void>;
|
||||||
}
|
}
|
||||||
@@ -57,9 +53,6 @@ export function ListView({
|
|||||||
addToast,
|
addToast,
|
||||||
globalPaused,
|
globalPaused,
|
||||||
onNewTask,
|
onNewTask,
|
||||||
isCreating,
|
|
||||||
onCancelCreate,
|
|
||||||
onCreateTask,
|
|
||||||
onQuickCreate,
|
onQuickCreate,
|
||||||
}: ListViewProps) {
|
}: ListViewProps) {
|
||||||
const [sortField, setSortField] = useState<SortField>("id");
|
const [sortField, setSortField] = useState<SortField>("id");
|
||||||
@@ -353,11 +346,9 @@ export function ListView({
|
|||||||
</button>
|
</button>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
{onQuickCreate && (
|
<div className="list-quick-entry">
|
||||||
<div className="list-quick-entry">
|
<QuickEntryBox onCreate={onQuickCreate} addToast={addToast} />
|
||||||
<QuickEntryBox onCreate={onQuickCreate} addToast={addToast} />
|
</div>
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
<div className="list-column-toggle" ref={columnDropdownRef}>
|
<div className="list-column-toggle" ref={columnDropdownRef}>
|
||||||
<button
|
<button
|
||||||
className="btn btn-sm"
|
className="btn btn-sm"
|
||||||
@@ -420,9 +411,7 @@ export function ListView({
|
|||||||
</button>
|
</button>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
{isCreating ? (
|
{onNewTask ? (
|
||||||
<span className="list-creating-indicator">Creating task...</span>
|
|
||||||
) : onNewTask ? (
|
|
||||||
<button className="btn btn-primary btn-sm" onClick={onNewTask}>
|
<button className="btn btn-primary btn-sm" onClick={onNewTask}>
|
||||||
+ New Task
|
+ New Task
|
||||||
</button>
|
</button>
|
||||||
@@ -457,18 +446,7 @@ export function ListView({
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="list-table-container">
|
<div className="list-table-container">
|
||||||
{/* Inline Create Card - outside the table for better UX */}
|
{filteredCount === 0 ? (
|
||||||
{isCreating && onCancelCreate && onCreateTask && (
|
|
||||||
<div className="list-inline-create-container">
|
|
||||||
<InlineCreateCard
|
|
||||||
tasks={tasks}
|
|
||||||
onSubmit={onCreateTask}
|
|
||||||
onCancel={onCancelCreate}
|
|
||||||
addToast={addToast}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
{filteredCount === 0 && !isCreating ? (
|
|
||||||
<div className="list-empty">
|
<div className="list-empty">
|
||||||
{filter ? "No tasks match your filter" : "No tasks yet"}
|
{filter ? "No tasks match your filter" : "No tasks yet"}
|
||||||
</div>
|
</div>
|
||||||
@@ -515,8 +493,8 @@ export function ListView({
|
|||||||
const columnTasks = groupedTasks[column];
|
const columnTasks = groupedTasks[column];
|
||||||
const isEmpty = columnTasks.length === 0;
|
const isEmpty = columnTasks.length === 0;
|
||||||
|
|
||||||
// When text filtering, hide empty sections entirely (except triage when creating)
|
// When text filtering, hide empty sections entirely
|
||||||
if (filter && isEmpty && !(column === "triage" && isCreating)) return null;
|
if (filter && isEmpty) return null;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Fragment key={column}>
|
<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 />);
|
render(<App />);
|
||||||
|
|
||||||
await waitFor(() => {
|
await waitFor(() => {
|
||||||
@@ -333,7 +333,9 @@ describe("App view switching", () => {
|
|||||||
|
|
||||||
fireEvent.click(screen.getByText("+ New Task"));
|
fireEvent.click(screen.getByText("+ New Task"));
|
||||||
|
|
||||||
|
// The NewTaskModal should be visible with its header and description field
|
||||||
await waitFor(() => {
|
await waitFor(() => {
|
||||||
|
expect(screen.getByText("New Task")).toBeTruthy();
|
||||||
expect(screen.getByPlaceholderText("What needs to be done?")).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", () => {
|
describe("ListView Quick Entry", () => {
|
||||||
beforeEach(() => {
|
beforeEach(() => {
|
||||||
vi.clearAllMocks();
|
vi.clearAllMocks();
|
||||||
@@ -1420,14 +1331,6 @@ describe("ListView Quick Entry", () => {
|
|||||||
expect(input).toBeDefined();
|
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 () => {
|
it("calls onQuickCreate with description when Enter is pressed", async () => {
|
||||||
const mockOnQuickCreate = vi.fn().mockResolvedValue(undefined);
|
const mockOnQuickCreate = vi.fn().mockResolvedValue(undefined);
|
||||||
renderListView({ onQuickCreate: mockOnQuickCreate });
|
renderListView({ onQuickCreate: mockOnQuickCreate });
|
||||||
|
|||||||
Reference in New Issue
Block a user