Files
multica/packages/views/issues/surface/issue-surface.test.tsx
Naiyuan Qing e70e71fea1 fix(issues): repair the issue Table's column interactions, loading states and scroll rendering (#6036)
* fix(ui): make table column resizing land where the pointer is (MUL-5166)

Pressing the resize handle committed a width before any drag: it wrote the
rendered width, which fixed table-layout had stretched past the configured
one, so a stray click on a column edge silently rewrote and persisted that
column and stopped it adapting to the window. Dragging then ran ahead of the
pointer, because committing one width changes how much leftover space the
layout has to share out and rescales every other column mid-gesture. And the
gesture never ended if the pointer came up outside the window or the user
switched apps — the column kept tracking the pointer on return, with the page
stuck unselectable under a col-resize cursor.

- Require 4px of travel before anything is committed, matching the
  column-reorder sensor's activation distance on the same header.
- Pin every column to its rendered width on the frame the drag starts, so
  there is no leftover left to redistribute and the drag maps 1:1.
- Capture the pointer and end on blur / pointercancel / lostpointercapture,
  the same four-part contract the sidebar rail already uses.
- Own the cursor from a portaled full-viewport layer for the duration of the
  drag. document.body.style.cursor loses to any descendant that declares its
  own, which is why the cursor flickered over rows and text.

Resizing also re-rendered every cell each frame, and each cell carries a
popover, so a frame cost ~143ms. Widths now travel as custom properties
published once on the <table>, and the body is swapped for a memoized copy
while a drag is live — the browser applies each new width with no React work.

Visually the table had no column rules at all, which left the pinned columns'
trailing shadow as the only vertical line and made it read as a stray border
on one column. Every column now carries a rule, the pinned shadow appears
only once the surface is actually scrolled sideways, and the resize handle
brightens its rule to brand rather than matching the faint resting colour.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

* feat(issues): drag a table column by its header, not by a hidden grip

Reordering columns was advertised by a grip that only appeared once the
pointer was already on the header, and dragging it moved a wrapper the height
of its own line of text while the <th> around it clipped anything that
travelled outside the cell — so the column being moved looked like it had
vanished rather than slid.

The header is now the handle. There is no grip: the cursor carries the
affordance (grab, then grabbing), the whole cell responds, and only the
sort/hide button opts out, since a press there is always the menu. The
wrapper spans the cell's box at all times — the negative margins undo the
<th>'s padding and put it back inside — so what travels is a header-sized
block and going translucent is the entire drag state, matching how a desktop
tab behaves. Overflow is lifted for the length of a reorder and the column in
hand is raised over its neighbours.

Columns are restricted to the horizontal axis, the same constraint the
desktop tab bar puts on tab reordering, which is why packages/views now
declares @dnd-kit/modifiers directly.

Transforming the <th> itself would carry the header's height along for free,
but `transform` on a table cell is a corner of the spec browsers take
liberties with — Chromium lifts the cell out of the table's box model and its
geometry stops matching the row.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

* fix(issues): stop a dragged table column stretching into its neighbour's width

Dragging a column header applied dnd-kit's full transform, which carries
scaleX/scaleY alongside the translation. Those come from its layout animation:
old rect over new rect, tweening an item into the shape of the slot it lands
in. Between two tabs of equal width the ratio is 1 and never shows. Between
two columns it is not — swapping a 174px column with a 96px one stretched the
header to 1.8x on the way across. Only the horizontal translation is applied
now; reordering changes no column's width, so there is no shape for a tween to
describe. The travel and the settle stay animated through `transition`.

The travelling wrapper was also fixed at h-8 while the header strip measures
39px once row borders are counted, leaving the block short at both edges and
misaligned by 3px — which read as the same flattening. Its height is derived
from the cell now.

The reorder grip returns as the drag handle, appearing on hover as before and
staying visible for the length of a drag.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

* fix(ui): mark the frozen column boundary while the table is scrolled sideways

