test: extract buildIssueTree utility and add tests for hierarchy logic

Extract the inline tree-building logic from IssuesList into a pure
`buildIssueTree` function in lib/issue-tree.ts so it can be unit tested.
Add six tests covering: flat lists, parent-child grouping, multi-level
nesting, orphaned sub-tasks promoted to root, empty input, and list
order preservation.

Add two tests to IssueRow.test.tsx covering the new titleSuffix prop:
renders inline after the title when provided, and renders cleanly when
omitted.

Co-Authored-By: Paperclip <noreply@paperclip.ing>
This commit is contained in:
Darren Davison
2026-04-04 12:29:25 +01:00
parent b380d6000f
commit 9be1b3f8a9
4 changed files with 165 additions and 10 deletions
+38
View File
@@ -136,4 +136,42 @@ describe("IssueRow", () => {
root.unmount();
});
});
it("renders titleSuffix inline after the issue title", () => {
const root = createRoot(container);
const issue = createIssue({ title: "Parent task" });
act(() => {
root.render(
<IssueRow
issue={issue}
titleSuffix={<span data-testid="suffix">(3 sub-tasks)</span>}
/>,
);
});
const titleEl = container.querySelector(".line-clamp-2, .truncate");
expect(titleEl?.textContent).toContain("Parent task");
expect(titleEl?.textContent).toContain("(3 sub-tasks)");
expect(container.querySelector('[data-testid="suffix"]')).not.toBeNull();
act(() => {
root.unmount();
});
});
it("renders without error when titleSuffix is omitted", () => {
const root = createRoot(container);
act(() => {
root.render(<IssueRow issue={createIssue()} />);
});
const titleEl = container.querySelector(".line-clamp-2, .truncate");
expect(titleEl?.textContent).toContain("Inbox item");
act(() => {
root.unmount();
});
});
});
+2 -10
View File
@@ -23,6 +23,7 @@ import { Checkbox } from "@/components/ui/checkbox";
import { Collapsible, CollapsibleTrigger, CollapsibleContent } from "@/components/ui/collapsible";
import { CircleDot, Plus, Filter, ArrowUpDown, Layers, Check, X, ChevronRight, List, Columns3, User, Search } from "lucide-react";
import { KanbanBoard } from "./KanbanBoard";
import { buildIssueTree } from "../lib/issue-tree";
import type { Issue } from "@paperclipai/shared";
/* ── Helpers ── */
@@ -667,16 +668,7 @@ export function IssuesList({
)}
<CollapsibleContent>
{(() => {
const itemIds = new Set(group.items.map((i) => i.id));
const roots = group.items.filter((i) => !i.parentId || !itemIds.has(i.parentId));
const childMap = new Map<string, Issue[]>();
for (const item of group.items) {
if (item.parentId && itemIds.has(item.parentId)) {
const arr = childMap.get(item.parentId) ?? [];
arr.push(item);
childMap.set(item.parentId, arr);
}
}
const { roots, childMap } = buildIssueTree(group.items);
const renderIssueRow = (issue: Issue, depth: number) => {
const children = childMap.get(issue.id) ?? [];
+98
View File
@@ -0,0 +1,98 @@
import { describe, expect, it } from "vitest";
import type { Issue } from "@paperclipai/shared";
import { buildIssueTree } from "./issue-tree";
function makeIssue(id: string, parentId: string | null = null): Issue {
return {
id,
identifier: id.toUpperCase(),
companyId: "company-1",
projectId: null,
projectWorkspaceId: null,
goalId: null,
parentId,
title: `Issue ${id}`,
description: null,
status: "todo",
priority: "medium",
assigneeAgentId: null,
assigneeUserId: null,
createdByAgentId: null,
createdByUserId: null,
issueNumber: 1,
requestDepth: 0,
billingCode: null,
assigneeAdapterOverrides: null,
executionWorkspaceId: null,
executionWorkspacePreference: null,
executionWorkspaceSettings: null,
checkoutRunId: null,
executionRunId: null,
executionAgentNameKey: null,
executionLockedAt: null,
startedAt: null,
completedAt: null,
cancelledAt: null,
hiddenAt: null,
createdAt: new Date("2026-01-01T00:00:00.000Z"),
updatedAt: new Date("2026-01-01T00:00:00.000Z"),
labels: [],
labelIds: [],
myLastTouchAt: null,
lastExternalCommentAt: null,
isUnreadForMe: false,
};
}
describe("buildIssueTree", () => {
it("returns all items as roots when no parent-child relationships exist", () => {
const items = [makeIssue("a"), makeIssue("b"), makeIssue("c")];
const { roots, childMap } = buildIssueTree(items);
expect(roots.map((r) => r.id)).toEqual(["a", "b", "c"]);
expect(childMap.size).toBe(0);
});
it("places children under their parent and excludes them from roots", () => {
const parent = makeIssue("parent");
const child1 = makeIssue("child1", "parent");
const child2 = makeIssue("child2", "parent");
const { roots, childMap } = buildIssueTree([parent, child1, child2]);
expect(roots.map((r) => r.id)).toEqual(["parent"]);
expect(childMap.get("parent")?.map((c) => c.id)).toEqual(["child1", "child2"]);
});
it("handles multiple levels of nesting", () => {
const grandparent = makeIssue("gp");
const parent = makeIssue("p", "gp");
const child = makeIssue("c", "p");
const { roots, childMap } = buildIssueTree([grandparent, parent, child]);
expect(roots.map((r) => r.id)).toEqual(["gp"]);
expect(childMap.get("gp")?.map((i) => i.id)).toEqual(["p"]);
expect(childMap.get("p")?.map((i) => i.id)).toEqual(["c"]);
});
it("promotes orphaned sub-tasks (parent not in list) to root level", () => {
// child references a parent that is not in the items array (e.g. filtered out)
const child = makeIssue("child", "missing-parent");
const unrelated = makeIssue("unrelated");
const { roots, childMap } = buildIssueTree([child, unrelated]);
expect(roots.map((r) => r.id)).toEqual(["child", "unrelated"]);
expect(childMap.size).toBe(0);
});
it("returns empty roots and empty childMap for an empty list", () => {
const { roots, childMap } = buildIssueTree([]);
expect(roots).toEqual([]);
expect(childMap.size).toBe(0);
});
it("preserves list order within roots and within children", () => {
const p1 = makeIssue("p1");
const p2 = makeIssue("p2");
const c1 = makeIssue("c1", "p1");
const c2 = makeIssue("c2", "p1");
const { roots, childMap } = buildIssueTree([p1, c1, p2, c2]);
expect(roots.map((r) => r.id)).toEqual(["p1", "p2"]);
expect(childMap.get("p1")?.map((c) => c.id)).toEqual(["c1", "c2"]);
});
});
+27
View File
@@ -0,0 +1,27 @@
import type { Issue } from "@paperclipai/shared";
export interface IssueTree {
roots: Issue[];
childMap: Map<string, Issue[]>;
}
/**
* Builds a parent→children tree from a flat list of issues.
*
* - `roots` contains issues whose parent is absent from the list (or have no
* parent at all), so orphaned sub-tasks are always visible at root level.
* - `childMap` maps each parent id to its direct children in list order.
*/
export function buildIssueTree(items: Issue[]): IssueTree {
const itemIds = new Set(items.map((i) => i.id));
const roots = items.filter((i) => !i.parentId || !itemIds.has(i.parentId));
const childMap = new Map<string, Issue[]>();
for (const item of items) {
if (item.parentId && itemIds.has(item.parentId)) {
const arr = childMap.get(item.parentId) ?? [];
arr.push(item);
childMap.set(item.parentId, arr);
}
}
return { roots, childMap };
}