mirror of
https://github.com/multica-ai/multica.git
synced 2026-07-26 20:45:37 +02:00
perf(issues): virtualize inbox/list/board/swimlane (MUL-4474, 方案2) (#5349)
* perf(inbox): virtualize notification list (MUL-4474) The inbox notification list rendered every item at once. Each row mounts an avatar + hover card, so a long inbox inflates the tab-switch commit — the same render-amplifier class this issue targets. Extract an InboxList component that virtualizes the rows via react-virtuoso (customScrollParent over the existing overflow-y-auto element, same pattern as the issue-detail timeline). Only the visible window plus a small overscan is mounted; everything else — selection, hover, archive, scroll semantics, the row component and callbacks — is unchanged. Virtualization changes exactly one thing: whether an off-screen row is in the DOM. Slice 2a of MUL-4474 (inbox is the no-DnD surface, done first to prove the Virtuoso + scroll + keyboard harness before the drag surfaces). Draft: must pass the manual zero-functional-change pass on a real Desktop build before merge. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> Co-authored-by: multica-agent <github@multica.ai> * perf(board): virtualize board columns (MUL-4474) Each board column rendered every card at once; cards carry pickers, avatars, and a per-issue activity indicator, so a tall column inflates the tab-switch commit. Virtualize the cards within each column via react-virtuoso, using the column's own scroll container as customScrollParent. The dnd-kit droppable stays on the always-mounted column scroll container (merged callback ref feeds both dnd-kit and Virtuoso), and SortableContext still wraps the full id list. So cross-column drops (status/assignee change) and reorder among on-screen cards are unchanged; reordering to an off-screen target relies on drag auto-scroll to mount it — the documented virtualization tradeoff, to be confirmed in the manual pass. The infinite-scroll sentinel rides Virtuoso's Footer slot so loadMore still fires at the bottom, and a per-item pt-2 reproduces the previous space-y-2 gap with padding inside the measured item box. issues-page.test.tsx: mock react-virtuoso to render items inline (jsdom has no layout), and make the useDroppable mock's setNodeRef referentially stable to match real dnd-kit — the board's merged customScrollParent ref would otherwise loop on a fresh ref each render. Slice 2b of MUL-4474 on the shared inbox/list/board/swimlane branch. Draft: requires the manual zero-functional-change pass on a real Desktop build. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> Co-authored-by: multica-agent <github@multica.ai> * perf(list): virtualize issue list rows (MUL-4474) The status-grouped list rendered every row in every expanded section at once; each row carries a sortable, context menu, tooltip, and activity indicator, so a long list inflates the tab-switch commit. Virtualize each expanded section's rows with react-virtuoso, all instances sharing the page's single scroll container as customScrollParent. Everything structural is preserved by construction: the Base UI accordion, sticky status headers, collapse, the per-section useDroppable, the per-section SortableContext, and the load-more sentinel (now Virtuoso's Footer). The Virtuoso only mounts for an expanded section (a collapsed/hidden panel has no viewport to measure). Virtualization changes exactly one thing: whether an off-screen row is in the DOM. issue-surface.test.tsx: mock react-virtuoso inline (jsdom has no layout) so the surface-level loading-semantics assertions still observe the list's rows. Slice 2c of MUL-4474 on the shared branch. Draft: requires the manual zero-functional-change pass on a real Desktop build. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> Co-authored-by: multica-agent <github@multica.ai> * perf(swimlane): virtualize lanes (MUL-4474) The swimlane rendered every lane (each a full row of status cells) at once. Virtualize the vertical lane axis with react-virtuoso over the board's outer scroll box (customScrollParent), so only on-screen lanes stay mounted. Behavior is preserved: pinned lanes keep their leading position, the SortableContext still wraps the lane set for grip-drag reorder (its items are only the non-pinned lane ids), per-cell droppables and per-cell card SortableContexts are unchanged (cells live on mounted lanes), the sticky status header stays above the list, and the per-status load-more sentinels ride Virtuoso's Footer. pt-4 per lane reproduces the previous gap-4. swimlane-view.test.tsx: mock react-virtuoso inline so the ~47 lane/cell/DnD assertions still see the lanes the virtualized list renders. Slice 2d of MUL-4474 on the shared branch — this completes the four surfaces (inbox/board/list/swimlane). Draft: requires the full manual zero-functional-change pass on a real Desktop build before merge. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> Co-authored-by: multica-agent <github@multica.ai> * fix(issues): don't pass undefined to Virtuoso `components` (MUL-4474) react-virtuoso seeds its `components` prop with an internal `{}` default; passing `components={undefined}` (which the list and board did when there was no Footer — hasMore false / no column footer) overwrites that default with undefined, so Virtuoso's startup destructure of `EmptyPlaceholder`/`Footer` throws and the surface crashes. jsdom tests mock react-virtuoso so this only surfaced on a real Desktop build (found in manual perf testing). Return a stable module-level empty object instead of undefined. Inbox (omits the prop entirely) and swimlane (always supplies a Footer) never hit this and are unchanged. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> Co-authored-by: multica-agent <github@multica.ai> --------- Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com> Co-authored-by: multica-agent <github@multica.ai>
This commit is contained in:
81
packages/views/inbox/components/inbox-list.tsx
Normal file
81
packages/views/inbox/components/inbox-list.tsx
Normal file
@@ -0,0 +1,81 @@
|
||||
"use client";
|
||||
|
||||
import { useState } from "react";
|
||||
import { Virtuoso } from "react-virtuoso";
|
||||
import { Inbox } from "lucide-react";
|
||||
import type { InboxItem } from "@multica/core/types";
|
||||
import { InboxListItem } from "./inbox-list-item";
|
||||
import { useT } from "../../i18n";
|
||||
|
||||
/**
|
||||
* Scrollable, virtualized inbox notification list.
|
||||
*
|
||||
* Owns the scroll container so both the mobile and desktop layouts render an
|
||||
* identical scroller. Rows are virtualized via react-virtuoso so only the
|
||||
* visible window (plus a small overscan) is mounted — the notification list
|
||||
* can grow long and every row otherwise carries an avatar + hover card, so
|
||||
* mounting all of them inflates the tab-switch commit (MUL-4474).
|
||||
*
|
||||
* Virtualization changes exactly one thing: whether an off-screen row is in
|
||||
* the DOM. Selection, hover, archive, and scroll semantics are unchanged —
|
||||
* the row component and the callbacks are the same as the non-virtualized
|
||||
* list. `customScrollParent` keeps Virtuoso reading/writing the existing
|
||||
* `overflow-y-auto` element (same pattern as the issue-detail timeline), so
|
||||
* scroll position behaves exactly as before.
|
||||
*
|
||||
* Known virtualization tradeoff: keyboard Tab only reaches currently-mounted
|
||||
* rows; a keyboard-only user must scroll to bring off-screen rows into the
|
||||
* tab order. The inbox has no custom arrow-key list navigation, so the
|
||||
* practical surface is small, but it is called out for the manual pass.
|
||||
*/
|
||||
export function InboxList({
|
||||
items,
|
||||
selectedKey,
|
||||
onSelect,
|
||||
onArchive,
|
||||
}: {
|
||||
items: InboxItem[];
|
||||
selectedKey: string;
|
||||
onSelect: (item: InboxItem) => void;
|
||||
onArchive: (id: string) => void;
|
||||
}) {
|
||||
const { t } = useT("inbox");
|
||||
// Virtuoso's `customScrollParent` wants the actual HTMLElement, not a ref.
|
||||
// A callback ref into state hands the element over once it mounts and
|
||||
// triggers the re-render that lets Virtuoso attach to it.
|
||||
const [scrollEl, setScrollEl] = useState<HTMLDivElement | null>(null);
|
||||
|
||||
if (items.length === 0) {
|
||||
return (
|
||||
<div className="flex-1 min-h-0 overflow-y-auto">
|
||||
<div className="flex flex-col items-center justify-center py-16 text-muted-foreground">
|
||||
<Inbox className="mb-3 h-8 w-8 text-muted-foreground/50" />
|
||||
<p className="text-sm">{t(($) => $.list.empty)}</p>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div ref={setScrollEl} className="flex-1 min-h-0 overflow-y-auto">
|
||||
<div className="px-2 py-1">
|
||||
{scrollEl && (
|
||||
<Virtuoso
|
||||
customScrollParent={scrollEl}
|
||||
data={items}
|
||||
computeItemKey={(_index, item) => item.id}
|
||||
increaseViewportBy={{ top: 400, bottom: 400 }}
|
||||
itemContent={(_index, item) => (
|
||||
<InboxListItem
|
||||
item={item}
|
||||
isSelected={(item.issue_id ?? item.id) === selectedKey}
|
||||
onClick={() => onSelect(item)}
|
||||
onArchive={() => onArchive(item.id)}
|
||||
/>
|
||||
)}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -52,7 +52,8 @@ import {
|
||||
} from "@multica/ui/components/ui/dropdown-menu";
|
||||
import { useIsMobile } from "@multica/ui/hooks/use-mobile";
|
||||
import { PageHeader } from "../../layout/page-header";
|
||||
import { InboxListItem, useTimeAgo } from "./inbox-list-item";
|
||||
import { useTimeAgo } from "./inbox-list-item";
|
||||
import { InboxList } from "./inbox-list";
|
||||
import { useTypeLabels } from "./inbox-detail-label";
|
||||
import { getInboxDisplayTitle } from "./inbox-display";
|
||||
import { useT } from "../../i18n";
|
||||
@@ -271,23 +272,13 @@ export function InboxPage() {
|
||||
</PageHeader>
|
||||
);
|
||||
|
||||
const listBody = items.length === 0 ? (
|
||||
<div className="flex flex-col items-center justify-center py-16 text-muted-foreground">
|
||||
<Inbox className="mb-3 h-8 w-8 text-muted-foreground/50" />
|
||||
<p className="text-sm">{t(($) => $.list.empty)}</p>
|
||||
</div>
|
||||
) : (
|
||||
<div className="px-2 py-1">
|
||||
{items.map((item) => (
|
||||
<InboxListItem
|
||||
key={item.id}
|
||||
item={item}
|
||||
isSelected={(item.issue_id ?? item.id) === selectedKey}
|
||||
onClick={() => handleSelect(item)}
|
||||
onArchive={() => handleArchive(item.id)}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
const list = (
|
||||
<InboxList
|
||||
items={items}
|
||||
selectedKey={selectedKey}
|
||||
onSelect={handleSelect}
|
||||
onArchive={handleArchive}
|
||||
/>
|
||||
);
|
||||
|
||||
const detailContent = selected?.issue_id ? (
|
||||
@@ -418,9 +409,7 @@ export function InboxPage() {
|
||||
return (
|
||||
<div className="flex flex-1 flex-col min-h-0">
|
||||
{listHeader}
|
||||
<div className="flex-1 min-h-0 overflow-y-auto">
|
||||
{listBody}
|
||||
</div>
|
||||
{list}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -464,9 +453,7 @@ export function InboxPage() {
|
||||
<ResizablePanel id="list" defaultSize={320} minSize={240} maxSize={480} groupResizeBehavior="preserve-pixel-size">
|
||||
<div className="flex flex-col border-r h-full">
|
||||
{listHeader}
|
||||
<div className="flex-1 min-h-0 overflow-y-auto">
|
||||
{listBody}
|
||||
</div>
|
||||
{list}
|
||||
</div>
|
||||
</ResizablePanel>
|
||||
<ResizableHandle />
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
"use client";
|
||||
|
||||
import { memo, useMemo, type ReactNode } from "react";
|
||||
import { memo, useCallback, useMemo, useState, type ReactNode } from "react";
|
||||
import { Virtuoso } from "react-virtuoso";
|
||||
import { EyeOff, MoreHorizontal, Plus, UserMinus } from "lucide-react";
|
||||
import { Tooltip, TooltipTrigger, TooltipContent } from "@multica/ui/components/ui/tooltip";
|
||||
import { useDroppable } from "@dnd-kit/core";
|
||||
@@ -35,6 +36,12 @@ import type { IssueCreateDefaults } from "../surface/types";
|
||||
export const BOARD_COL_WIDTH = 280;
|
||||
export const BOARD_CARD_WIDTH = BOARD_COL_WIDTH - 16 - 8; // col(280) - col p-2(16) - droppable p-1(8)
|
||||
|
||||
// Passed to <Virtuoso components> when the column has no footer. Must be a
|
||||
// STABLE object, never `undefined`: an explicit `undefined` prop overwrites
|
||||
// react-virtuoso's internal `{}` default and its startup destructure of
|
||||
// `EmptyPlaceholder`/`Footer` throws (MUL-4474).
|
||||
const EMPTY_VIRTUOSO_COMPONENTS = {};
|
||||
|
||||
export interface BoardColumnGroup {
|
||||
id: string;
|
||||
title: string;
|
||||
@@ -85,6 +92,28 @@ export const BoardColumn = memo(function BoardColumn({
|
||||
[issueIds, issueMap],
|
||||
);
|
||||
|
||||
// The column's scroll container is both dnd-kit's droppable and Virtuoso's
|
||||
// customScrollParent, so a merged callback ref feeds the element to both.
|
||||
// useDroppable's setNodeRef is stable across renders. Keeping the droppable
|
||||
// on the always-mounted scroll container (not on individual cards) is what
|
||||
// lets cross-column drops survive virtualization — only the cards inside
|
||||
// window in/out of the DOM.
|
||||
const [scrollEl, setScrollEl] = useState<HTMLDivElement | null>(null);
|
||||
const mergedRef = useCallback(
|
||||
(el: HTMLDivElement | null) => {
|
||||
setNodeRef(el);
|
||||
setScrollEl(el);
|
||||
},
|
||||
[setNodeRef],
|
||||
);
|
||||
// Infinite-scroll sentinel rides Virtuoso's Footer slot so it sits at the
|
||||
// real end of the virtualized list and its IntersectionObserver still fires
|
||||
// loadMore when scrolled to the bottom.
|
||||
const footerComponents = useMemo(
|
||||
() => (footer ? { Footer: () => <>{footer}</> } : EMPTY_VIRTUOSO_COMPONENTS),
|
||||
[footer],
|
||||
);
|
||||
|
||||
return (
|
||||
<div style={{ width: BOARD_COL_WIDTH }} className={`flex shrink-0 flex-col rounded-xl ${cfg?.columnBg ?? "bg-muted/40"} p-2`}>
|
||||
<div className="mb-2 flex items-center justify-between px-1.5">
|
||||
@@ -143,8 +172,8 @@ export const BoardColumn = memo(function BoardColumn({
|
||||
</div>
|
||||
)}
|
||||
<div
|
||||
ref={setNodeRef}
|
||||
className={`absolute inset-0 space-y-2 overflow-y-auto rounded-lg p-1 transition-colors ${
|
||||
ref={mergedRef}
|
||||
className={`absolute inset-0 overflow-y-auto rounded-lg p-1 transition-colors ${
|
||||
isOver && sortLabel
|
||||
? "ring-2 ring-brand/25 bg-accent/15"
|
||||
: isOver
|
||||
@@ -152,25 +181,43 @@ export const BoardColumn = memo(function BoardColumn({
|
||||
: ""
|
||||
}`}
|
||||
>
|
||||
<SortableContext items={issueIds} strategy={verticalListSortingStrategy}>
|
||||
{resolvedIssues.map((issue) => (
|
||||
<DraggableBoardCard
|
||||
key={issue.id}
|
||||
issue={issue}
|
||||
childProgress={childProgressMap?.get(issue.id)}
|
||||
project={
|
||||
issue.project_id ? projectMap?.get(issue.project_id) : undefined
|
||||
}
|
||||
disableSorting={!!sortLabel}
|
||||
/>
|
||||
))}
|
||||
</SortableContext>
|
||||
{issueIds.length === 0 && (
|
||||
<p className="py-8 text-center text-xs text-muted-foreground">
|
||||
{t(($) => $.board.empty_column)}
|
||||
</p>
|
||||
{resolvedIssues.length > 0 ? (
|
||||
<SortableContext items={issueIds} strategy={verticalListSortingStrategy}>
|
||||
{scrollEl && (
|
||||
<Virtuoso
|
||||
customScrollParent={scrollEl}
|
||||
data={resolvedIssues}
|
||||
computeItemKey={(_index, issue) => issue.id}
|
||||
increaseViewportBy={{ top: 300, bottom: 300 }}
|
||||
components={footerComponents}
|
||||
itemContent={(index, issue) => (
|
||||
// pt-2 on every card but the first reproduces the previous
|
||||
// `space-y-2` gap; padding (not margin) is inside Virtuoso's
|
||||
// measured item box so its height math stays correct.
|
||||
<div className={index === 0 ? undefined : "pt-2"}>
|
||||
<DraggableBoardCard
|
||||
issue={issue}
|
||||
childProgress={childProgressMap?.get(issue.id)}
|
||||
project={
|
||||
issue.project_id ? projectMap?.get(issue.project_id) : undefined
|
||||
}
|
||||
disableSorting={!!sortLabel}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
/>
|
||||
)}
|
||||
</SortableContext>
|
||||
) : (
|
||||
<>
|
||||
{issueIds.length === 0 && (
|
||||
<p className="py-8 text-center text-xs text-muted-foreground">
|
||||
{t(($) => $.board.empty_column)}
|
||||
</p>
|
||||
)}
|
||||
{footer}
|
||||
</>
|
||||
)}
|
||||
{footer}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -286,16 +286,23 @@ vi.mock("sonner", () => ({
|
||||
}));
|
||||
|
||||
// Mock dnd-kit
|
||||
vi.mock("@dnd-kit/core", () => ({
|
||||
DndContext: ({ children }: any) => children,
|
||||
DragOverlay: () => null,
|
||||
PointerSensor: class {},
|
||||
useSensor: () => ({}),
|
||||
useSensors: () => [],
|
||||
useDroppable: () => ({ setNodeRef: vi.fn(), isOver: false }),
|
||||
pointerWithin: vi.fn(),
|
||||
closestCenter: vi.fn(),
|
||||
}));
|
||||
vi.mock("@dnd-kit/core", () => {
|
||||
// Real dnd-kit useDroppable returns a referentially stable setNodeRef
|
||||
// (memoized internally). BoardColumn merges it with a state-setting
|
||||
// callback ref for Virtuoso's customScrollParent, so a fresh function each
|
||||
// render would loop the ref detach/reattach. Model the stable identity.
|
||||
const stableSetNodeRef = () => {};
|
||||
return {
|
||||
DndContext: ({ children }: any) => children,
|
||||
DragOverlay: () => null,
|
||||
PointerSensor: class {},
|
||||
useSensor: () => ({}),
|
||||
useSensors: () => [],
|
||||
useDroppable: () => ({ setNodeRef: stableSetNodeRef, isOver: false }),
|
||||
pointerWithin: vi.fn(),
|
||||
closestCenter: vi.fn(),
|
||||
};
|
||||
});
|
||||
|
||||
vi.mock("@dnd-kit/sortable", () => ({
|
||||
SortableContext: ({ children }: any) => children,
|
||||
@@ -329,6 +336,21 @@ vi.mock("@base-ui/react/accordion", () => ({
|
||||
),
|
||||
}));
|
||||
|
||||
// Mock react-virtuoso: jsdom has no layout, so the real Virtuoso computes a
|
||||
// 0-height viewport and renders nothing (and throws on its resize plumbing).
|
||||
// Render every item inline so the virtualized board columns expose their
|
||||
// cards to the DOM, matching the non-virtualized behavior these tests assert.
|
||||
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>
|
||||
),
|
||||
}));
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Test data
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
@@ -15,6 +15,7 @@ import {
|
||||
type DragOverEvent,
|
||||
} from "@dnd-kit/core";
|
||||
import { SortableContext, verticalListSortingStrategy, arrayMove } from "@dnd-kit/sortable";
|
||||
import { Virtuoso } from "react-virtuoso";
|
||||
import { Tooltip, TooltipTrigger, TooltipContent } from "@multica/ui/components/ui/tooltip";
|
||||
import { Button } from "@multica/ui/components/ui/button";
|
||||
import type { Issue, IssueStatus, Project } from "@multica/core/types";
|
||||
@@ -43,6 +44,11 @@ import type { IssueCreateDefaults } from "../surface/types";
|
||||
|
||||
const EMPTY_PROGRESS_MAP = new Map<string, ChildProgress>();
|
||||
const EMPTY_IDS: string[] = [];
|
||||
// Passed to <Virtuoso components> when there is no Footer. Must be a STABLE
|
||||
// object, never `undefined`: react-virtuoso seeds `components` with an internal
|
||||
// `{}` default, and an explicit `undefined` prop overwrites that default, so
|
||||
// its startup destructure of `EmptyPlaceholder`/`Footer` throws (MUL-4474).
|
||||
const EMPTY_VIRTUOSO_COMPONENTS = {};
|
||||
|
||||
function buildListGroups(visibleStatuses: IssueStatus[]): BoardColumnGroup[] {
|
||||
return visibleStatuses.map((status) => ({
|
||||
@@ -294,6 +300,13 @@ export function ListView({
|
||||
[issues, groups, onMoveIssue, groupIds, groupMap, sortBy, beginSettle, setColumns, columnsRef, isDraggingRef],
|
||||
);
|
||||
|
||||
// The single scroll container is shared by every status panel's Virtuoso as
|
||||
// its customScrollParent, so a callback ref hands the element to the panels
|
||||
// once it mounts. Keeping one scroller (rather than one per panel) preserves
|
||||
// the current sticky-header + cross-section scroll behavior; only the rows
|
||||
// inside each expanded panel virtualize.
|
||||
const [scrollEl, setScrollEl] = useState<HTMLDivElement | null>(null);
|
||||
|
||||
const content = (
|
||||
<Accordion.Root
|
||||
multiple
|
||||
@@ -327,6 +340,7 @@ export function ListView({
|
||||
isExpanded={isExpanded}
|
||||
sortLabel={sortLabel}
|
||||
sort={sort}
|
||||
scrollParent={scrollEl}
|
||||
/>
|
||||
);
|
||||
})}
|
||||
@@ -335,7 +349,7 @@ export function ListView({
|
||||
|
||||
if (!dragEnabled) {
|
||||
return (
|
||||
<div className="flex-1 min-h-0 overflow-y-auto p-2 pt-0">
|
||||
<div ref={setScrollEl} className="flex-1 min-h-0 overflow-y-auto p-2 pt-0">
|
||||
{content}
|
||||
</div>
|
||||
);
|
||||
@@ -349,7 +363,7 @@ export function ListView({
|
||||
onDragOver={handleDragOver}
|
||||
onDragEnd={handleDragEnd}
|
||||
>
|
||||
<div className="flex-1 min-h-0 overflow-y-auto p-2 pt-0">
|
||||
<div ref={setScrollEl} className="flex-1 min-h-0 overflow-y-auto p-2 pt-0">
|
||||
{content}
|
||||
</div>
|
||||
|
||||
@@ -378,6 +392,7 @@ function StatusAccordionItem({
|
||||
isExpanded,
|
||||
sortLabel,
|
||||
sort,
|
||||
scrollParent,
|
||||
}: {
|
||||
status: IssueStatus;
|
||||
issueIds: string[];
|
||||
@@ -391,6 +406,7 @@ function StatusAccordionItem({
|
||||
isExpanded: boolean;
|
||||
sortLabel: string | null;
|
||||
sort?: IssueSortParam;
|
||||
scrollParent: HTMLElement | null;
|
||||
}) {
|
||||
const { t } = useT("issues");
|
||||
const selection = useIssueSurfaceSelection();
|
||||
@@ -422,6 +438,52 @@ function StatusAccordionItem({
|
||||
|
||||
const disableSorting = !!sortLabel;
|
||||
|
||||
// The infinite-scroll sentinel rides Virtuoso's Footer so it sits at the true
|
||||
// end of the virtualized rows and still fires loadMore when scrolled to it.
|
||||
const listComponents = useMemo(
|
||||
() =>
|
||||
hasMore
|
||||
? { Footer: () => <InfiniteScrollSentinel onVisible={loadMore} loading={isLoading} /> }
|
||||
: EMPTY_VIRTUOSO_COMPONENTS,
|
||||
[hasMore, loadMore, isLoading],
|
||||
);
|
||||
|
||||
// Rows virtualize into the page's shared scroll parent. Only mount the
|
||||
// Virtuoso when the section is expanded and that parent exists — a Virtuoso
|
||||
// in a collapsed (0-height / hidden) panel has no viewport to measure. The
|
||||
// droppable, SortableContext, sticky header, and collapse are unchanged;
|
||||
// virtualization only decides whether an off-screen row is in the DOM.
|
||||
const rows =
|
||||
isExpanded && scrollParent && issues.length > 0 ? (
|
||||
<Virtuoso
|
||||
customScrollParent={scrollParent}
|
||||
data={issues}
|
||||
computeItemKey={(_index, issue) => issue.id}
|
||||
increaseViewportBy={{ top: 400, bottom: 400 }}
|
||||
components={listComponents}
|
||||
itemContent={(_index, issue) =>
|
||||
dragEnabled ? (
|
||||
<DraggableListRow
|
||||
issue={issue}
|
||||
childProgress={childProgressMap.get(issue.id)}
|
||||
project={
|
||||
issue.project_id ? projectMap?.get(issue.project_id) : undefined
|
||||
}
|
||||
disableSorting={disableSorting}
|
||||
/>
|
||||
) : (
|
||||
<ListRow
|
||||
issue={issue}
|
||||
childProgress={childProgressMap.get(issue.id)}
|
||||
project={
|
||||
issue.project_id ? projectMap?.get(issue.project_id) : undefined
|
||||
}
|
||||
/>
|
||||
)
|
||||
}
|
||||
/>
|
||||
) : null;
|
||||
|
||||
return (
|
||||
<Accordion.Item value={status} ref={dragEnabled ? setDroppableRef : undefined}>
|
||||
<Accordion.Header
|
||||
@@ -482,37 +544,10 @@ function StatusAccordionItem({
|
||||
{issues.length > 0 ? (
|
||||
dragEnabled ? (
|
||||
<SortableContext items={issueIds} strategy={verticalListSortingStrategy}>
|
||||
{issues.map((issue) => (
|
||||
<DraggableListRow
|
||||
key={issue.id}
|
||||
issue={issue}
|
||||
childProgress={childProgressMap.get(issue.id)}
|
||||
project={
|
||||
issue.project_id ? projectMap?.get(issue.project_id) : undefined
|
||||
}
|
||||
disableSorting={disableSorting}
|
||||
/>
|
||||
))}
|
||||
{hasMore && (
|
||||
<InfiniteScrollSentinel onVisible={loadMore} loading={isLoading} />
|
||||
)}
|
||||
{rows}
|
||||
</SortableContext>
|
||||
) : (
|
||||
<>
|
||||
{issues.map((issue) => (
|
||||
<ListRow
|
||||
key={issue.id}
|
||||
issue={issue}
|
||||
childProgress={childProgressMap.get(issue.id)}
|
||||
project={
|
||||
issue.project_id ? projectMap?.get(issue.project_id) : undefined
|
||||
}
|
||||
/>
|
||||
))}
|
||||
{hasMore && (
|
||||
<InfiniteScrollSentinel onVisible={loadMore} loading={isLoading} />
|
||||
)}
|
||||
</>
|
||||
rows
|
||||
)
|
||||
) : (
|
||||
<p className="py-6 text-center text-xs text-muted-foreground">
|
||||
|
||||
@@ -248,6 +248,21 @@ vi.mock("@dnd-kit/utilities", () => ({
|
||||
CSS: { Transform: { toString: () => undefined } },
|
||||
}));
|
||||
|
||||
// Mock react-virtuoso: jsdom has no layout, so the real Virtuoso renders
|
||||
// nothing (and throws on its resize plumbing). Render every lane inline so the
|
||||
// virtualized swimlane exposes its lanes/cells/cards to the DOM, matching the
|
||||
// non-virtualized behavior these tests assert.
|
||||
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>
|
||||
),
|
||||
}));
|
||||
|
||||
const mockIssues: Issue[] = [
|
||||
{
|
||||
id: "parent-1",
|
||||
|
||||
@@ -17,6 +17,7 @@ import {
|
||||
} from "@dnd-kit/core";
|
||||
import { SortableContext, useSortable, verticalListSortingStrategy, arrayMove } from "@dnd-kit/sortable";
|
||||
import { CSS } from "@dnd-kit/utilities";
|
||||
import { Virtuoso } from "react-virtuoso";
|
||||
import { ChevronRight, EyeOff, GripVertical, MoreHorizontal, Pencil, Plus } from "lucide-react";
|
||||
import { useQuery, useQueries, useQueryClient } from "@tanstack/react-query";
|
||||
import type {
|
||||
@@ -811,6 +812,8 @@ export function SwimLaneView({
|
||||
);
|
||||
|
||||
const [activeIssue, setActiveIssue] = useState<Issue | null>(null);
|
||||
// The outer scroll box is the customScrollParent for the lane Virtuoso.
|
||||
const [scrollEl, setScrollEl] = useState<HTMLDivElement | null>(null);
|
||||
const isDraggingRef = useRef(false);
|
||||
// Settle lock: held from drop until the move mutation settles, so a cache
|
||||
// change that lands mid-flight (e.g. a membership refetch) does not rebuild
|
||||
@@ -1127,6 +1130,42 @@ export function SwimLaneView({
|
||||
[sortedStatuses.length, trackWidth],
|
||||
);
|
||||
|
||||
// Lanes render in one Virtuoso so only on-screen lanes stay mounted. Pinned
|
||||
// lanes keep their leading position; the SortableContext still wraps the
|
||||
// whole set (its `items` are only the non-pinned lane ids, so reorder is
|
||||
// unchanged), and per-cell droppables live on always-mounted lane cells.
|
||||
const orderedLanes = useMemo(
|
||||
() => [
|
||||
...laneGroups.filter((g) => g.isPinned),
|
||||
...laneGroups.filter((g) => !g.isPinned),
|
||||
],
|
||||
[laneGroups],
|
||||
);
|
||||
const nonPinnedLaneIds = useMemo(
|
||||
() =>
|
||||
laneGroups
|
||||
.filter((g) => !g.isPinned)
|
||||
.map((g) => laneIdFor(swimlaneGrouping, g.rawId)),
|
||||
[laneGroups, swimlaneGrouping],
|
||||
);
|
||||
// Per-status load-more sentinels ride Virtuoso's Footer so they sit at the
|
||||
// true end of the lane list; pt-4 reproduces the previous gap-4.
|
||||
const laneComponents = useMemo(
|
||||
() => ({
|
||||
Footer: () => (
|
||||
<div className="pt-4">
|
||||
<SwimLaneLoadMoreRow
|
||||
sortedStatuses={sortedStatuses}
|
||||
gridStyle={gridStyle}
|
||||
myIssuesOpts={myIssuesOpts}
|
||||
sort={sort}
|
||||
/>
|
||||
</div>
|
||||
),
|
||||
}),
|
||||
[sortedStatuses, gridStyle, myIssuesOpts, sort],
|
||||
);
|
||||
|
||||
return (
|
||||
<DndContext
|
||||
sensors={sensors}
|
||||
@@ -1135,7 +1174,7 @@ export function SwimLaneView({
|
||||
onDragOver={handleDragOver}
|
||||
onDragEnd={handleDragEnd}
|
||||
>
|
||||
<div className="flex flex-1 min-h-0 gap-4 overflow-auto p-4">
|
||||
<div ref={setScrollEl} className="flex flex-1 min-h-0 gap-4 overflow-auto p-4">
|
||||
<div className="flex shrink-0 flex-col" style={{ width: `${trackWidth}px` }}>
|
||||
{/* Sticky status header row — visually matches the top of a BoardColumn */}
|
||||
<div className="sticky top-0 z-10 mb-2 bg-background/95 pb-2 backdrop-blur supports-[backdrop-filter]:bg-background/75">
|
||||
@@ -1178,67 +1217,44 @@ export function SwimLaneView({
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Lane rows. Pinned lanes (the no-X bucket, and parent-grouping's
|
||||
orphan fallback) sit at the top and are non-draggable; the rest
|
||||
are wrapped in a SortableContext so users can reorder lanes by
|
||||
dragging the grip handle. */}
|
||||
<div className="flex flex-col gap-4">
|
||||
{laneGroups
|
||||
.filter((g) => g.isPinned)
|
||||
.map((lane) => (
|
||||
<DraggableSwimLane
|
||||
key={lane.key}
|
||||
lane={lane}
|
||||
grouping={swimlaneGrouping}
|
||||
isCollapsed={collapsedLanes.has(lane.key)}
|
||||
onToggleCollapse={() => toggleLane(lane.key)}
|
||||
localCells={localCells}
|
||||
sortedStatuses={sortedStatuses}
|
||||
issueMap={issueMapRef.current}
|
||||
childProgressMap={childProgressMap}
|
||||
projectMap={projectMap}
|
||||
gridStyle={gridStyle}
|
||||
paths={paths}
|
||||
projectId={projectId}
|
||||
onCreateIssue={onCreateIssue}
|
||||
/>
|
||||
))}
|
||||
<SortableContext
|
||||
items={laneGroups
|
||||
.filter((g) => !g.isPinned)
|
||||
.map((g) => laneIdFor(swimlaneGrouping, g.rawId))}
|
||||
strategy={verticalListSortingStrategy}
|
||||
>
|
||||
{laneGroups
|
||||
.filter((g) => !g.isPinned)
|
||||
.map((lane) => (
|
||||
<DraggableSwimLane
|
||||
key={lane.key}
|
||||
lane={lane}
|
||||
grouping={swimlaneGrouping}
|
||||
isCollapsed={collapsedLanes.has(lane.key)}
|
||||
onToggleCollapse={() => toggleLane(lane.key)}
|
||||
localCells={localCells}
|
||||
sortedStatuses={sortedStatuses}
|
||||
issueMap={issueMapRef.current}
|
||||
childProgressMap={childProgressMap}
|
||||
projectMap={projectMap}
|
||||
gridStyle={gridStyle}
|
||||
paths={paths}
|
||||
projectId={projectId}
|
||||
onCreateIssue={onCreateIssue}
|
||||
/>
|
||||
))}
|
||||
</SortableContext>
|
||||
|
||||
{/* Per-status load-more sentinels — same bucketed cache as Board. */}
|
||||
<SwimLaneLoadMoreRow
|
||||
sortedStatuses={sortedStatuses}
|
||||
gridStyle={gridStyle}
|
||||
myIssuesOpts={myIssuesOpts}
|
||||
sort={sort}
|
||||
/>
|
||||
</div>
|
||||
{/* Lane rows, virtualized. Pinned lanes (the no-X bucket, and
|
||||
parent-grouping's orphan fallback) keep their leading position and
|
||||
stay non-draggable; the SortableContext still lets the rest reorder
|
||||
by dragging the grip handle (its `items` are only the non-pinned
|
||||
lane ids). Only on-screen lanes stay mounted. */}
|
||||
<SortableContext
|
||||
items={nonPinnedLaneIds}
|
||||
strategy={verticalListSortingStrategy}
|
||||
>
|
||||
{scrollEl && (
|
||||
<Virtuoso
|
||||
customScrollParent={scrollEl}
|
||||
data={orderedLanes}
|
||||
computeItemKey={(_index, lane) => lane.key}
|
||||
increaseViewportBy={{ top: 600, bottom: 600 }}
|
||||
components={laneComponents}
|
||||
itemContent={(index, lane) => (
|
||||
<div className={index === 0 ? undefined : "pt-4"}>
|
||||
<DraggableSwimLane
|
||||
lane={lane}
|
||||
grouping={swimlaneGrouping}
|
||||
isCollapsed={collapsedLanes.has(lane.key)}
|
||||
onToggleCollapse={() => toggleLane(lane.key)}
|
||||
localCells={localCells}
|
||||
sortedStatuses={sortedStatuses}
|
||||
issueMap={issueMapRef.current}
|
||||
childProgressMap={childProgressMap}
|
||||
projectMap={projectMap}
|
||||
gridStyle={gridStyle}
|
||||
paths={paths}
|
||||
projectId={projectId}
|
||||
onCreateIssue={onCreateIssue}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
/>
|
||||
)}
|
||||
</SortableContext>
|
||||
</div>
|
||||
|
||||
{hiddenStatuses.length > 0 && (
|
||||
|
||||
@@ -23,6 +23,21 @@ 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>
|
||||
),
|
||||
}));
|
||||
|
||||
const mockAuthUser = { id: "user-1", email: "test@test.com", name: "Test User" };
|
||||
vi.mock("@multica/core/auth", () => ({
|
||||
useAuthStore: Object.assign(
|
||||
|
||||
Reference in New Issue
Block a user