The edge where the frozen columns end had a shadow drawn inside the pinned
cell with `--border`. That token is tuned for hairlines between adjacent
surfaces — oklch .945 in light mode — and all but disappears once spread into
a shadow, so the boundary read as unmarked while content slid underneath it.
Darkening it in place only made the frozen column look outlined: an inset
shadow can shade that cell's own edge and nothing else, while the depth being
described belongs to the content passing beneath.

Split into the two things MUI X's data grid separates, a permanent border for
where the boundary is and a shadow for something crossing it right now:

- the rule between the frozen columns and the rest stays put, as before;
- a 12px gradient is cast past the frozen block, mounted outside the scroll
  container and shown only while scrolled sideways.

Its position is measured off the DOM — the trailing frozen header carries a
`data-pinned-edge` marker — rather than summed from column sizes. Fixed
table-layout stretches columns past their configured widths whenever the
container is wider than the table's min-width, so a sum lands the marker in
the middle of visible content.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

* feat(ui): size a table column to its content on double-click

Double-clicking a column's resize handle called column.resetSize, which
cleared the stored width and let TanStack fall back to its generic default of
150 — a number unrelated to any width this table was designed with. "Reset"
therefore widened priority from 130, collapsed labels from 220, and clamped
title to its 260 minimum. It restored nothing.

It now sizes the column to its widest rendered cell, the convention Excel,
Sheets, AG Grid and Notion all share for that gesture.

Fixed table-layout ignores content and the cells truncate their own text, so
nothing on screen reports the width the content wants. The measurement lifts
both constraints across the column's cells, reads them, and restores
everything within the same task, so the browser paints once — after the
restore — and the intermediate layout is never seen. Only the rows inside the
virtual window are measured.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

* fix(ui): stop the table header flickering black during scroll

