forked from farhoodlabs/paperclip
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:
@@ -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();
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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) ?? [];
|
||||
|
||||
@@ -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"]);
|
||||
});
|
||||
});
|
||||
@@ -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 };
|
||||
}
|
||||
Reference in New Issue
Block a user