feat(KB-156): add search input to dependency dropdowns
- Add searchable text input to TaskDetailModal dependency dropdown with filtering by id, title, and description - Add matching search input to InlineCreateCard dependency dropdown - Add sticky .dep-dropdown-search CSS with focus styling - Reset search term when dropdown closes and reopens - Add tests for search filtering, case-insensitive matching, empty state, and reset behavior
This commit is contained in:
@@ -22,6 +22,7 @@ export function InlineCreateCard({ tasks, onSubmit, onCancel, addToast }: Inline
|
|||||||
const [description, setDescription] = useState("");
|
const [description, setDescription] = useState("");
|
||||||
const [dependencies, setDependencies] = useState<string[]>([]);
|
const [dependencies, setDependencies] = useState<string[]>([]);
|
||||||
const [showDeps, setShowDeps] = useState(false);
|
const [showDeps, setShowDeps] = useState(false);
|
||||||
|
const [depSearch, setDepSearch] = useState("");
|
||||||
const [submitting, setSubmitting] = useState(false);
|
const [submitting, setSubmitting] = useState(false);
|
||||||
const [pendingImages, setPendingImages] = useState<PendingImage[]>([]);
|
const [pendingImages, setPendingImages] = useState<PendingImage[]>([]);
|
||||||
const inputRef = useRef<HTMLTextAreaElement>(null);
|
const inputRef = useRef<HTMLTextAreaElement>(null);
|
||||||
@@ -31,6 +32,10 @@ export function InlineCreateCard({ tasks, onSubmit, onCancel, addToast }: Inline
|
|||||||
inputRef.current?.focus();
|
inputRef.current?.focus();
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!showDeps) setDepSearch("");
|
||||||
|
}, [showDeps]);
|
||||||
|
|
||||||
// Cancel when focus leaves the card entirely and there's no content
|
// Cancel when focus leaves the card entirely and there's no content
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const card = cardRef.current;
|
const card = cardRef.current;
|
||||||
@@ -190,24 +195,42 @@ export function InlineCreateCard({ tasks, onSubmit, onCancel, addToast }: Inline
|
|||||||
>
|
>
|
||||||
<Link size={12} style={{ verticalAlign: 'middle' }} />{dependencies.length > 0 ? ` ${dependencies.length} deps` : " Deps"}
|
<Link size={12} style={{ verticalAlign: 'middle' }} />{dependencies.length > 0 ? ` ${dependencies.length} deps` : " Deps"}
|
||||||
</button>
|
</button>
|
||||||
{showDeps && (
|
{showDeps && (() => {
|
||||||
<div className="dep-dropdown">
|
const term = depSearch.toLowerCase();
|
||||||
{tasks.length === 0 ? (
|
const filtered = term
|
||||||
<div className="dep-dropdown-empty">No existing tasks</div>
|
? tasks.filter((t) =>
|
||||||
) : (
|
t.id.toLowerCase().includes(term) ||
|
||||||
tasks.map((t) => (
|
(t.title && t.title.toLowerCase().includes(term)) ||
|
||||||
<div
|
(t.description && t.description.toLowerCase().includes(term))
|
||||||
key={t.id}
|
)
|
||||||
className={`dep-dropdown-item${dependencies.includes(t.id) ? " selected" : ""}`}
|
: tasks;
|
||||||
onClick={() => toggleDep(t.id)}
|
return (
|
||||||
>
|
<div className="dep-dropdown">
|
||||||
<span className="dep-dropdown-id">{t.id}</span>
|
<input
|
||||||
<span className="dep-dropdown-title">{truncate(t.title || t.description || t.id, 30)}</span>
|
className="dep-dropdown-search"
|
||||||
</div>
|
placeholder="Search tasks…"
|
||||||
))
|
autoFocus
|
||||||
)}
|
value={depSearch}
|
||||||
</div>
|
onChange={(e) => setDepSearch(e.target.value)}
|
||||||
)}
|
onClick={(e) => e.stopPropagation()}
|
||||||
|
/>
|
||||||
|
{filtered.length === 0 ? (
|
||||||
|
<div className="dep-dropdown-empty">No existing tasks</div>
|
||||||
|
) : (
|
||||||
|
filtered.map((t) => (
|
||||||
|
<div
|
||||||
|
key={t.id}
|
||||||
|
className={`dep-dropdown-item${dependencies.includes(t.id) ? " selected" : ""}`}
|
||||||
|
onClick={() => toggleDep(t.id)}
|
||||||
|
>
|
||||||
|
<span className="dep-dropdown-id">{t.id}</span>
|
||||||
|
<span className="dep-dropdown-title">{truncate(t.title || t.description || t.id, 30)}</span>
|
||||||
|
</div>
|
||||||
|
))
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
})()}
|
||||||
</div>
|
</div>
|
||||||
<span className="inline-create-hint">Enter to create · Esc to cancel</span>
|
<span className="inline-create-hint">Enter to create · Esc to cancel</span>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -59,6 +59,10 @@ export function TaskDetailModal({
|
|||||||
const [uploading, setUploading] = useState(false);
|
const [uploading, setUploading] = useState(false);
|
||||||
const [dependencies, setDependencies] = useState<string[]>(task.dependencies || []);
|
const [dependencies, setDependencies] = useState<string[]>(task.dependencies || []);
|
||||||
const [showDepDropdown, setShowDepDropdown] = useState(false);
|
const [showDepDropdown, setShowDepDropdown] = useState(false);
|
||||||
|
const [depSearch, setDepSearch] = useState("");
|
||||||
|
useEffect(() => {
|
||||||
|
if (!showDepDropdown) setDepSearch("");
|
||||||
|
}, [showDepDropdown]);
|
||||||
const fileInputRef = useRef<HTMLInputElement>(null);
|
const fileInputRef = useRef<HTMLInputElement>(null);
|
||||||
const { entries: agentLogEntries, loading: agentLogLoading } = useAgentLogs(
|
const { entries: agentLogEntries, loading: agentLogLoading } = useAgentLogs(
|
||||||
task.id,
|
task.id,
|
||||||
@@ -419,27 +423,45 @@ export function TaskDetailModal({
|
|||||||
>
|
>
|
||||||
Add Dependency
|
Add Dependency
|
||||||
</button>
|
</button>
|
||||||
{showDepDropdown && (
|
{showDepDropdown && (() => {
|
||||||
<div className="dep-dropdown">
|
const term = depSearch.toLowerCase();
|
||||||
{availableTasks.length === 0 ? (
|
const filtered = term
|
||||||
<div className="dep-dropdown-empty">No available tasks</div>
|
? availableTasks.filter((t) =>
|
||||||
) : (
|
t.id.toLowerCase().includes(term) ||
|
||||||
availableTasks.map((t) => (
|
(t.title && t.title.toLowerCase().includes(term)) ||
|
||||||
<div
|
(t.description && t.description.toLowerCase().includes(term))
|
||||||
key={t.id}
|
)
|
||||||
className="dep-dropdown-item"
|
: availableTasks;
|
||||||
onClick={() => {
|
return (
|
||||||
handleAddDep(t.id);
|
<div className="dep-dropdown">
|
||||||
setShowDepDropdown(false);
|
<input
|
||||||
}}
|
className="dep-dropdown-search"
|
||||||
>
|
placeholder="Search tasks…"
|
||||||
<span className="dep-dropdown-id">{t.id}</span>
|
autoFocus
|
||||||
<span className="dep-dropdown-title">{truncate(t.title || t.description || t.id, 30)}</span>
|
value={depSearch}
|
||||||
</div>
|
onChange={(e) => setDepSearch(e.target.value)}
|
||||||
))
|
onClick={(e) => e.stopPropagation()}
|
||||||
)}
|
/>
|
||||||
</div>
|
{filtered.length === 0 ? (
|
||||||
)}
|
<div className="dep-dropdown-empty">No available tasks</div>
|
||||||
|
) : (
|
||||||
|
filtered.map((t) => (
|
||||||
|
<div
|
||||||
|
key={t.id}
|
||||||
|
className="dep-dropdown-item"
|
||||||
|
onClick={() => {
|
||||||
|
handleAddDep(t.id);
|
||||||
|
setShowDepDropdown(false);
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<span className="dep-dropdown-id">{t.id}</span>
|
||||||
|
<span className="dep-dropdown-title">{truncate(t.title || t.description || t.id, 30)}</span>
|
||||||
|
</div>
|
||||||
|
))
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
})()}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div className="detail-section detail-activity">
|
<div className="detail-section detail-activity">
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
import { describe, it, expect, vi } from "vitest";
|
import { describe, it, expect, vi } from "vitest";
|
||||||
import { render, screen, fireEvent } from "@testing-library/react";
|
import { render, screen, fireEvent } from "@testing-library/react";
|
||||||
import { InlineCreateCard } from "../InlineCreateCard";
|
import { InlineCreateCard } from "../InlineCreateCard";
|
||||||
|
import type { Task, Column } from "@kb/core";
|
||||||
|
|
||||||
// Mock lucide-react
|
// Mock lucide-react
|
||||||
vi.mock("lucide-react", () => ({
|
vi.mock("lucide-react", () => ({
|
||||||
@@ -12,9 +13,9 @@ vi.mock("../../api", () => ({
|
|||||||
uploadAttachment: vi.fn(),
|
uploadAttachment: vi.fn(),
|
||||||
}));
|
}));
|
||||||
|
|
||||||
function renderCard() {
|
function renderCard(tasks: Task[] = []) {
|
||||||
const props = {
|
const props = {
|
||||||
tasks: [],
|
tasks,
|
||||||
onSubmit: vi.fn().mockResolvedValue({ id: "KB-001" }),
|
onSubmit: vi.fn().mockResolvedValue({ id: "KB-001" }),
|
||||||
onCancel: vi.fn(),
|
onCancel: vi.fn(),
|
||||||
addToast: vi.fn(),
|
addToast: vi.fn(),
|
||||||
@@ -65,3 +66,30 @@ describe("InlineCreateCard blur-to-cancel", () => {
|
|||||||
expect(props.onCancel).toHaveBeenCalledTimes(1);
|
expect(props.onCancel).toHaveBeenCalledTimes(1);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
describe("InlineCreateCard dependency dropdown search", () => {
|
||||||
|
const testTasks: Task[] = [
|
||||||
|
{ id: "KB-001", title: "Fix login", description: "Login page broken", column: "todo" as Column, dependencies: [], steps: [], currentStep: 0, log: [], createdAt: "2026-01-01T00:00:00Z", updatedAt: "2026-01-01T00:00:00Z" },
|
||||||
|
{ id: "KB-002", title: "Add dark mode", description: "Theme support", column: "todo" as Column, dependencies: [], steps: [], currentStep: 0, log: [], createdAt: "2026-02-01T00:00:00Z", updatedAt: "2026-02-01T00:00:00Z" },
|
||||||
|
{ id: "KB-003", title: "Refactor API", description: "Clean up endpoints", column: "todo" as Column, dependencies: [], steps: [], currentStep: 0, log: [], createdAt: "2026-03-01T00:00:00Z", updatedAt: "2026-03-01T00:00:00Z" },
|
||||||
|
];
|
||||||
|
|
||||||
|
it("shows search input when dropdown is opened", () => {
|
||||||
|
renderCard(testTasks);
|
||||||
|
fireEvent.click(screen.getByText(/Deps/));
|
||||||
|
const input = document.querySelector(".dep-dropdown-search") as HTMLInputElement;
|
||||||
|
expect(input).toBeTruthy();
|
||||||
|
expect(input.placeholder).toBe("Search tasks…");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("filters tasks by search term", () => {
|
||||||
|
renderCard(testTasks);
|
||||||
|
fireEvent.click(screen.getByText(/Deps/));
|
||||||
|
const input = document.querySelector(".dep-dropdown-search") as HTMLInputElement;
|
||||||
|
fireEvent.change(input, { target: { value: "dark" } });
|
||||||
|
|
||||||
|
const items = document.querySelectorAll(".dep-dropdown-item");
|
||||||
|
expect(items).toHaveLength(1);
|
||||||
|
expect(items[0].querySelector(".dep-dropdown-id")?.textContent).toBe("KB-002");
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|||||||
@@ -619,4 +619,98 @@ describe("TaskDetailModal", () => {
|
|||||||
expect(afterSwitch[1]).toBe(true);
|
expect(afterSwitch[1]).toBe(true);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
describe("dependency dropdown search", () => {
|
||||||
|
const searchTasks: Task[] = [
|
||||||
|
{ id: "KB-010", title: "Fix login bug", description: "Users cannot log in", column: "todo" as Column, dependencies: [], steps: [], currentStep: 0, log: [], createdAt: "2026-01-01T00:00:00Z", updatedAt: "2026-01-01T00:00:00Z" },
|
||||||
|
{ id: "KB-020", title: "Add dark mode", description: "Theme support", column: "todo" as Column, dependencies: [], steps: [], currentStep: 0, log: [], createdAt: "2026-02-01T00:00:00Z", updatedAt: "2026-02-01T00:00:00Z" },
|
||||||
|
{ id: "KB-030", title: "Refactor API", description: "Clean up endpoints", column: "todo" as Column, dependencies: [], steps: [], currentStep: 0, log: [], createdAt: "2026-03-01T00:00:00Z", updatedAt: "2026-03-01T00:00:00Z" },
|
||||||
|
{ id: "KB-099", description: "Self", column: "in-progress" as Column, dependencies: [], steps: [], currentStep: 0, log: [], createdAt: "2026-03-15T00:00:00Z", updatedAt: "2026-03-15T00:00:00Z" },
|
||||||
|
];
|
||||||
|
|
||||||
|
function renderWithSearch(taskOverrides: Partial<TaskDetail> = {}) {
|
||||||
|
return render(
|
||||||
|
<TaskDetailModal
|
||||||
|
task={makeTask(taskOverrides)}
|
||||||
|
tasks={searchTasks}
|
||||||
|
onClose={noop}
|
||||||
|
onMoveTask={noopMove}
|
||||||
|
onDeleteTask={noopDelete}
|
||||||
|
onMergeTask={noopMerge}
|
||||||
|
addToast={noop}
|
||||||
|
/>,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
it("shows search input when dropdown is opened", () => {
|
||||||
|
renderWithSearch();
|
||||||
|
fireEvent.click(screen.getByText("Add Dependency"));
|
||||||
|
const input = document.querySelector(".dep-dropdown-search") as HTMLInputElement;
|
||||||
|
expect(input).toBeTruthy();
|
||||||
|
expect(input.placeholder).toBe("Search tasks…");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("filters tasks by search term", () => {
|
||||||
|
renderWithSearch();
|
||||||
|
fireEvent.click(screen.getByText("Add Dependency"));
|
||||||
|
const input = document.querySelector(".dep-dropdown-search") as HTMLInputElement;
|
||||||
|
fireEvent.change(input, { target: { value: "login" } });
|
||||||
|
|
||||||
|
const items = document.querySelectorAll(".dep-dropdown-item");
|
||||||
|
expect(items).toHaveLength(1);
|
||||||
|
expect(items[0].querySelector(".dep-dropdown-id")?.textContent).toBe("KB-010");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("matches task ID case-insensitively", () => {
|
||||||
|
renderWithSearch();
|
||||||
|
fireEvent.click(screen.getByText("Add Dependency"));
|
||||||
|
const input = document.querySelector(".dep-dropdown-search") as HTMLInputElement;
|
||||||
|
fireEvent.change(input, { target: { value: "kb-020" } });
|
||||||
|
|
||||||
|
const items = document.querySelectorAll(".dep-dropdown-item");
|
||||||
|
expect(items).toHaveLength(1);
|
||||||
|
expect(items[0].querySelector(".dep-dropdown-id")?.textContent).toBe("KB-020");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("matches task title", () => {
|
||||||
|
renderWithSearch();
|
||||||
|
fireEvent.click(screen.getByText("Add Dependency"));
|
||||||
|
const input = document.querySelector(".dep-dropdown-search") as HTMLInputElement;
|
||||||
|
fireEvent.change(input, { target: { value: "dark mode" } });
|
||||||
|
|
||||||
|
const items = document.querySelectorAll(".dep-dropdown-item");
|
||||||
|
expect(items).toHaveLength(1);
|
||||||
|
expect(items[0].querySelector(".dep-dropdown-id")?.textContent).toBe("KB-020");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("shows empty state when search matches nothing", () => {
|
||||||
|
renderWithSearch();
|
||||||
|
fireEvent.click(screen.getByText("Add Dependency"));
|
||||||
|
const input = document.querySelector(".dep-dropdown-search") as HTMLInputElement;
|
||||||
|
fireEvent.change(input, { target: { value: "zzz-nonexistent" } });
|
||||||
|
|
||||||
|
const items = document.querySelectorAll(".dep-dropdown-item");
|
||||||
|
expect(items).toHaveLength(0);
|
||||||
|
expect(document.querySelector(".dep-dropdown-empty")?.textContent).toBe("No available tasks");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("resets search when dropdown closes and reopens", () => {
|
||||||
|
renderWithSearch();
|
||||||
|
fireEvent.click(screen.getByText("Add Dependency"));
|
||||||
|
const input = document.querySelector(".dep-dropdown-search") as HTMLInputElement;
|
||||||
|
fireEvent.change(input, { target: { value: "login" } });
|
||||||
|
expect(input.value).toBe("login");
|
||||||
|
|
||||||
|
// Close by clicking again
|
||||||
|
fireEvent.click(screen.getByText("Add Dependency"));
|
||||||
|
expect(document.querySelector(".dep-dropdown")).toBeNull();
|
||||||
|
|
||||||
|
// Reopen
|
||||||
|
fireEvent.click(screen.getByText("Add Dependency"));
|
||||||
|
const newInput = document.querySelector(".dep-dropdown-search") as HTMLInputElement;
|
||||||
|
expect(newInput.value).toBe("");
|
||||||
|
// All items visible again
|
||||||
|
expect(document.querySelectorAll(".dep-dropdown-item")).toHaveLength(3);
|
||||||
|
});
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -1134,6 +1134,30 @@ html, body {
|
|||||||
box-shadow: var(--shadow);
|
box-shadow: var(--shadow);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.dep-dropdown-search {
|
||||||
|
position: sticky;
|
||||||
|
top: 0;
|
||||||
|
width: 100%;
|
||||||
|
box-sizing: border-box;
|
||||||
|
padding: 6px 12px;
|
||||||
|
font-size: 12px;
|
||||||
|
font-family: inherit;
|
||||||
|
background: var(--surface);
|
||||||
|
border: none;
|
||||||
|
border-bottom: 1px solid var(--border);
|
||||||
|
color: var(--text);
|
||||||
|
outline: none;
|
||||||
|
z-index: 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
.dep-dropdown-search::placeholder {
|
||||||
|
color: var(--text-dim);
|
||||||
|
}
|
||||||
|
|
||||||
|
.dep-dropdown-search:focus {
|
||||||
|
border-bottom-color: var(--accent, #58a6ff);
|
||||||
|
}
|
||||||
|
|
||||||
.dep-dropdown-empty {
|
.dep-dropdown-empty {
|
||||||
padding: 10px 12px;
|
padding: 10px 12px;
|
||||||
font-size: 12px;
|
font-size: 12px;
|
||||||
|
|||||||
Reference in New Issue
Block a user