The sticky header carried backdrop-blur over a translucent fill. A
backdrop-filter on a sticky element with content scrolling beneath it is a
known Chromium compositing fault — the blur layer recomputes its backdrop
every frame, and virtualisation is adding and removing the very rows it reads
from, so the strip flickers black under fast scrolling. Chromium's fix for the
same symptom was reverted, so upgrading does not carry it (electron#12906,
electron#45854, chromium#339841685).

The fill is now the opaque colour that bg-muted/30 was compositing to, which
is the mix the pinned header cells already use. A header the content scrolls
behind has no reason to show it through.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

* fix(issues): give the table's title column back the width its hover actions held

The sub-issue and rename buttons sat inline in the title cell at opacity 0.
Hidden is not absent: their boxes reserved around 40px of the column at all
times, in the one column with the least room to spare, for controls that only
appear on hover. They are positioned over the cell's trailing edge now, the
way SidebarMenuAction is, with a gradient fading the title running underneath
rather than icons sitting on top of it — the sidebar needs no gradient because
its labels are short, a title runs to the cell's edge. focus-within keeps them
reachable from the keyboard, where hover never fires.

Reordering a column also scrolled the table vertically. Modifiers constrain a
drag's movement but not its auto-scrolling, which reads raw pointer
coordinates, so a few pixels of vertical drift sent the rows moving under a
gesture that cannot act on them. Auto-scroll's y threshold is zero now; x
keeps it, since a table wider than its viewport needs it to reach a distant
slot, but at 0.05 rather than the default 0.2, which arms it a fifth of the
way in from either edge — most of a wide header.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

* feat(issues): show the table's own grid while its first page loads, and end its columns like every other surface

Two loading-state gaps, both in how the table's non-issue rows are modelled.

A cold load rendered a single "Loading…" line under a full header, which reads
as an empty table rather than a loading one. The surface-level skeleton meant
for it was unreachable: use-issue-surface-data hardcodes isLoading to false for
table, so the `mode === "table"` branch never ran — and it would not have
fitted, drawing rounded bars at p-2 where the table draws an edge-to-edge grid,
so switching it on would have traded one wrong state for a layout jump.

Placeholders are rows now, rendered through the ordinary cell renderer so they
inherit the real column widths, pinning and borders. Everything the table knows
before its data — header, toolbar, column layout — is up immediately, and the
rows swap in without moving anything. The unreachable surface branch is gone.

The end of a column was hand-rolled too, leaving the table the one surface
where a failed page read as muted body text rather than an error, where the
prompt sat left-aligned against three centred ones, and where reaching the end
of a paginated branch said nothing at all. The row carries its state rather
than a finished label and renders through ListLoadMoreFooter, the footer Board,
List and Swimlane already share — which exists, per its own comment, so those
states and their wording stay consistent.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

* fix(ui): measure table rows instead of assuming they are all one height

Virtualisation sized every row at the same 41px estimate, but the table does
not have one kind of row: group headers are 37px, an end-of-column footer
shorter still, and placeholders different again. Each one put the estimate a
few pixels out, and the error accumulates — with grouping on, the rendered
window drifts off the rows it should be showing and the scrollbar overshoots
the bottom.

Rows report their own height through the virtualizer's measureElement now, and
the estimate only covers rows that have never been mounted. Rows built by
renderRow are cloned to carry data-index and the measuring ref, so callers keep
returning a plain <tr> and still take part.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

* fix(issues): scope the title cell's actions to the title cell

The sub-issue and rename buttons keyed off the row's hover state, so pointing
anywhere along a row — a status chip, a date, empty space — put controls that
act on the title under a pointer that was somewhere else. They follow the
title cell's own hover now. The gradient behind them still tracks the row
colour, since that is what the cell is painted with while they are showing.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

* fix(ui): let group rows report their height, and re-measure the frozen edge on resize

Self-review of the branch turned up two places where a change did not reach as
far as it was supposed to.

Group rows never took part in the row measurement. DataTable clones the
virtualizer's ref and data-index onto whatever renderRow returns, but
IssueTableGroupRow declared only its own three props and absorbed them — and a
group header, being shorter than a data row, is exactly the row the
measurement was added for.

The frozen-column shadow measured its boundary from the scroll handler alone.
Resizing a frozen column while the surface is scrolled sideways moves that
boundary with no scroll event to follow, leaving the shadow behind at the old
position.

Also drops a dependency left behind when the load-more rows stopped building
their own labels.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-07-29 15:13:20 +08:00

685 lines
23 KiB
TypeScript

/**
* @vitest-environment jsdom
*/
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import {
act,
cleanup,
fireEvent,
render,
screen,
waitFor,
} from "@testing-library/react";
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
import { setApiInstance } from "@multica/core/api";
import type { ApiClient } from "@multica/core/api/client";
import { pruneIssueSurfaceViewStates } from "@multica/core/issues/stores/surface-view-store";
import type {
AgentTask,
Issue,
IssueTableRowsRequest,
ListIssuesParams,
ListIssuesResponse,
} from "@multica/core/types";
import { IssueSurface } from "./issue-surface";
import { statusTableMethodsFromLegacy } from "./status-table-test-api";
// Mutable so tests can simulate a workspace switch — the workspace layout
// does not remount its children on switch, so the surface must handle the
// wsId change itself.
const mockWsId = vi.hoisted(() => ({ current: "ws-1" }));
const mockTranslate = vi.hoisted(() => vi.fn(() => "translated"));
vi.mock("@multica/core/hooks", () => ({
useWorkspaceId: () => mockWsId.current,
}));
// The list/board virtualize their rows via react-virtuoso; jsdom has no layout
// so the real Virtuoso renders nothing (and throws on its resize plumbing).
// Render items inline so these surface-level loading-semantics assertions still
// see the issues the virtualized list would show.
vi.mock("react-virtuoso", () => ({
Virtuoso: ({ data, itemContent, components }: any) => (
<div data-testid="virtuoso-mock">
{(data ?? []).map((item: any, i: number) => (
<div key={i}>{itemContent(i, item)}</div>
))}
{components?.Footer ? <components.Footer /> : null}
</div>
),
}));
vi.mock("@tanstack/react-virtual", () => ({
useVirtualizer: ({ count, estimateSize }: any) => ({
getVirtualItems: () =>
Array.from({ length: count }, (_, index) => ({
index,
start: index * estimateSize(),
end: (index + 1) * estimateSize(),
})),
getTotalSize: () => count * estimateSize(),
}),
}));
const mockAuthUser = { id: "user-1", email: "test@test.com", name: "Test User" };
vi.mock("@multica/core/auth", () => ({
useAuthStore: Object.assign(
(selector?: (state: unknown) => unknown) => {
const state = { user: mockAuthUser, isAuthenticated: true };
return selector ? selector(state) : state;
},
{ getState: () => ({ user: mockAuthUser, isAuthenticated: true }) },
),
registerAuthStore: vi.fn(),
createAuthStore: vi.fn(),
}));
vi.mock("../../i18n", () => ({
// TableView also reads `i18n.language` for its date formatting.
useT: () => ({ t: mockTranslate, i18n: { language: "en" } }),
useTimeAgo: () => () => "now",
}));
vi.mock("../../navigation", () => ({
AppLink: ({ children, href, ...props }: React.ComponentProps<"a">) => (
<a href={href} {...props}>
{children}
</a>
),
useNavigation: () => ({ push: vi.fn(), pathname: "/" }),
}));
vi.mock("@multica/core/paths", async () => {
const actual = await vi.importActual<typeof import("@multica/core/paths")>(
"@multica/core/paths",
);
return {
...actual,
useCurrentWorkspace: () => ({ id: "ws-1", name: "Test WS", slug: "test" }),
useWorkspacePaths: () => actual.paths.workspace("test"),
};
});
function makeIssue(id: string, title: string, projectId: string): Issue {
return {
id,
workspace_id: "ws-1",
number: 1,
identifier: `MUL-${id}`,
title,
description: null,
status: "todo",
priority: "none",
assignee_type: null,
assignee_id: null,
creator_type: "member",
creator_id: "user-1",
parent_issue_id: null,
project_id: projectId,
position: 1,
stage: null,
start_date: null,
due_date: null,
labels: [],
metadata: {},
properties: {},
created_at: "2026-01-01T00:00:00Z",
updated_at: "2026-01-01T00:00:00Z",
};
}
function never<T>() {
return new Promise<T>(() => {});
}
function projectSurface(projectId: string) {
return (
<IssueSurface
scope={{ type: "project", projectId }}
modes={["list"]}
renderHeader={() => null}
renderLoading={() => <div data-testid="surface-loading" />}
batchToolbar="never"
/>
);
}
describe("IssueSurface — scope switch loading semantics", () => {
let qc: QueryClient;
beforeEach(() => {
mockWsId.current = "ws-1";
qc = new QueryClient({ defaultOptions: { queries: { retry: false } } });
// p1 answers immediately with one issue; p2 stays in flight forever so
// the test can observe the in-between state after switching.
const listIssues = vi.fn((params?: ListIssuesParams) => {
if (params?.project_id === "p2") return never<ListIssuesResponse>();
const issues =
params?.status === "todo" ? [makeIssue("i1", "P1 issue", "p1")] : [];
return Promise.resolve({ issues, total: issues.length });
});
setApiInstance({
listIssues,
...statusTableMethodsFromLegacy(listIssues),
listGroupedIssues: vi.fn(() => never()),
listProjects: vi.fn(() => never()),
getAgentTaskSnapshot: vi.fn(() => never<AgentTask[]>()),
getChildIssueProgress: vi.fn(() => never()),
} as unknown as ApiClient);
pruneIssueSurfaceViewStates([]);
});
afterEach(() => {
cleanup();
qc.clear();
pruneIssueSurfaceViewStates([]);
vi.restoreAllMocks();
});
it("shows loading — not the previous project's issues — while the next project is fetching", async () => {
// Regression: the list queries use `placeholderData: keepPreviousData` to
// keep sort/filter changes flicker-free WITHIN one surface. Without a
// scope-keyed remount, that placeholder leaks ACROSS surfaces: switching
// pinned projects kept rendering project A's cards (isLoading=false, so
// no skeleton either) until project B's response landed — the "click does
// nothing, then it snaps" bug.
const { rerender } = render(
<QueryClientProvider client={qc}>{projectSurface("p1")}</QueryClientProvider>,
);
await screen.findByText("P1 issue");
rerender(
<QueryClientProvider client={qc}>{projectSurface("p2")}</QueryClientProvider>,
);
// The switch must be honest: p2 has no data yet, so the surface is
// loading — p1's cards must not impersonate p2.
expect(screen.getByTestId("surface-loading")).toBeInTheDocument();
expect(screen.queryByText("P1 issue")).not.toBeInTheDocument();
});
it("shows a cached project instantly on switch-back (no loading flash)", async () => {
const { rerender } = render(
<QueryClientProvider client={qc}>{projectSurface("p1")}</QueryClientProvider>,
);
await screen.findByText("P1 issue");
rerender(
<QueryClientProvider client={qc}>{projectSurface("p2")}</QueryClientProvider>,
);
expect(screen.getByTestId("surface-loading")).toBeInTheDocument();
// Back to p1: its cache is warm, so the list renders immediately from
// cache — remounting must not degrade the instant-switch path.
rerender(
<QueryClientProvider client={qc}>{projectSurface("p1")}</QueryClientProvider>,
);
await waitFor(() =>
expect(screen.getByText("P1 issue")).toBeInTheDocument(),
);
expect(screen.queryByTestId("surface-loading")).not.toBeInTheDocument();
});
it("shows loading on a workspace switch even though the scope key is identical", async () => {
// The workspace layout does NOT remount children on switch, and two
// workspaces share the same scope key (e.g. "workspace:all") — so the
// remount key must include wsId, or workspace A's issues impersonate
// workspace B's while B is still fetching.
//
// A fresh element per render — reusing one element reference would let
// React bail out of re-rendering the subtree entirely, and the wsId
// change would never propagate.
const workspaceSurface = () => (
<IssueSurface
scope={{ type: "workspace" }}
modes={["list"]}
renderHeader={() => null}
renderLoading={() => <div data-testid="surface-loading" />}
batchToolbar="never"
/>
);
const listIssues = vi.fn((params?: ListIssuesParams) => {
const issues =
params?.status === "todo" ? [makeIssue("i1", "WS1 issue", "p1")] : [];
return Promise.resolve({ issues, total: issues.length });
});
setApiInstance({
listIssues,
...statusTableMethodsFromLegacy(listIssues),
listGroupedIssues: vi.fn(() => never()),
listProjects: vi.fn(() => never()),
getAgentTaskSnapshot: vi.fn(() => never<AgentTask[]>()),
getChildIssueProgress: vi.fn(() => never()),
} as unknown as ApiClient);
const { rerender } = render(
<QueryClientProvider client={qc}>{workspaceSurface()}</QueryClientProvider>,
);
await screen.findByText("WS1 issue");
// Switch workspace: same scope, new wsId, and the new workspace's
// fetches hang so the in-between state is observable.
listIssues.mockImplementation(() => never<ListIssuesResponse>());
mockWsId.current = "ws-2";
rerender(
<QueryClientProvider client={qc}>{workspaceSurface()}</QueryClientProvider>,
);
expect(screen.getByTestId("surface-loading")).toBeInTheDocument();
expect(screen.queryByText("WS1 issue")).not.toBeInTheDocument();
});
});
describe("IssueSurface — table pagination ownership", () => {
let qc: QueryClient;
let listIssues: ReturnType<
typeof vi.fn<(params?: ListIssuesParams) => Promise<ListIssuesResponse>>
>;
beforeEach(() => {
qc = new QueryClient({ defaultOptions: { queries: { retry: false } } });
listIssues = vi.fn(() => never<ListIssuesResponse>());
mockTranslate.mockClear();
pruneIssueSurfaceViewStates([]);
mockWsId.current = "ws-1";
// jsdom has no IntersectionObserver; the table footer sentinel constructs
// one on mount. A stub that never fires keeps the sentinel inert, so the
// structure loop is the only automatic pagination driver under test.
vi.stubGlobal(
"IntersectionObserver",
class {
observe() {}
unobserve() {}
disconnect() {}
},
);
});
afterEach(() => {
cleanup();
qc.clear();
pruneIssueSurfaceViewStates([]);
vi.unstubAllGlobals();
vi.restoreAllMocks();
});
it("does not materialize the legacy offset window and starts one cursor root branch", async () => {
const { getIssueSurfaceViewStore } = await import(
"@multica/core/issues/stores/surface-view-store"
);
const store = getIssueSurfaceViewStore("project:pt");
store.getState().setViewMode("table");
if (!store.getState().agentRunningFilter) {
store.getState().toggleAgentRunningFilter();
}
const runningIssues = Array.from({ length: 250 }, (_, index) => ({
...makeIssue(`run-${index}`, `Running ${index}`, "pt"),
status: "in_progress" as const,
}));
const listIssueTableRows = vi.fn(() => never());
setApiInstance({
listIssues,
listIssueTableRows,
listIssueTableFacets: vi.fn(() => never()),
listGroupedIssues: vi.fn(() => never()),
listProjects: vi.fn(() => never()),
getAgentTaskSnapshot: vi.fn(() =>
Promise.resolve(
runningIssues.map((issue, index) => ({
id: `task-${index}`,
agent_id: `agent-${index}`,
issue_id: issue.id,
status: "running",
})) as unknown as AgentTask[],
),
),
getWorkspaceWorkingAgents: vi.fn(() =>
Promise.resolve(
runningIssues.map((issue, index) => ({
id: `agent-${index}`,
name: `Agent ${index}`,
avatar_url: null,
running_task_count: 1,
issue_ids: [issue.id],
})),
),
),
getChildIssueProgress: vi.fn(() => never()),
listProperties: vi.fn(() => never()),
listMembers: vi.fn(() => never()),
listAgents: vi.fn(() => never()),
listSquads: vi.fn(() => never()),
} as unknown as ApiClient);
render(
<QueryClientProvider client={qc}>
<IssueSurface
scope={{ type: "project", projectId: "pt" }}
modes={["table"]}
renderHeader={() => null}
renderLoading={() => <div data-testid="surface-loading" />}
batchToolbar="never"
/>
</QueryClientProvider>,
);
// The first render has not received the independent working-agents query
// yet and therefore requests the explicit match-none form. Once that
// query resolves, the Table owns a new query key containing the agent id
// list and starts the real branch.
await waitFor(() => expect(listIssueTableRows).toHaveBeenCalledTimes(2));
expect(listIssueTableRows).toHaveBeenLastCalledWith(
expect.objectContaining({
group: { kind: "none" },
group_key: null,
parent_id: null,
query: expect.objectContaining({
filters: expect.objectContaining({
working_issue_ids: runningIssues.map((issue) => issue.id),
}),
}),
}),
);
expect(listIssues).not.toHaveBeenCalled();
});
it("keeps loaded rows when a continuation page reports zero", async () => {
const { getIssueSurfaceViewStore } = await import(
"@multica/core/issues/stores/surface-view-store"
);
const store = getIssueSurfaceViewStore("project:pt-pages");
store.getState().setViewMode("table");
const first = makeIssue("page-1", "First cursor row", "pt-pages");
const second = makeIssue("page-2", "Second cursor row", "pt-pages");
const listIssueTableRows = vi.fn((request: IssueTableRowsRequest) =>
Promise.resolve(
request.page?.cursor == null
? {
query_fingerprint: "sha256:pages",
group_key: null,
parent_id: null,
total: 2,
rows: [{ issue: first, direct_child_count: 0 }],
branch_total: 1,
next_cursor: "cursor-2",
}
: {
query_fingerprint: "sha256:pages",
group_key: null,
parent_id: null,
total: 0,
rows: [{ issue: second, direct_child_count: 0 }],
branch_total: 1,
next_cursor: null,
},
),
);
setApiInstance({
listIssues,
listIssueTableRows,
listIssueTableFacets: vi.fn(() => never()),
listGroupedIssues: vi.fn(() => never()),
listProjects: vi.fn(() => Promise.resolve([])),
getAgentTaskSnapshot: vi.fn(() => Promise.resolve([])),
getChildIssueProgress: vi.fn(() => Promise.resolve([])),
listProperties: vi.fn(() => Promise.resolve({ properties: [] })),
listMembers: vi.fn(() => Promise.resolve([])),
listAgents: vi.fn(() => Promise.resolve([])),
listSquads: vi.fn(() => Promise.resolve([])),
} as unknown as ApiClient);
// Continuation is driven by the shared footer's sentinel, the same one
// Board / List / Swimlane use — there is no manual button to press, so the
// observer has to actually report the footer as visible.
vi.stubGlobal(
"IntersectionObserver",
class {
private readonly callback: IntersectionObserverCallback;
constructor(callback: IntersectionObserverCallback) {
this.callback = callback;
}
observe(target: Element) {
this.callback(
[{ isIntersecting: true, target } as IntersectionObserverEntry],
this as unknown as IntersectionObserver,
);
}
unobserve() {}
disconnect() {}
takeRecords() {
return [];
}
root = null;
rootMargin = "0px";
thresholds = [0];
},
);
render(
<QueryClientProvider client={qc}>
<IssueSurface
scope={{ type: "project", projectId: "pt-pages" }}
modes={["table"]}
renderHeader={() => null}
batchToolbar="never"
/>
</QueryClientProvider>,
);
await screen.findByText("First cursor row");
await screen.findByText("Second cursor row");
expect(listIssueTableRows).toHaveBeenCalledWith(
expect.objectContaining({ page: { limit: 50, cursor: "cursor-2" } }),
);
expect(screen.getByText("First cursor row")).toBeInTheDocument();
});
it("feeds loaded Table rows to the shared batch toolbar", async () => {
const { getIssueSurfaceViewStore } = await import(
"@multica/core/issues/stores/surface-view-store"
);
const store = getIssueSurfaceViewStore("project:pt-batch");
store.getState().setViewMode("table");
const issue = makeIssue("table-selected", "Loaded Table issue", "pt-batch");
setApiInstance({
listIssues,
listIssueTableRows: vi.fn(() =>
Promise.resolve({
query_fingerprint: "sha256:table-batch",
group_key: null,
parent_id: null,
total: 1,
rows: [{ issue, direct_child_count: 0 }],
branch_total: 1,
next_cursor: null,
}),
),
listIssueTableFacets: vi.fn(() => never()),
listGroupedIssues: vi.fn(() => never()),
listProjects: vi.fn(() => Promise.resolve([])),
getAgentTaskSnapshot: vi.fn(() => Promise.resolve([])),
getChildIssueProgress: vi.fn(() => Promise.resolve([])),
listProperties: vi.fn(() => Promise.resolve({ properties: [] })),
listMembers: vi.fn(() => Promise.resolve([])),
listAgents: vi.fn(() => Promise.resolve([])),
listSquads: vi.fn(() => Promise.resolve([])),
} as unknown as ApiClient);
const { container } = render(
<QueryClientProvider client={qc}>
<IssueSurface
scope={{ type: "project", projectId: "pt-batch" }}
modes={["table"]}
renderHeader={() => null}
batchToolbar="always"
/>
</QueryClientProvider>,
);
await screen.findByText("Loaded Table issue");
const checkboxes = screen.getAllByRole("checkbox");
fireEvent.click(checkboxes[1]!);
await waitFor(() => {
expect(container.querySelector(".fixed.bottom-6")).not.toBeNull();
});
});
it("keeps the previous Table rows painted while a new sort is loading", async () => {
const { getIssueSurfaceViewStore } = await import(
"@multica/core/issues/stores/surface-view-store"
);
const store = getIssueSurfaceViewStore("project:pt-sort-transition");
store.getState().setViewMode("table");
const issue = makeIssue(
"table-sort-placeholder",
"Table row kept during sort",
"pt-sort-transition",
);
const listIssueTableRows = vi.fn((request: IssueTableRowsRequest) =>
request.query.sort.field === "position"
? Promise.resolve({
query_fingerprint: "sha256:initial-sort",
group_key: null,
parent_id: null,
total: 1,
rows: [{ issue, direct_child_count: 0 }],
branch_total: 1,
next_cursor: null,
})
: never(),
);
setApiInstance({
listIssues,
listIssueTableRows,
listIssueTableFacets: vi.fn(() => never()),
listGroupedIssues: vi.fn(() => never()),
listProjects: vi.fn(() => Promise.resolve([])),
getAgentTaskSnapshot: vi.fn(() => Promise.resolve([])),
getChildIssueProgress: vi.fn(() => Promise.resolve([])),
listProperties: vi.fn(() => Promise.resolve({ properties: [] })),
listMembers: vi.fn(() => Promise.resolve([])),
listAgents: vi.fn(() => Promise.resolve([])),
listSquads: vi.fn(() => Promise.resolve([])),
} as unknown as ApiClient);
render(
<QueryClientProvider client={qc}>
<IssueSurface
scope={{ type: "project", projectId: "pt-sort-transition" }}
modes={["table"]}
renderHeader={() => null}
batchToolbar="never"
/>
</QueryClientProvider>,
);
await screen.findByText("Table row kept during sort");
act(() => store.getState().setSortBy("title"));
await waitFor(() => expect(listIssueTableRows).toHaveBeenCalledTimes(2));
expect(screen.getByText("Table row kept during sort")).toBeInTheDocument();
});
it("keeps selected Table rows in the batch universe after their group collapses", async () => {
const { getIssueSurfaceViewStore } = await import(
"@multica/core/issues/stores/surface-view-store"
);
const store = getIssueSurfaceViewStore("project:pt-collapsed-batch");
store.getState().setViewMode("table");
store.getState().setTableGrouping("status");
const issue = makeIssue(
"table-collapsed-selected",
"Selected issue in collapsed group",
"pt-collapsed-batch",
);
vi.stubGlobal(
"IntersectionObserver",
class {
private readonly callback: IntersectionObserverCallback;
constructor(callback: IntersectionObserverCallback) {
this.callback = callback;
}
observe(target: Element) {
this.callback(
[{ isIntersecting: true, target } as IntersectionObserverEntry],
this as unknown as IntersectionObserver,
);
}
unobserve() {}
disconnect() {}
takeRecords() {
return [];
}
root = null;
rootMargin = "0px";
thresholds = [0];
},
);
setApiInstance({
listIssues,
listIssueTableGroups: vi.fn(() =>
Promise.resolve({
query_fingerprint: "sha256:collapsed-groups",
total: 1,
groups: [
{
key: "status:todo",
value: { kind: "status", status: "todo" },
count: 1,
},
],
next_cursor: null,
}),
),
listIssueTableRows: vi.fn(() =>
Promise.resolve({
query_fingerprint: "sha256:collapsed-rows",
group_key: "status:todo",
parent_id: null,
total: 1,
rows: [{ issue, direct_child_count: 0 }],
branch_total: 1,
next_cursor: null,
}),
),
listIssueTableFacets: vi.fn(() => never()),
listGroupedIssues: vi.fn(() => never()),
listProjects: vi.fn(() => Promise.resolve([])),
getAgentTaskSnapshot: vi.fn(() => Promise.resolve([])),
getChildIssueProgress: vi.fn(() => Promise.resolve([])),
listProperties: vi.fn(() => Promise.resolve({ properties: [] })),
listMembers: vi.fn(() => Promise.resolve([])),
listAgents: vi.fn(() => Promise.resolve([])),
listSquads: vi.fn(() => Promise.resolve([])),
} as unknown as ApiClient);
const { container } = render(
<QueryClientProvider client={qc}>
<IssueSurface
scope={{ type: "project", projectId: "pt-collapsed-batch" }}
modes={["table"]}
renderHeader={() => null}
batchToolbar="always"
/>
</QueryClientProvider>,
);
await screen.findByText("Selected issue in collapsed group");
fireEvent.click(screen.getAllByRole("checkbox")[1]!);
await waitFor(() => {
expect(container.querySelector(".fixed.bottom-6")).not.toBeNull();
});
fireEvent.click(screen.getByRole("button", { name: "translated1" }));
await waitFor(() => {
expect(screen.queryByText("Selected issue in collapsed group")).toBeNull();
});
expect(container.querySelector(".fixed.bottom-6")).not.toBeNull();
});
});