Files
paperclip/ui/src/components/KanbanBoard.test.tsx
T
Emad Ibrahim afb73ba553 Scale issue kanban board for high-volume columns (#5309)
## Thinking Path

> - Paperclip is a control plane for autonomous AI-agent companies, and
the board UI needs to keep operator visibility clear as company work
scales.
> - The involved subsystem is the Issues page board mode, specifically
the Kanban rendering path for issue status columns.
> - The current board keeps the classic Kanban model, but high-volume
columns can become tall, slow, and hard to scan when hundreds of issues
are loaded.
> - We explored alternatives and chose the conservative Scaled Kanban
direction: preserve status lanes and drag/drop, but bound visible cards
and collapse low-signal lanes.
> - This pull request adds UI-only density controls and high-volume
defaults rather than introducing schema or API changes.
> - The benefit is a board that remains usable with large issue
inventories while keeping active workflow lanes visible.

## What Changed

- Added scaled Kanban behavior with compact cards, collapsed cold-lane
rails, per-column visible-card limits, and per-column "show more" reveal
controls.
- Added persisted board density preferences to the Issues page view
state, scoped through the existing company-specific localStorage path.
- Added board toolbar controls for compact cards, collapsed cold lanes,
cards-per-column page size (`10`, `25`, `50`), and density reset.
- Added a design spec and implementation plan under `doc/plans/`.
- Added focused Vitest coverage for `KanbanBoard` and `IssuesList`
high-volume board behavior.

## Verification

- `pnpm exec vitest run ui/src/components/IssuesList.test.tsx
ui/src/components/KanbanBoard.test.tsx` — pass, 35 tests.
- `pnpm -r typecheck` — pass.
- `pnpm build` — pass before the upstream merge; not rerun after
docs/assets cleanup.
- `curl -fsS http://127.0.0.1:3100/api/health` — pass against restarted
local dev server after applying pending migration
`0078_white_darwin.sql`.
- `pnpm test:run` — previously failed in unrelated Cursor remote-sandbox
server tests:
- `server/src/__tests__/cursor-local-adapter-environment.test.ts`:
expected probe status `pass`, received `fail`.
- `server/src/__tests__/cursor-local-execute.test.ts`: two remote
sandbox execution cases exited `127` instead of `0`.

Local dev server for manual UI inspection: `http://127.0.0.1:3100`.

Screenshots were captured for review and attached in the PR thread
rather than committed to source.

## Risks

- Low schema/API risk: this is UI-only and uses the existing issue data
path.
- Board users may need to notice the new density controls if they want
to override high-volume defaults.
- Collapsed cold lanes remain valid drop targets, so status moves can
happen without expanding the destination lane.
- Very large remote columns can still hit the existing 200-item
per-column query cap; this PR improves rendering, not server-side board
pagination.

> For core feature work, check [`ROADMAP.md`](ROADMAP.md) first and
discuss it in `#dev` before opening the PR. Feature PRs that overlap
with planned core work may need to be redirected — check the roadmap
first. See `CONTRIBUTING.md`.

## Model Used

- OpenAI Codex coding agent based on GPT-5, with repository tool use,
shell execution, local test/build execution, and inline implementation
planning. No subagents were used.

## Checklist

- [x] I have included a thinking path that traces from project context
to this change
- [x] I have specified the model used (with version and capability
details)
- [x] I have checked ROADMAP.md and confirmed this PR does not duplicate
planned core work
- [x] I have run tests locally and they pass
- [x] I have added or updated tests where applicable
- [x] If this change affects the UI, I have included before/after
screenshots
- [x] I have updated relevant documentation to reflect my changes
- [x] I have considered and documented any risks above
- [x] I will address all Greptile and reviewer comments before
requesting merge
2026-05-15 10:53:09 -05:00

199 lines
5.8 KiB
TypeScript

// @vitest-environment jsdom
import { act } from "react";
import { createRoot } from "react-dom/client";
import type { Issue, IssueStatus } from "@paperclipai/shared";
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import { KanbanBoard, resolveKanbanTargetStatus } from "./KanbanBoard";
vi.mock("@/lib/router", () => ({
Link: ({
children,
to,
disableIssueQuicklook: _disableIssueQuicklook,
...props
}: React.AnchorHTMLAttributes<HTMLAnchorElement> & {
to: string;
disableIssueQuicklook?: boolean;
}) => (
<a href={to} {...props}>{children}</a>
),
}));
// eslint-disable-next-line @typescript-eslint/no-explicit-any
(globalThis as any).IS_REACT_ACT_ENVIRONMENT = true;
function createIssue(index: number, status: IssueStatus): Issue {
return {
id: `issue-${status}-${index}`,
identifier: `PAP-${index}`,
companyId: "company-1",
projectId: null,
projectWorkspaceId: null,
goalId: null,
parentId: null,
title: `Issue ${index}`,
description: null,
status,
workMode: "standard",
priority: "medium",
assigneeAgentId: index === 1 ? "agent-1" : null,
assigneeUserId: null,
createdByAgentId: null,
createdByUserId: null,
issueNumber: index,
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-05-05T00:00:00.000Z"),
updatedAt: new Date("2026-05-05T00:00:00.000Z"),
labels: [],
labelIds: [],
myLastTouchAt: null,
lastExternalCommentAt: null,
lastActivityAt: null,
isUnreadForMe: false,
};
}
function createIssues(count: number, status: IssueStatus): Issue[] {
return Array.from({ length: count }, (_, index) => createIssue(index + 1, status));
}
function renderBoard(
props: Partial<React.ComponentProps<typeof KanbanBoard>> & { issues: Issue[] },
) {
const container = document.createElement("div");
document.body.appendChild(container);
const root = createRoot(container);
const render = (nextProps: Partial<React.ComponentProps<typeof KanbanBoard>> & { issues: Issue[] }) => {
act(() => {
root.render(
<KanbanBoard
agents={[{ id: "agent-1", name: "Codex" }]}
liveIssueIds={new Set(["issue-todo-1"])}
onUpdateIssue={vi.fn()}
{...nextProps}
/>,
);
});
};
render(props);
return { container, root, render };
}
describe("KanbanBoard", () => {
beforeEach(() => {
document.body.innerHTML = "";
});
afterEach(() => {
document.body.innerHTML = "";
});
it("limits visible cards and reveals more cards per column", () => {
const { container } = renderBoard({
issues: createIssues(60, "todo"),
compactCards: true,
initialVisibleCount: 50,
revealIncrement: 50,
});
expect(container.textContent).toContain("Showing 50 of 60");
expect(container.textContent).toContain("Show 10 more");
expect(container.textContent).toContain("Issue 50");
expect(container.textContent).not.toContain("Issue 51");
const showMoreButton = Array.from(container.querySelectorAll("button")).find((button) =>
button.textContent?.includes("Show 10 more"),
);
expect(showMoreButton).toBeTruthy();
act(() => {
showMoreButton?.dispatchEvent(new MouseEvent("click", { bubbles: true }));
});
expect(container.textContent).toContain("Issue 60");
expect(container.textContent).not.toContain("Show 10 more");
});
it("resets visible counts when the column page size changes", () => {
const issues = createIssues(60, "todo");
const { container, render } = renderBoard({
issues,
initialVisibleCount: 50,
revealIncrement: 50,
});
const showMoreButton = Array.from(container.querySelectorAll("button")).find((button) =>
button.textContent?.includes("Show 10 more"),
);
expect(showMoreButton).toBeTruthy();
act(() => {
showMoreButton?.dispatchEvent(new MouseEvent("click", { bubbles: true }));
});
expect(container.textContent).toContain("Issue 60");
render({
issues,
initialVisibleCount: 10,
revealIncrement: 10,
});
expect(container.textContent).toContain("Showing 10 of 60");
expect(container.textContent).toContain("Show 10 more");
expect(container.textContent).toContain("Issue 10");
expect(container.textContent).not.toContain("Issue 11");
});
it("renders collapsed statuses as rails without cards", () => {
const { container } = renderBoard({
issues: createIssues(3, "done"),
collapsedStatuses: ["done"],
});
expect(container.textContent).toContain("Done");
expect(container.textContent).toContain("3");
expect(container.textContent).not.toContain("Issue 1");
});
it("keeps core issue signals in compact cards", () => {
const { container } = renderBoard({
issues: createIssues(1, "todo"),
compactCards: true,
});
expect(container.textContent).toContain("PAP-1");
expect(container.textContent).toContain("Issue 1");
expect(container.textContent).toContain("Codex");
expect(container.textContent).toContain("Live");
});
it("resolves drop targets from status rails and cards", () => {
const issues = [
createIssue(1, "todo"),
createIssue(2, "blocked"),
];
expect(resolveKanbanTargetStatus("done", issues)).toBe("done");
expect(resolveKanbanTargetStatus("issue-blocked-2", issues)).toBe("blocked");
expect(resolveKanbanTargetStatus("missing", issues)).toBeNull();
});
});