diff --git a/packages/views/issues/components/table-view-model.ts b/packages/views/issues/components/table-view-model.ts index ca6bb5bbf2..3285ef9f06 100644 --- a/packages/views/issues/components/table-view-model.ts +++ b/packages/views/issues/components/table-view-model.ts @@ -34,12 +34,22 @@ export type IssueTableDisplayRow = hasChildren: boolean; collapsed: boolean; } + // Stands in for a row that has not arrived yet. Cold loads render these in + // the table's own grid rather than swapping the surface for a generic + // placeholder: columns, widths and the header are known before any data is, + // so only the rows need standing in for, and the layout does not shift when + // they are replaced. + | { kind: "skeleton"; key: string } + // Carries the state rather than a finished label: the row renders through + // ListLoadMoreFooter, the same footer Board / List / Swimlane use, which owns + // the wording and the styling for each state. | { kind: "load_more"; key: string; - label: string; - loading: boolean; - autoLoad?: boolean; + state: "loading" | "has_more" | "error" | "end"; + // Rows past this many mean the branch actually paginated, which is what + // decides whether reaching the end is worth marking. + total: number; onLoad?: () => void; }; diff --git a/packages/views/issues/components/table-view.tsx b/packages/views/issues/components/table-view.tsx index cfdc7589c1..69ef89e0a2 100644 --- a/packages/views/issues/components/table-view.tsx +++ b/packages/views/issues/components/table-view.tsx @@ -67,6 +67,7 @@ import { TableCell, TableRow, } from "@multica/ui/components/ui/table"; +import { Skeleton } from "@multica/ui/components/ui/skeleton"; import { cn } from "@multica/ui/lib/utils"; import { ApiError } from "@multica/core/api"; import { useWorkspaceId } from "@multica/core/hooks"; @@ -137,9 +138,13 @@ import { type IssueTableDisplayRow, } from "./table-view-model"; import type { ChildProgress } from "./list-row"; -import { InfiniteScrollSentinel } from "./infinite-scroll-sentinel"; +import { ListLoadMoreFooter } from "./list-load-more-footer"; import { IssueAgentActivityIndicator } from "./issue-agent-activity-indicator"; +// Enough placeholder rows to cover a typical viewport; the virtualizer only +// mounts what fits, so overshooting costs nothing. +const SKELETON_ROW_COUNT = 12; + const SELECT_COLUMN_ID = "__select"; const ADD_COLUMN_ID = "__add"; @@ -1072,6 +1077,12 @@ function IssueTableBodyCell({ meta.editingCellKey, meta.setEditingCellKey, ); + // Placeholder rows go through the ordinary cell renderer so they inherit the + // real column widths, pinning and borders — the grid is already correct + // before any data arrives, so the rows swap in without shifting anything. + if (row.original.kind === "skeleton") { + return ; + } if (row.original.kind !== "issue") return null; const issueRow = row.original; const issue = issueRow.issue; @@ -1772,9 +1783,8 @@ export function TableView({ result.push({ kind: "load_more", key: `${registered ? "loading" : "activate"}:${key}`, - label: t(($) => $.table.loading_branch), - loading: registered, - autoLoad: !registered, + state: registered ? "loading" : "has_more", + total: 0, onLoad: registered ? undefined : () => activateServerBranch(groupKey, parentId, ancestorIds), @@ -1785,8 +1795,8 @@ export function TableView({ result.push({ kind: "load_more", key: `loading:${key}`, - label: t(($) => $.table.loading_branch), - loading: true, + state: "loading", + total: 0, }); } for (const row of data.rows) { @@ -1815,8 +1825,8 @@ export function TableView({ result.push({ kind: "load_more", key: `retry:${key}`, - label: t(($) => $.table.load_more_failed_retry), - loading: false, + state: "error", + total: data.total, onLoad: () => retryServerBranch(key), }); } else if (data.nextCursor) { @@ -1824,11 +1834,20 @@ export function TableView({ result.push({ kind: "load_more", key: `more:${key}:${nextCursor}`, - label: t(($) => $.table.load_more), - loading: data.loading, - autoLoad: true, + state: data.loading ? "loading" : "has_more", + total: data.total, onLoad: () => loadNextServerBranchPage(key, nextCursor), }); + } else if (data.rows.length > 0) { + // Reaching the end is only worth marking on a branch that paginated; + // the footer applies that rule, so the row is pushed unconditionally + // and carries the total for it to judge by. + result.push({ + kind: "load_more", + key: `end:${key}`, + state: "end", + total: data.total, + }); } }; @@ -1855,27 +1874,40 @@ export function TableView({ result.push({ kind: "load_more", key: "loading:groups", - label: t(($) => $.table.loading_branch), - loading: true, + state: "loading", + total: 0, }); } else if (usesServerGrouping && serverGroupsError) { result.push({ kind: "load_more", key: "retry:groups", - label: t(($) => $.table.load_failed_retry), - loading: false, + state: "error", + total: 0, onLoad: () => void refetchServerGroups(), }); } else if (usesServerGrouping && hasNextServerGroupPage) { result.push({ kind: "load_more", key: "more:groups", - label: t(($) => $.table.load_more), - loading: fetchingNextServerGroupPage, - autoLoad: true, + state: fetchingNextServerGroupPage ? "loading" : "has_more", + total: 0, onLoad: () => void fetchNextServerGroupPage(), }); } + + // Nothing has landed yet and something is still in flight: show the grid + // filled with placeholders instead of one "Loading…" line, which reads as + // an empty table more than a loading one. + const isColdLoad = + !result.some((row) => row.kind === "issue") && + result.some((row) => row.kind === "load_more" && row.state === "loading"); + if (isColdLoad) { + return Array.from({ length: SKELETON_ROW_COUNT }, (_, index) => ({ + kind: "skeleton" as const, + key: `skeleton:${index}`, + })); + } + return result; }, [ collapsedGroupSet, @@ -2391,32 +2423,27 @@ export function TableView({ - {loadMoreRow.autoLoad && - loadMoreRow.onLoad && - !loadMoreRow.loading && ( - + loadMoreRow.onLoad?.()} + isError={loadMoreRow.state === "error"} + onRetry={loadMoreRow.onLoad} /> - )} - + ); diff --git a/packages/views/issues/surface/issue-surface.test.tsx b/packages/views/issues/surface/issue-surface.test.tsx index 36731f3ebb..18340abfa7 100644 --- a/packages/views/issues/surface/issue-surface.test.tsx +++ b/packages/views/issues/surface/issue-surface.test.tsx @@ -430,6 +430,33 @@ describe("IssueSurface — table pagination ownership", () => { 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( { ); await screen.findByText("First cursor row"); - const loadMoreButton = document.querySelector( - "tbody button.sticky", - ); - expect(loadMoreButton).not.toBeNull(); - fireEvent.click(loadMoreButton!); - await screen.findByText("Second cursor row"); expect(listIssueTableRows).toHaveBeenCalledWith( expect.objectContaining({ page: { limit: 50, cursor: "cursor-2" } }), diff --git a/packages/views/issues/surface/issue-surface.tsx b/packages/views/issues/surface/issue-surface.tsx index 7133ab015e..6f4ad9c077 100644 --- a/packages/views/issues/surface/issue-surface.tsx +++ b/packages/views/issues/surface/issue-surface.tsx @@ -327,19 +327,16 @@ function IssueSurfaceContent({ ); } +// Table is deliberately absent. It owns its own placeholders, drawn as rows +// inside its real grid so the header, column widths and toolbar are up before +// any data is — a surface-level stand-in would replace all of that with bars +// of a different shape and then jump when the rows arrived. function IssueSurfaceSkeleton({ mode }: { mode: string }) { - if (mode === "list" || mode === "table") { + if (mode === "list") { return (
- {mode === "table" && } - {Array.from({ length: mode === "table" ? 8 : 4 }).map((_, i) => ( - + {Array.from({ length: 4 }).map((_, i) => ( + ))}
);