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:
Dustin Byrne
2026-03-28 02:29:45 -04:00
parent 3aa2ec743d
commit 40dacb49d3
5 changed files with 232 additions and 41 deletions

View File

@@ -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>

View File

@@ -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">

View File

@@ -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");
});
});

View File

@@ -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);
});
});
}); });

View File

@@ -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;