mirror of
https://github.com/multica-ai/multica.git
synced 2026-07-26 20:45:37 +02:00
fix(issues): keep table cell editors open across refreshes; title click opens the issue (MUL-5108) (#5730)
* fix(issues): keep table cell editors open across refreshes; title click opens the issue (MUL-5108) Table view interaction fixes: - Cell/header renderers are now stable module-level components reading data via table.options.meta. flexRender mounts function cells as component types, so the per-render closures rebuilt on every data refresh (new childProgressMap / property / actor identities) remounted every cell and closed any open picker popup. - One hoisted editingCellKey drives all cell editors (controlled open), and while an editor is open the row structure renders from a frozen snapshot (live issue values, frozen order) so window materialization, hierarchy assembly, and realtime reorders cannot move or unmount the popup's anchor row mid-interaction. Structure catches up on close. - Clicking an issue title now opens the issue (it previously started inline rename despite the link-style hover). Renaming moved to a hover pencil affordance; dead space in the title cell navigates too. Co-authored-by: multica-agent <github@multica.ai> * fix(issues): address table editor review — blur-click nav, virtual-unmount key release, deterministic test (MUL-5108) Follow-up to the MUL-5108 table fixes, per code review on PR #5730: - R1#2 (P1): committing a rename by clicking away also navigated into the issue. onBlur flips `editing` off synchronously before the commit-click lands, stripping the click-time guard so the click bubbled to row navigation. Record whether the gesture began while editing (mousedown, before blur) and swallow that click in the capture phase, before it can reach the row or the title's open handler. Dead-space clicks while not editing still open the issue. New blur-click regression test. - R1#3 (P2): the hoisted editingCellKey (and the frozen row structure keyed off it) never cleared when row virtualization unmounted the anchor cell — Base UI does not fire onOpenChange(false) on unmount, so the table stayed frozen and the picker silently reopened / dropped the rename draft on scroll-back. Add useReleaseEditingCellOnUnmount: an unmount responder that releases the key iff the unmounting cell still owns it. Focused tests. - R1#1 (P1): the new integration test exceeded the 5s default under concurrent CI worker load (real-timer gaps between userEvent steps). Drive userEvent with delay:null and give the heavy full-mount test explicit headroom so it is deterministic. Co-authored-by: multica-agent <github@multica.ai> --------- Co-authored-by: Lambda <lambda@multica.ai> Co-authored-by: multica-agent <github@multica.ai>
This commit is contained in:
@@ -42,10 +42,14 @@ export function CustomPropertyValueEditor({
|
||||
issue,
|
||||
property,
|
||||
defaultOpen = false,
|
||||
open,
|
||||
onOpenChange,
|
||||
}: {
|
||||
issue: Issue;
|
||||
property: IssueProperty;
|
||||
defaultOpen?: boolean;
|
||||
open?: boolean;
|
||||
onOpenChange?: (open: boolean) => void;
|
||||
}) {
|
||||
const setProperty = useSetIssueProperty();
|
||||
const unsetProperty = useUnsetIssueProperty();
|
||||
@@ -58,6 +62,8 @@ export function CustomPropertyValueEditor({
|
||||
property={property}
|
||||
value={value}
|
||||
defaultOpen={defaultOpen}
|
||||
open={open}
|
||||
onOpenChange={onOpenChange}
|
||||
onChange={(next) => {
|
||||
if (next === undefined) {
|
||||
unsetProperty.mutate(
|
||||
|
||||
@@ -2,7 +2,9 @@
|
||||
* @vitest-environment jsdom
|
||||
*/
|
||||
import { fireEvent, render, screen } from "@testing-library/react";
|
||||
import userEvent from "@testing-library/user-event";
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
import { useState } from "react";
|
||||
|
||||
import type { Issue } from "@multica/core/types";
|
||||
import { InlineTitle } from "./table-view";
|
||||
@@ -50,20 +52,119 @@ function makeRow(title: string): Extract<
|
||||
};
|
||||
}
|
||||
|
||||
const baseProps = {
|
||||
onUpdate: vi.fn(),
|
||||
onOpen: vi.fn(),
|
||||
onToggleParent: vi.fn(),
|
||||
toggleLabel: "Toggle sub-issues",
|
||||
renameLabel: "Rename issue",
|
||||
};
|
||||
|
||||
/** Editing state lives in the table (one editor at a time); mirror that. */
|
||||
function Harness({
|
||||
title,
|
||||
onOpen,
|
||||
onUpdate,
|
||||
onEditingChange,
|
||||
}: {
|
||||
title: string;
|
||||
onOpen?: () => void;
|
||||
onUpdate?: (updates: unknown) => void;
|
||||
onEditingChange?: (editing: boolean) => void;
|
||||
}) {
|
||||
const [editing, setEditing] = useState(false);
|
||||
return (
|
||||
<InlineTitle
|
||||
{...baseProps}
|
||||
row={makeRow(title)}
|
||||
editing={editing}
|
||||
onEditingChange={(next) => {
|
||||
setEditing(next);
|
||||
onEditingChange?.(next);
|
||||
}}
|
||||
onOpen={onOpen ?? baseProps.onOpen}
|
||||
onUpdate={onUpdate ?? baseProps.onUpdate}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
describe("InlineTitle", () => {
|
||||
it("preserves an active draft when a realtime title snapshot arrives", () => {
|
||||
const props = {
|
||||
onUpdate: vi.fn(),
|
||||
onToggleParent: vi.fn(),
|
||||
toggleLabel: "Toggle sub-issues",
|
||||
};
|
||||
const view = render(<InlineTitle row={makeRow("Original")} {...props} />);
|
||||
it("opens the issue when the title is clicked instead of entering edit mode", () => {
|
||||
const onOpen = vi.fn();
|
||||
const rowClick = vi.fn();
|
||||
render(
|
||||
// The title's click must not also bubble into the row, which would be
|
||||
// a second, duplicate navigation through onRowClick.
|
||||
<div onClick={rowClick}>
|
||||
<Harness title="Original" onOpen={onOpen} />
|
||||
</div>,
|
||||
);
|
||||
|
||||
fireEvent.click(screen.getByRole("button", { name: "Original" }));
|
||||
|
||||
expect(onOpen).toHaveBeenCalledTimes(1);
|
||||
expect(screen.queryByRole("textbox")).toBeNull();
|
||||
expect(rowClick).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("enters edit mode from the rename affordance and commits on Enter", () => {
|
||||
const onUpdate = vi.fn();
|
||||
render(<Harness title="Original" onUpdate={onUpdate} />);
|
||||
|
||||
fireEvent.click(screen.getByRole("button", { name: "Rename issue" }));
|
||||
const input = screen.getByRole("textbox");
|
||||
fireEvent.change(input, { target: { value: "Renamed" } });
|
||||
fireEvent.keyDown(input, { key: "Enter" });
|
||||
|
||||
expect(onUpdate).toHaveBeenCalledWith({ title: "Renamed" });
|
||||
expect(screen.queryByRole("textbox")).toBeNull();
|
||||
});
|
||||
|
||||
it("commits on click-away without also navigating into the issue", async () => {
|
||||
const user = userEvent.setup({ delay: null });
|
||||
const onUpdate = vi.fn();
|
||||
const rowClick = vi.fn();
|
||||
render(
|
||||
// The row's onClick is the navigation handler. Committing a rename by
|
||||
// clicking away (which blurs the input) must not bubble into it.
|
||||
<div onClick={rowClick}>
|
||||
<Harness title="Original" onUpdate={onUpdate} />
|
||||
</div>,
|
||||
);
|
||||
|
||||
await user.click(screen.getByRole("button", { name: "Rename issue" }));
|
||||
const input = screen.getByRole("textbox");
|
||||
await user.clear(input);
|
||||
await user.type(input, "Renamed");
|
||||
// The identifier is inside the title cell and not focusable, so clicking
|
||||
// it blurs the input (→ commit) and is the click that previously leaked
|
||||
// into row navigation.
|
||||
await user.click(screen.getByText("MUL-1"));
|
||||
|
||||
expect(onUpdate).toHaveBeenCalledWith({ title: "Renamed" });
|
||||
expect(screen.queryByRole("textbox")).toBeNull();
|
||||
expect(rowClick).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("preserves an active draft when a realtime title snapshot arrives", () => {
|
||||
function SnapshotHarness({ title }: { title: string }) {
|
||||
const [editing, setEditing] = useState(false);
|
||||
return (
|
||||
<InlineTitle
|
||||
{...baseProps}
|
||||
row={makeRow(title)}
|
||||
editing={editing}
|
||||
onEditingChange={setEditing}
|
||||
/>
|
||||
);
|
||||
}
|
||||
const view = render(<SnapshotHarness title="Original" />);
|
||||
|
||||
fireEvent.click(screen.getByRole("button", { name: "Rename issue" }));
|
||||
const input = screen.getByRole("textbox");
|
||||
fireEvent.change(input, { target: { value: "Local draft" } });
|
||||
|
||||
view.rerender(<InlineTitle row={makeRow("Remote title")} {...props} />);
|
||||
view.rerender(<SnapshotHarness title="Remote title" />);
|
||||
|
||||
expect(screen.getByRole("textbox")).toHaveValue("Local draft");
|
||||
fireEvent.keyDown(screen.getByRole("textbox"), { key: "Escape" });
|
||||
|
||||
325
packages/views/issues/components/table-view-editing.test.tsx
Normal file
325
packages/views/issues/components/table-view-editing.test.tsx
Normal file
@@ -0,0 +1,325 @@
|
||||
/**
|
||||
* @vitest-environment jsdom
|
||||
*
|
||||
* MUL-5108 regression coverage: an open cell editor popup must survive the
|
||||
* data refreshes that constantly hit an active workspace (realtime refetches
|
||||
* rebuilding childProgressMap / issue arrays, window pages arriving, the
|
||||
* end-of-load hierarchy assembly). Before the fix, refreshed lookups rebuilt
|
||||
* the column defs' render closures — flexRender treats those as component
|
||||
* TYPES, so React remounted every cell and the just-opened picker closed.
|
||||
*/
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import { cleanup, render, screen, within } from "@testing-library/react";
|
||||
import userEvent from "@testing-library/user-event";
|
||||
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
|
||||
import { setApiInstance } from "@multica/core/api";
|
||||
import type { ApiClient } from "@multica/core/api/client";
|
||||
import { ViewStoreProvider } from "@multica/core/issues/stores/view-store-context";
|
||||
import { getIssueSurfaceViewStore } from "@multica/core/issues/stores/surface-view-store";
|
||||
import type { Issue } from "@multica/core/types";
|
||||
import { renderWithI18n } from "../../test/i18n";
|
||||
import { IssueSurfaceSelectionProvider } from "../surface/selection-context";
|
||||
import type { IssueSurfaceSelection } from "../surface/selection-context";
|
||||
import type { ChildProgress } from "./list-row";
|
||||
import { TableView, useReleaseEditingCellOnUnmount } from "./table-view";
|
||||
|
||||
vi.mock("@multica/core/hooks", () => ({
|
||||
useWorkspaceId: () => "ws-1",
|
||||
}));
|
||||
|
||||
// jsdom has no layout, so the real row virtualizer sees a 0-height viewport
|
||||
// and renders nothing. Render every row inline instead (mirrors the
|
||||
// react-virtuoso mock in issue-surface.test.tsx).
|
||||
vi.mock("@tanstack/react-virtual", () => ({
|
||||
useVirtualizer: (options: {
|
||||
count: number;
|
||||
getItemKey?: (index: number) => unknown;
|
||||
}) => ({
|
||||
getVirtualItems: () =>
|
||||
Array.from({ length: options.count }, (_, index) => ({
|
||||
index,
|
||||
key: options.getItemKey?.(index) ?? index,
|
||||
start: index * 41,
|
||||
end: (index + 1) * 41,
|
||||
size: 41,
|
||||
lane: 0,
|
||||
})),
|
||||
getTotalSize: () => options.count * 41,
|
||||
measureElement: () => {},
|
||||
}),
|
||||
}));
|
||||
|
||||
vi.mock("@multica/core/workspace/hooks", () => ({
|
||||
useActorName: () => ({ getActorName: () => "Someone" }),
|
||||
buildActorNameResolver: () => () => "Someone",
|
||||
}));
|
||||
|
||||
const mockAuthUser = { id: "user-1", email: "t@t.co", name: "Tester" };
|
||||
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 }) },
|
||||
),
|
||||
}));
|
||||
|
||||
vi.mock("../../navigation", () => ({
|
||||
AppLink: ({ children, ...props }: React.ComponentProps<"a">) => (
|
||||
<a {...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,
|
||||
useWorkspacePaths: () => actual.paths.workspace("test"),
|
||||
};
|
||||
});
|
||||
|
||||
class ObserverStub {
|
||||
observe() {}
|
||||
unobserve() {}
|
||||
disconnect() {}
|
||||
takeRecords() {
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
function makeIssue(id: string, title: string, status: Issue["status"]): Issue {
|
||||
return {
|
||||
id,
|
||||
workspace_id: "ws-1",
|
||||
number: 1,
|
||||
identifier: `MUL-${id}`,
|
||||
title,
|
||||
description: null,
|
||||
status,
|
||||
priority: "none",
|
||||
assignee_type: null,
|
||||
assignee_id: null,
|
||||
creator_type: "member",
|
||||
creator_id: "member-1",
|
||||
parent_issue_id: null,
|
||||
project_id: null,
|
||||
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",
|
||||
};
|
||||
}
|
||||
|
||||
const selection: IssueSurfaceSelection = {
|
||||
selectedIds: new Set<string>(),
|
||||
toggle: () => {},
|
||||
select: () => {},
|
||||
deselect: () => {},
|
||||
clear: () => {},
|
||||
};
|
||||
|
||||
function Harness({
|
||||
issues,
|
||||
childProgressMap,
|
||||
surfaceKey,
|
||||
}: {
|
||||
issues: Issue[];
|
||||
childProgressMap: Map<string, ChildProgress>;
|
||||
surfaceKey: string;
|
||||
}) {
|
||||
return (
|
||||
<ViewStoreProvider store={getIssueSurfaceViewStore(surfaceKey)}>
|
||||
<IssueSurfaceSelectionProvider selection={selection}>
|
||||
<TableView
|
||||
issues={issues}
|
||||
childProgressMap={childProgressMap}
|
||||
fetchNextPage={() => Promise.resolve()}
|
||||
hasNextPage={false}
|
||||
isFetchingNextPage={false}
|
||||
windowError={false}
|
||||
total={issues.length}
|
||||
search=""
|
||||
onSearchChange={() => {}}
|
||||
exportIssues={() => Promise.resolve(issues)}
|
||||
resolveExportLookups={() =>
|
||||
Promise.resolve({
|
||||
projectMap: new Map(),
|
||||
childProgressMap: new Map(),
|
||||
})
|
||||
}
|
||||
/>
|
||||
</IssueSurfaceSelectionProvider>
|
||||
</ViewStoreProvider>
|
||||
);
|
||||
}
|
||||
|
||||
describe("TableView cell editors under data refresh", () => {
|
||||
let queryClient: QueryClient;
|
||||
|
||||
beforeEach(() => {
|
||||
vi.stubGlobal("IntersectionObserver", ObserverStub);
|
||||
vi.stubGlobal("ResizeObserver", ObserverStub);
|
||||
queryClient = new QueryClient({
|
||||
defaultOptions: { queries: { retry: false, staleTime: Infinity } },
|
||||
});
|
||||
setApiInstance({
|
||||
listProperties: async () => ({ properties: [] }),
|
||||
listMembers: async () => [],
|
||||
listAgents: async () => [],
|
||||
listSquads: async () => [],
|
||||
getAssigneeFrequency: async () => [],
|
||||
} as unknown as ApiClient);
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
cleanup();
|
||||
vi.unstubAllGlobals();
|
||||
});
|
||||
|
||||
// Explicit timeout: this mounts the full TableView with every picker + a
|
||||
// QueryClient, so it is heavier than a unit test. `delay: null` drives
|
||||
// userEvent off fake-synchronous timing instead of the default real-timer
|
||||
// gaps between events, which were what let the whole gesture blow past the
|
||||
// 5s default under concurrent CI worker load (MUL-5108 review R1#1).
|
||||
it("keeps the status picker open and the row order frozen across a refresh, then catches up on close", async () => {
|
||||
const user = userEvent.setup({ delay: null, pointerEventsCheck: 0 });
|
||||
const issueA = makeIssue("a", "Alpha task", "todo");
|
||||
const issueB = makeIssue("b", "Beta task", "in_progress");
|
||||
const progress1 = new Map<string, ChildProgress>();
|
||||
const surfaceKey = `test-surface-${Math.floor(Math.random() * 1e9)}`;
|
||||
|
||||
const view = renderWithI18n(
|
||||
<QueryClientProvider client={queryClient}>
|
||||
<Harness
|
||||
issues={[issueA, issueB]}
|
||||
childProgressMap={progress1}
|
||||
surfaceKey={surfaceKey}
|
||||
/>
|
||||
</QueryClientProvider>,
|
||||
);
|
||||
|
||||
const identifiers = () =>
|
||||
screen.getAllByText(/^MUL-/).map((node) => node.textContent);
|
||||
expect(identifiers()).toEqual(["MUL-a", "MUL-b"]);
|
||||
|
||||
// Open the status picker on row A: its cell trigger shows "Todo".
|
||||
const rowA = screen.getByText("MUL-a").closest("tr")!;
|
||||
await user.click(within(rowA).getByRole("button", { name: /Todo/ }));
|
||||
// Base UI portals the popup; "Backlog" only exists while it is open.
|
||||
expect(screen.getByRole("button", { name: /Backlog/ })).toBeTruthy();
|
||||
|
||||
// A realtime refresh lands: new array identities, rows reordered, new
|
||||
// childProgressMap. The popup must stay open and the structure must hold
|
||||
// (frozen order) so the anchor row cannot move away mid-interaction.
|
||||
const refreshedA = { ...issueA, title: "Alpha task (updated)" };
|
||||
view.rerender(
|
||||
<QueryClientProvider client={queryClient}>
|
||||
<Harness
|
||||
issues={[issueB, refreshedA]}
|
||||
childProgressMap={new Map<string, ChildProgress>()}
|
||||
surfaceKey={surfaceKey}
|
||||
/>
|
||||
</QueryClientProvider>,
|
||||
);
|
||||
|
||||
expect(screen.getByRole("button", { name: /Backlog/ })).toBeTruthy();
|
||||
expect(identifiers()).toEqual(["MUL-a", "MUL-b"]);
|
||||
// …while the VALUES inside the frozen rows keep tracking the live data.
|
||||
expect(screen.getByText("Alpha task (updated)")).toBeTruthy();
|
||||
|
||||
// Selecting a value closes the editor; the deferred live order applies.
|
||||
await user.click(screen.getByRole("button", { name: /Backlog/ }));
|
||||
expect(screen.queryByRole("button", { name: /Backlog/ })).toBeNull();
|
||||
expect(identifiers()).toEqual(["MUL-b", "MUL-a"]);
|
||||
}, 20_000);
|
||||
});
|
||||
|
||||
// Row virtualization unmounts a cell when its row scrolls out of the window
|
||||
// (data-table.tsx). Base UI does not fire onOpenChange(false) on unmount, so
|
||||
// the hoisted editing key — and the frozen structure it holds — needs an
|
||||
// explicit release when the owning cell leaves the DOM (MUL-5108 review R1#3).
|
||||
// A cell unmounting is exactly what a virtual-window change does; probing the
|
||||
// hook directly keeps the assertion deterministic (jsdom has no layout for a
|
||||
// real virtualizer to react to).
|
||||
describe("useReleaseEditingCellOnUnmount", () => {
|
||||
function Probe({
|
||||
cellKey,
|
||||
editingCellKey,
|
||||
setEditingCellKey,
|
||||
}: {
|
||||
cellKey: string | null;
|
||||
editingCellKey: string | null;
|
||||
setEditingCellKey: (key: string | null) => void;
|
||||
}) {
|
||||
useReleaseEditingCellOnUnmount(cellKey, editingCellKey, setEditingCellKey);
|
||||
return null;
|
||||
}
|
||||
|
||||
afterEach(cleanup);
|
||||
|
||||
it("clears the key when the cell that owns the open editor unmounts", () => {
|
||||
const setEditingCellKey = vi.fn();
|
||||
const { unmount } = render(
|
||||
<Probe
|
||||
cellKey="issue-a:status"
|
||||
editingCellKey="issue-a:status"
|
||||
setEditingCellKey={setEditingCellKey}
|
||||
/>,
|
||||
);
|
||||
|
||||
unmount();
|
||||
|
||||
expect(setEditingCellKey).toHaveBeenCalledWith(null);
|
||||
});
|
||||
|
||||
it("leaves the key untouched when a different cell unmounts", () => {
|
||||
const setEditingCellKey = vi.fn();
|
||||
const { unmount } = render(
|
||||
<Probe
|
||||
cellKey="issue-b:status"
|
||||
editingCellKey="issue-a:status"
|
||||
setEditingCellKey={setEditingCellKey}
|
||||
/>,
|
||||
);
|
||||
|
||||
unmount();
|
||||
|
||||
expect(setEditingCellKey).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("does not fire on mount while the cell is not yet the active editor", () => {
|
||||
const setEditingCellKey = vi.fn();
|
||||
// Mount not-owning, then the editor opens on THIS cell (rerender, no
|
||||
// remount), then it unmounts — the responder reads the latest key.
|
||||
const { rerender, unmount } = render(
|
||||
<Probe
|
||||
cellKey="issue-a:status"
|
||||
editingCellKey={null}
|
||||
setEditingCellKey={setEditingCellKey}
|
||||
/>,
|
||||
);
|
||||
expect(setEditingCellKey).not.toHaveBeenCalled();
|
||||
|
||||
rerender(
|
||||
<Probe
|
||||
cellKey="issue-a:status"
|
||||
editingCellKey="issue-a:status"
|
||||
setEditingCellKey={setEditingCellKey}
|
||||
/>,
|
||||
);
|
||||
expect(setEditingCellKey).not.toHaveBeenCalled();
|
||||
|
||||
unmount();
|
||||
expect(setEditingCellKey).toHaveBeenCalledTimes(1);
|
||||
expect(setEditingCellKey).toHaveBeenCalledWith(null);
|
||||
});
|
||||
});
|
||||
@@ -7,7 +7,9 @@ import {
|
||||
calculateIssueTableColumn,
|
||||
getIssueTableSelectionRange,
|
||||
isTableStructureSuspended,
|
||||
refreshFrozenTableRows,
|
||||
shouldAutoLoadNextWindowPage,
|
||||
type IssueTableDisplayRow,
|
||||
} from "./table-view-model";
|
||||
|
||||
function makeIssue(id: string, overrides: Partial<Issue> = {}): Issue {
|
||||
@@ -262,6 +264,57 @@ describe("getIssueTableSelectionRange", () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe("refreshFrozenTableRows", () => {
|
||||
const groupRow: IssueTableDisplayRow = {
|
||||
kind: "group",
|
||||
key: "status:todo",
|
||||
label: "Todo",
|
||||
count: 2,
|
||||
collapsed: false,
|
||||
};
|
||||
|
||||
it("keeps structure and keys while swapping in live issue objects", () => {
|
||||
const staleA = makeIssue("issue-1", { title: "Stale A" });
|
||||
const staleB = makeIssue("issue-2", { title: "Stale B" });
|
||||
const snapshot: IssueTableDisplayRow[] = [
|
||||
groupRow,
|
||||
{ kind: "issue", key: staleA.id, issue: staleA, depth: 0, hasChildren: true, collapsed: false },
|
||||
{ kind: "issue", key: staleB.id, issue: staleB, depth: 1, hasChildren: false, collapsed: false },
|
||||
];
|
||||
const liveA = makeIssue("issue-1", { title: "Live A" });
|
||||
|
||||
const refreshed = refreshFrozenTableRows(
|
||||
snapshot,
|
||||
new Map([[liveA.id, liveA], [staleB.id, staleB]]),
|
||||
);
|
||||
|
||||
expect(refreshed.map((row) => row.key)).toEqual([
|
||||
"status:todo",
|
||||
"issue-1",
|
||||
"issue-2",
|
||||
]);
|
||||
expect(refreshed[1]).toMatchObject({
|
||||
issue: liveA,
|
||||
depth: 0,
|
||||
hasChildren: true,
|
||||
});
|
||||
// Identical live object → the snapshot row is reused untouched.
|
||||
expect(refreshed[2]).toBe(snapshot[2]);
|
||||
expect(refreshed[0]).toBe(groupRow);
|
||||
});
|
||||
|
||||
it("keeps the stale issue when it vanished from the live window", () => {
|
||||
const stale = makeIssue("issue-9", { title: "Deleted remotely" });
|
||||
const snapshot: IssueTableDisplayRow[] = [
|
||||
{ kind: "issue", key: stale.id, issue: stale, depth: 0, hasChildren: false, collapsed: false },
|
||||
];
|
||||
|
||||
const refreshed = refreshFrozenTableRows(snapshot, new Map());
|
||||
|
||||
expect(refreshed[0]).toBe(snapshot[0]);
|
||||
});
|
||||
});
|
||||
|
||||
describe("table calculations and CSV", () => {
|
||||
it("calculates numeric custom-property sums, averages, and counts", () => {
|
||||
const issues = [
|
||||
|
||||
@@ -330,6 +330,27 @@ export function buildIssueTableRows(
|
||||
return rows;
|
||||
}
|
||||
|
||||
/**
|
||||
* Refresh the issue objects inside a frozen row snapshot. While a cell editor
|
||||
* popup is open the table renders a structural snapshot (row order, grouping,
|
||||
* nesting stay put so the popup's anchor row cannot be reordered out of the
|
||||
* virtualized render window mid-interaction), but the VALUES inside those
|
||||
* rows must stay live — a multi-select toggle, for example, commits while the
|
||||
* popup is open and its checkmark has to reflect the optimistic cache.
|
||||
* Issues deleted from the live window keep their stale snapshot object; the
|
||||
* structure catches up the moment the editor closes.
|
||||
*/
|
||||
export function refreshFrozenTableRows(
|
||||
snapshot: IssueTableDisplayRow[],
|
||||
issueById: ReadonlyMap<string, Issue>,
|
||||
): IssueTableDisplayRow[] {
|
||||
return snapshot.map((row) => {
|
||||
if (row.kind !== "issue") return row;
|
||||
const live = issueById.get(row.issue.id);
|
||||
return live && live !== row.issue ? { ...row, issue: live } : row;
|
||||
});
|
||||
}
|
||||
|
||||
function columnValue(
|
||||
issue: Issue,
|
||||
columnKey: TableColumnKey,
|
||||
|
||||
@@ -26,9 +26,13 @@ import { CSS } from "@dnd-kit/utilities";
|
||||
import {
|
||||
getCoreRowModel,
|
||||
useReactTable,
|
||||
type CellContext,
|
||||
type ColumnDef,
|
||||
type ColumnSizingState,
|
||||
type HeaderContext,
|
||||
type OnChangeFn,
|
||||
type Table as TanstackTable,
|
||||
type TableMeta,
|
||||
} from "@tanstack/react-table";
|
||||
import {
|
||||
ArrowDown,
|
||||
@@ -39,6 +43,7 @@ import {
|
||||
EyeOff,
|
||||
GripVertical,
|
||||
Loader2,
|
||||
Pencil,
|
||||
Plus,
|
||||
Search,
|
||||
X,
|
||||
@@ -67,7 +72,6 @@ import {
|
||||
TABLE_SYSTEM_COLUMNS,
|
||||
propertyIdFromViewKey,
|
||||
type SortField,
|
||||
type TableColumnConfig,
|
||||
type TableColumnKey,
|
||||
type TableSystemColumnKey,
|
||||
} from "@multica/core/issues/stores/view-store";
|
||||
@@ -111,6 +115,7 @@ import {
|
||||
buildIssueTableRows,
|
||||
getIssueTableSelectionRange,
|
||||
isTableStructureSuspended,
|
||||
refreshFrozenTableRows,
|
||||
shouldAutoLoadNextWindowPage,
|
||||
type IssueTableDisplayRow,
|
||||
} from "./table-view-model";
|
||||
@@ -467,19 +472,37 @@ export function TableIssueSearch({
|
||||
|
||||
export function InlineTitle({
|
||||
row,
|
||||
editing,
|
||||
onEditingChange,
|
||||
onUpdate,
|
||||
onOpen,
|
||||
onToggleParent,
|
||||
toggleLabel,
|
||||
renameLabel,
|
||||
}: {
|
||||
row: Extract<IssueTableDisplayRow, { kind: "issue" }>;
|
||||
/** Rename state is owned by the table (one editor at a time) so it also
|
||||
* survives cell remounts and drives the structure freeze. */
|
||||
editing: boolean;
|
||||
onEditingChange: (editing: boolean) => void;
|
||||
onUpdate: (updates: Partial<UpdateIssueRequest>) => void;
|
||||
/** Navigate to the issue — clicking the title is the primary way IN. */
|
||||
onOpen: () => void;
|
||||
onToggleParent: () => void;
|
||||
toggleLabel: string;
|
||||
renameLabel: string;
|
||||
}) {
|
||||
const [editing, setEditing] = useState(false);
|
||||
const [draft, setDraft] = useState(row.issue.title);
|
||||
const editingRef = useRef(editing);
|
||||
editingRef.current = editing;
|
||||
// True between the mousedown and the click of ONE gesture when that gesture
|
||||
// began while the rename input was up. onBlur commits and flips `editing`
|
||||
// off synchronously, before the click that caused the blur lands — so a
|
||||
// guard keyed only on the current `editing` value is already gone by click
|
||||
// time, and the commit-click bubbles into row navigation (and could hit the
|
||||
// title's own open handler): clicking away to save a rename would also open
|
||||
// the issue (MUL-5108 review R1#2).
|
||||
const gestureStartedWhileEditingRef = useRef(false);
|
||||
|
||||
useEffect(() => {
|
||||
// Realtime/cache snapshots should refresh the passive label, but must not
|
||||
@@ -489,7 +512,7 @@ export function InlineTitle({
|
||||
|
||||
const commit = () => {
|
||||
const title = draft.trim();
|
||||
setEditing(false);
|
||||
onEditingChange(false);
|
||||
if (title && title !== row.issue.title) onUpdate({ title });
|
||||
else setDraft(row.issue.title);
|
||||
};
|
||||
@@ -498,14 +521,30 @@ export function InlineTitle({
|
||||
<div
|
||||
className="flex min-w-0 items-center gap-1.5"
|
||||
style={{ paddingLeft: row.depth * 18 }}
|
||||
onClick={stopRowNavigation}
|
||||
// Record whether the gesture began while editing (mousedown fires before
|
||||
// the blur that commits), then swallow that click in the capture phase —
|
||||
// before it can reach the row (navigation) or the title's open handler.
|
||||
// A gesture that began while NOT editing passes through untouched, so
|
||||
// clicking dead space still opens the issue.
|
||||
onMouseDownCapture={() => {
|
||||
gestureStartedWhileEditingRef.current = editingRef.current;
|
||||
}}
|
||||
onClickCapture={(event) => {
|
||||
if (editing || gestureStartedWhileEditingRef.current) {
|
||||
event.stopPropagation();
|
||||
}
|
||||
gestureStartedWhileEditingRef.current = false;
|
||||
}}
|
||||
>
|
||||
{row.hasChildren ? (
|
||||
<button
|
||||
type="button"
|
||||
aria-label={toggleLabel}
|
||||
className="rounded p-0.5 text-muted-foreground hover:bg-accent"
|
||||
onClick={onToggleParent}
|
||||
onClick={(event) => {
|
||||
event.stopPropagation();
|
||||
onToggleParent();
|
||||
}}
|
||||
>
|
||||
{row.collapsed ? (
|
||||
<ChevronRight className="size-3.5" />
|
||||
@@ -529,36 +568,60 @@ export function InlineTitle({
|
||||
if (event.key === "Enter") commit();
|
||||
if (event.key === "Escape") {
|
||||
setDraft(row.issue.title);
|
||||
setEditing(false);
|
||||
onEditingChange(false);
|
||||
}
|
||||
}}
|
||||
className="h-7 min-w-0 flex-1 px-2"
|
||||
/>
|
||||
) : (
|
||||
<button
|
||||
type="button"
|
||||
className="min-w-0 flex-1 truncate text-left hover:underline"
|
||||
onClick={() => setEditing(true)}
|
||||
>
|
||||
{row.issue.title}
|
||||
</button>
|
||||
<>
|
||||
<button
|
||||
type="button"
|
||||
className="min-w-0 flex-1 truncate text-left hover:underline"
|
||||
onClick={(event) => {
|
||||
event.stopPropagation();
|
||||
onOpen();
|
||||
}}
|
||||
>
|
||||
{row.issue.title}
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
aria-label={renameLabel}
|
||||
className="shrink-0 rounded p-1 text-muted-foreground/60 opacity-0 hover:bg-accent hover:text-foreground focus-visible:opacity-100 group-hover:opacity-100"
|
||||
onClick={(event) => {
|
||||
event.stopPropagation();
|
||||
setDraft(row.issue.title);
|
||||
onEditingChange(true);
|
||||
}}
|
||||
>
|
||||
<Pencil className="size-3" />
|
||||
</button>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function LazyLabelCell({ issue }: { issue: Issue }) {
|
||||
function LazyLabelCell({
|
||||
issue,
|
||||
open,
|
||||
onOpenChange,
|
||||
}: {
|
||||
issue: Issue;
|
||||
open: boolean;
|
||||
onOpenChange: (open: boolean) => void;
|
||||
}) {
|
||||
const { t } = useT("issues");
|
||||
const [editing, setEditing] = useState(false);
|
||||
const labels = issue.labels ?? [];
|
||||
if (editing) {
|
||||
if (open) {
|
||||
return (
|
||||
<div onClick={stopRowNavigation}>
|
||||
<LabelPicker
|
||||
issueId={issue.id}
|
||||
open
|
||||
onOpenChange={(open) => {
|
||||
if (!open) setEditing(false);
|
||||
onOpenChange={(next) => {
|
||||
if (!next) onOpenChange(false);
|
||||
}}
|
||||
triggerRender={<button type="button" className="flex max-w-full gap-1" />}
|
||||
/>
|
||||
@@ -571,7 +634,7 @@ function LazyLabelCell({ issue }: { issue: Issue }) {
|
||||
className="flex max-w-full items-center gap-1 overflow-hidden rounded px-1 py-0.5 hover:bg-accent"
|
||||
onClick={(event) => {
|
||||
event.stopPropagation();
|
||||
setEditing(true);
|
||||
onOpenChange(true);
|
||||
}}
|
||||
>
|
||||
{labels.length > 0 ? (
|
||||
@@ -645,6 +708,353 @@ function propertyDisplayValue(
|
||||
return String(value);
|
||||
}
|
||||
|
||||
/**
|
||||
* Render-time context for the module-level cell/header components below,
|
||||
* carried on `table.options.meta`. The renderers MUST be module-level
|
||||
* components with stable identities: TanStack's flexRender mounts a
|
||||
* function-typed `cell`/`header` as a React component, so a renderer closure
|
||||
* rebuilt when any lookup changed identity (childProgressMap on every
|
||||
* realtime refetch, propertyById, actor names…) was a NEW element type and
|
||||
* React remounted every cell — closing any open picker popup and dropping
|
||||
* in-progress drafts the moment workspace activity refreshed the window
|
||||
* (MUL-5108). Data flows through meta instead so the element types never
|
||||
* change.
|
||||
*/
|
||||
type TableViewMeta = {
|
||||
childProgressMap: Map<string, ChildProgress>;
|
||||
propertyById: Map<string, IssueProperty>;
|
||||
properties: IssueProperty[];
|
||||
visibleIssueIds: string[];
|
||||
/** `${row.key}:${column.id}` of the cell whose editor popup / rename input
|
||||
* is open, or null. Owned by TableView so the open editor survives cell
|
||||
* remounts and freezes the table structure while it is up. */
|
||||
editingCellKey: string | null;
|
||||
setEditingCellKey: (key: string | null) => void;
|
||||
updateIssue: (issueId: string, updates: Partial<UpdateIssueRequest>) => void;
|
||||
openIssue: (issueId: string) => void;
|
||||
toggleTableParentCollapsed: (issueId: string) => void;
|
||||
handleIssueSelection: (issueId: string, shiftKey: boolean) => void;
|
||||
getActorName: (actorType: string, actorId: string) => string;
|
||||
columnLabel: (key: TableColumnKey) => string;
|
||||
sortBy: SortField;
|
||||
sortDirection: "asc" | "desc";
|
||||
onSort: (field: SortField, direction: "asc" | "desc") => void;
|
||||
toggleTableColumn: (key: TableColumnKey) => void;
|
||||
};
|
||||
|
||||
function getTableViewMeta(
|
||||
table: TanstackTable<IssueTableDisplayRow>,
|
||||
): TableViewMeta {
|
||||
return table.options.meta as unknown as TableViewMeta;
|
||||
}
|
||||
|
||||
/**
|
||||
* Release the hoisted editing key when the cell that owns it unmounts.
|
||||
*
|
||||
* Row virtualization (see data-table.tsx) unmounts a cell as its row scrolls
|
||||
* out of the rendered window. Base UI does NOT call onOpenChange(false) on
|
||||
* unmount, so without this the open picker's key — and the frozen row
|
||||
* structure keyed off it — would persist after the anchor row leaves the
|
||||
* viewport: the table would stay frozen, and scrolling the row back would
|
||||
* silently reopen the picker and discard any in-progress rename draft
|
||||
* (MUL-5108 review R1#3). Clearing the key iff this unmounting cell still owns
|
||||
* it thaws the structure and closes the editor.
|
||||
*
|
||||
* Live values are read through refs so the empty-dep cleanup always sees the
|
||||
* current key/setter. At initial mount a cell is never yet the active editor
|
||||
* (the editor is opened by a later interaction, which does not remount the
|
||||
* cell), so this never fires spuriously — including under StrictMode's
|
||||
* mount → unmount → mount probe, whose first cleanup sees `editingCellKey`
|
||||
* still unequal to this cell's key.
|
||||
*/
|
||||
export function useReleaseEditingCellOnUnmount(
|
||||
cellKey: string | null,
|
||||
editingCellKey: string | null,
|
||||
setEditingCellKey: (key: string | null) => void,
|
||||
) {
|
||||
const editingCellKeyRef = useRef(editingCellKey);
|
||||
editingCellKeyRef.current = editingCellKey;
|
||||
const setEditingCellKeyRef = useRef(setEditingCellKey);
|
||||
setEditingCellKeyRef.current = setEditingCellKey;
|
||||
useEffect(() => {
|
||||
return () => {
|
||||
if (cellKey !== null && editingCellKeyRef.current === cellKey) {
|
||||
setEditingCellKeyRef.current(null);
|
||||
}
|
||||
};
|
||||
}, [cellKey]);
|
||||
}
|
||||
|
||||
function IssueTableSelectHeader({
|
||||
table,
|
||||
}: HeaderContext<IssueTableDisplayRow, unknown>) {
|
||||
const meta = getTableViewMeta(table);
|
||||
const { t } = useT("issues");
|
||||
return (
|
||||
<SelectAllCheckbox
|
||||
issueIds={meta.visibleIssueIds}
|
||||
label={t(($) => $.table.select_all)}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
function IssueTableSelectCell({
|
||||
row,
|
||||
table,
|
||||
}: CellContext<IssueTableDisplayRow, unknown>) {
|
||||
const meta = getTableViewMeta(table);
|
||||
const selection = useIssueSurfaceSelection();
|
||||
const { t } = useT("issues");
|
||||
if (row.original.kind !== "issue") return null;
|
||||
const issue = row.original.issue;
|
||||
return (
|
||||
<IssueCheckbox
|
||||
checked={selection.selectedIds.has(issue.id)}
|
||||
label={t(($) => $.table.select_issue, { identifier: issue.identifier })}
|
||||
onToggle={(shiftKey) => meta.handleIssueSelection(issue.id, shiftKey)}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
function IssueTableAddColumnHeader({
|
||||
table,
|
||||
}: HeaderContext<IssueTableDisplayRow, unknown>) {
|
||||
const meta = getTableViewMeta(table);
|
||||
const { t } = useT("issues");
|
||||
return (
|
||||
<TableColumnPicker
|
||||
properties={meta.properties}
|
||||
trigger={
|
||||
<button
|
||||
type="button"
|
||||
aria-label={t(($) => $.table.columns.add)}
|
||||
className="rounded p-1 text-muted-foreground hover:bg-accent hover:text-foreground"
|
||||
>
|
||||
<Plus className="size-3.5" />
|
||||
</button>
|
||||
}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
function IssueTableEmptyCell() {
|
||||
return null;
|
||||
}
|
||||
|
||||
function IssueTableHeaderCell({
|
||||
column,
|
||||
table,
|
||||
}: HeaderContext<IssueTableDisplayRow, unknown>) {
|
||||
const meta = getTableViewMeta(table);
|
||||
const { t } = useT("issues");
|
||||
const key = column.id as TableColumnKey;
|
||||
const propertyId = propertyIdFromViewKey(key);
|
||||
const property = propertyId ? meta.propertyById.get(propertyId) : undefined;
|
||||
const staticSort = propertyId
|
||||
? property && !["multi_select", "checkbox"].includes(property.type)
|
||||
? (`property:${propertyId}` as SortField)
|
||||
: undefined
|
||||
: SORTABLE_COLUMNS[key as TableSystemColumnKey];
|
||||
const label = meta.columnLabel(key);
|
||||
return (
|
||||
<SortableColumnHeader
|
||||
columnKey={key}
|
||||
label={label}
|
||||
sortField={staticSort}
|
||||
sortBy={meta.sortBy}
|
||||
sortDirection={meta.sortDirection}
|
||||
onSort={meta.onSort}
|
||||
onHide={key === "title" ? undefined : () => meta.toggleTableColumn(key)}
|
||||
ascendingLabel={t(($) => $.table.sort_ascending)}
|
||||
descendingLabel={t(($) => $.table.sort_descending)}
|
||||
hideLabel={t(($) => $.table.columns.hide)}
|
||||
reorderLabel={t(($) => $.table.columns.reorder, { column: label })}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
function IssueTableBodyCell({
|
||||
row,
|
||||
column,
|
||||
table,
|
||||
}: CellContext<IssueTableDisplayRow, unknown>) {
|
||||
const meta = getTableViewMeta(table);
|
||||
const { t, i18n } = useT("issues");
|
||||
// Computed (and the unmount responder registered) before the early return so
|
||||
// the hook order is stable across issue/group rows.
|
||||
const cellKey =
|
||||
row.original.kind === "issue" ? `${row.original.key}:${column.id}` : null;
|
||||
useReleaseEditingCellOnUnmount(
|
||||
cellKey,
|
||||
meta.editingCellKey,
|
||||
meta.setEditingCellKey,
|
||||
);
|
||||
if (row.original.kind !== "issue") return null;
|
||||
const issueRow = row.original;
|
||||
const issue = issueRow.issue;
|
||||
const key = column.id as TableColumnKey;
|
||||
const editorOpen = meta.editingCellKey === cellKey;
|
||||
const setEditorOpen = (open: boolean) =>
|
||||
meta.setEditingCellKey(open ? cellKey : null);
|
||||
const onUpdate = (updates: Partial<UpdateIssueRequest>) =>
|
||||
meta.updateIssue(issue.id, updates);
|
||||
|
||||
const propertyId = propertyIdFromViewKey(key);
|
||||
if (propertyId) {
|
||||
const property = meta.propertyById.get(propertyId);
|
||||
if (!property) return null;
|
||||
return (
|
||||
<div onClick={stopRowNavigation}>
|
||||
<CustomPropertyValueEditor
|
||||
issue={issue}
|
||||
property={property}
|
||||
open={editorOpen}
|
||||
onOpenChange={setEditorOpen}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
switch (key) {
|
||||
case "title":
|
||||
return (
|
||||
<InlineTitle
|
||||
row={issueRow}
|
||||
editing={editorOpen}
|
||||
onEditingChange={setEditorOpen}
|
||||
onUpdate={onUpdate}
|
||||
onOpen={() => meta.openIssue(issue.id)}
|
||||
onToggleParent={() => meta.toggleTableParentCollapsed(issue.id)}
|
||||
toggleLabel={t(($) => $.table.toggle_sub_issues)}
|
||||
renameLabel={t(($) => $.table.rename_title)}
|
||||
/>
|
||||
);
|
||||
case "identifier":
|
||||
return (
|
||||
<span className="text-xs text-muted-foreground">{issue.identifier}</span>
|
||||
);
|
||||
case "status":
|
||||
return (
|
||||
<div onClick={stopRowNavigation}>
|
||||
<StatusPicker
|
||||
status={issue.status}
|
||||
onUpdate={onUpdate}
|
||||
align="start"
|
||||
open={editorOpen}
|
||||
onOpenChange={setEditorOpen}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
case "priority":
|
||||
return (
|
||||
<div onClick={stopRowNavigation}>
|
||||
<PriorityPicker
|
||||
priority={issue.priority}
|
||||
onUpdate={onUpdate}
|
||||
align="start"
|
||||
open={editorOpen}
|
||||
onOpenChange={setEditorOpen}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
case "assignee":
|
||||
return (
|
||||
<div onClick={stopRowNavigation}>
|
||||
<AssigneePicker
|
||||
assigneeType={issue.assignee_type}
|
||||
assigneeId={issue.assignee_id}
|
||||
onUpdate={onUpdate}
|
||||
align="start"
|
||||
open={editorOpen}
|
||||
onOpenChange={setEditorOpen}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
case "labels":
|
||||
return (
|
||||
<LazyLabelCell
|
||||
issue={issue}
|
||||
open={editorOpen}
|
||||
onOpenChange={setEditorOpen}
|
||||
/>
|
||||
);
|
||||
case "project":
|
||||
return (
|
||||
<div onClick={stopRowNavigation}>
|
||||
<ProjectPicker
|
||||
projectId={issue.project_id}
|
||||
onUpdate={onUpdate}
|
||||
open={editorOpen}
|
||||
onOpenChange={setEditorOpen}
|
||||
triggerRender={
|
||||
<button
|
||||
type="button"
|
||||
className="flex max-w-full items-center gap-1.5 rounded px-1 py-0.5 hover:bg-accent"
|
||||
/>
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
case "start_date":
|
||||
return (
|
||||
<div onClick={stopRowNavigation}>
|
||||
<StartDatePicker
|
||||
startDate={issue.start_date}
|
||||
onUpdate={onUpdate}
|
||||
open={editorOpen}
|
||||
onOpenChange={setEditorOpen}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
case "due_date":
|
||||
return (
|
||||
<div onClick={stopRowNavigation}>
|
||||
<DueDatePicker
|
||||
dueDate={issue.due_date}
|
||||
onUpdate={onUpdate}
|
||||
open={editorOpen}
|
||||
onOpenChange={setEditorOpen}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
case "created_at":
|
||||
case "updated_at":
|
||||
return (
|
||||
<span className="text-xs text-muted-foreground">
|
||||
{new Intl.DateTimeFormat(i18n.language, {
|
||||
month: "short",
|
||||
day: "numeric",
|
||||
year: "numeric",
|
||||
}).format(new Date(issue[key]))}
|
||||
</span>
|
||||
);
|
||||
case "child_progress": {
|
||||
const progress = meta.childProgressMap.get(issue.id);
|
||||
return progress ? (
|
||||
<span className="inline-flex items-center gap-1.5 text-xs text-muted-foreground">
|
||||
<ProgressRing done={progress.done} total={progress.total} size={15} />
|
||||
{progress.done}/{progress.total}
|
||||
</span>
|
||||
) : (
|
||||
<span className="text-muted-foreground">{t(($) => $.table.empty_value)}</span>
|
||||
);
|
||||
}
|
||||
case "creator":
|
||||
return (
|
||||
<span className="flex min-w-0 items-center gap-1.5">
|
||||
<ActorAvatar
|
||||
actorType={issue.creator_type}
|
||||
actorId={issue.creator_id}
|
||||
size="sm"
|
||||
/>
|
||||
<span className="truncate">
|
||||
{meta.getActorName(issue.creator_type, issue.creator_id)}
|
||||
</span>
|
||||
</span>
|
||||
);
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
export function TableView({
|
||||
issues,
|
||||
childProgressMap,
|
||||
@@ -658,7 +1068,7 @@ export function TableView({
|
||||
exportIssues,
|
||||
resolveExportLookups,
|
||||
}: TableViewProps) {
|
||||
const { t, i18n } = useT("issues");
|
||||
const { t } = useT("issues");
|
||||
const wsId = useWorkspaceId();
|
||||
const queryClient = useQueryClient();
|
||||
const navigation = useNavigation();
|
||||
@@ -695,6 +1105,9 @@ export function TableView({
|
||||
const setSortDirection = useViewStore((state) => state.setSortDirection);
|
||||
const [exporting, setExporting] = useState<"all" | "selected" | null>(null);
|
||||
const selectionAnchorRef = useRef<string | null>(null);
|
||||
// The one cell whose editor (picker popup / rename input) is open — see
|
||||
// TableViewMeta.editingCellKey.
|
||||
const [editingCellKey, setEditingCellKey] = useState<string | null>(null);
|
||||
|
||||
const groupingPropertyId = propertyIdFromViewKey(tableGrouping);
|
||||
const effectiveTableGrouping =
|
||||
@@ -752,7 +1165,7 @@ export function TableView({
|
||||
[activePropertyIds, tableColumns],
|
||||
);
|
||||
|
||||
const displayRows = useMemo(
|
||||
const liveDisplayRows = useMemo(
|
||||
() =>
|
||||
buildIssueTableRows(issues, {
|
||||
grouping: structureGrouping,
|
||||
@@ -780,6 +1193,33 @@ export function TableView({
|
||||
structureHierarchy,
|
||||
],
|
||||
);
|
||||
|
||||
// While a cell editor popup / rename input is open, hold the row structure
|
||||
// still: window materialization, the end-of-load hierarchy assembly, and
|
||||
// realtime refetches all rebuild the row list, and a reorder can move the
|
||||
// anchor row out of the virtualized render window — unmounting the cell and
|
||||
// closing the popup the user just opened (MUL-5108). The snapshot freezes
|
||||
// ORDER only; issue objects inside the rows keep tracking the live cache so
|
||||
// the open editor reflects optimistic updates. Live structure snaps back
|
||||
// the moment the editor closes. Ref writes happen during render on purpose:
|
||||
// the snapshot must be captured from the same render that flips `editing`
|
||||
// on, and both branches are idempotent under StrictMode double-render.
|
||||
const frozenRowsRef = useRef<IssueTableDisplayRow[] | null>(null);
|
||||
if (editingCellKey === null) frozenRowsRef.current = null;
|
||||
else if (frozenRowsRef.current === null)
|
||||
frozenRowsRef.current = liveDisplayRows;
|
||||
const frozenRows = frozenRowsRef.current;
|
||||
const issueById = useMemo(
|
||||
() => new Map(issues.map((issue) => [issue.id, issue])),
|
||||
[issues],
|
||||
);
|
||||
const displayRows = useMemo(
|
||||
() =>
|
||||
frozenRows && frozenRows !== liveDisplayRows
|
||||
? refreshFrozenTableRows(frozenRows, issueById)
|
||||
: liveDisplayRows,
|
||||
[frozenRows, issueById, liveDisplayRows],
|
||||
);
|
||||
const visibleIssueIds = useMemo(
|
||||
() =>
|
||||
displayRows
|
||||
@@ -832,178 +1272,41 @@ export function TableView({
|
||||
[actions],
|
||||
);
|
||||
|
||||
const makeColumn = useCallback(
|
||||
(config: TableColumnConfig): ColumnDef<IssueTableDisplayRow> => {
|
||||
const propertyId = propertyIdFromViewKey(config.key);
|
||||
const property = propertyId ? propertyById.get(propertyId) : undefined;
|
||||
const staticSort = propertyId
|
||||
? property && !["multi_select", "checkbox"].includes(property.type)
|
||||
? (`property:${propertyId}` as SortField)
|
||||
: undefined
|
||||
: SORTABLE_COLUMNS[config.key as TableSystemColumnKey];
|
||||
const definition: ColumnDef<IssueTableDisplayRow> = {
|
||||
id: config.key,
|
||||
minSize: config.key === "title" ? 260 : 96,
|
||||
maxSize: 640,
|
||||
enableResizing: true,
|
||||
header: () => (
|
||||
<SortableColumnHeader
|
||||
columnKey={config.key}
|
||||
label={columnLabel(config.key)}
|
||||
sortField={staticSort}
|
||||
sortBy={sortBy}
|
||||
sortDirection={sortDirection}
|
||||
onSort={(field, direction) => {
|
||||
setSortBy(field);
|
||||
setSortDirection(direction);
|
||||
}}
|
||||
onHide={
|
||||
config.key === "title"
|
||||
? undefined
|
||||
: () => toggleTableColumn(config.key)
|
||||
}
|
||||
ascendingLabel={t(($) => $.table.sort_ascending)}
|
||||
descendingLabel={t(($) => $.table.sort_descending)}
|
||||
hideLabel={t(($) => $.table.columns.hide)}
|
||||
reorderLabel={t(($) => $.table.columns.reorder, {
|
||||
column: columnLabel(config.key),
|
||||
})}
|
||||
/>
|
||||
),
|
||||
cell: ({ row }) => {
|
||||
if (row.original.kind !== "issue") return null;
|
||||
const issueRow = row.original;
|
||||
const issue = issueRow.issue;
|
||||
const onUpdate = (updates: Partial<UpdateIssueRequest>) =>
|
||||
updateIssue(issue.id, updates);
|
||||
|
||||
if (property) {
|
||||
return (
|
||||
<div onClick={stopRowNavigation}>
|
||||
<CustomPropertyValueEditor issue={issue} property={property} />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
switch (config.key) {
|
||||
case "title":
|
||||
return (
|
||||
<InlineTitle
|
||||
row={issueRow}
|
||||
onUpdate={onUpdate}
|
||||
onToggleParent={() =>
|
||||
toggleTableParentCollapsed(issue.id)
|
||||
}
|
||||
toggleLabel={t(($) => $.table.toggle_sub_issues)}
|
||||
/>
|
||||
);
|
||||
case "identifier":
|
||||
return <span className="text-xs text-muted-foreground">{issue.identifier}</span>;
|
||||
case "status":
|
||||
return (
|
||||
<div onClick={stopRowNavigation}>
|
||||
<StatusPicker status={issue.status} onUpdate={onUpdate} align="start" />
|
||||
</div>
|
||||
);
|
||||
case "priority":
|
||||
return (
|
||||
<div onClick={stopRowNavigation}>
|
||||
<PriorityPicker priority={issue.priority} onUpdate={onUpdate} align="start" />
|
||||
</div>
|
||||
);
|
||||
case "assignee":
|
||||
return (
|
||||
<div onClick={stopRowNavigation}>
|
||||
<AssigneePicker
|
||||
assigneeType={issue.assignee_type}
|
||||
assigneeId={issue.assignee_id}
|
||||
onUpdate={onUpdate}
|
||||
align="start"
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
case "labels":
|
||||
return <LazyLabelCell issue={issue} />;
|
||||
case "project":
|
||||
return (
|
||||
<div onClick={stopRowNavigation}>
|
||||
<ProjectPicker
|
||||
projectId={issue.project_id}
|
||||
onUpdate={onUpdate}
|
||||
triggerRender={<button type="button" className="flex max-w-full items-center gap-1.5 rounded px-1 py-0.5 hover:bg-accent" />}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
case "start_date":
|
||||
return (
|
||||
<div onClick={stopRowNavigation}>
|
||||
<StartDatePicker startDate={issue.start_date} onUpdate={onUpdate} />
|
||||
</div>
|
||||
);
|
||||
case "due_date":
|
||||
return (
|
||||
<div onClick={stopRowNavigation}>
|
||||
<DueDatePicker dueDate={issue.due_date} onUpdate={onUpdate} />
|
||||
</div>
|
||||
);
|
||||
case "created_at":
|
||||
case "updated_at":
|
||||
return (
|
||||
<span className="text-xs text-muted-foreground">
|
||||
{new Intl.DateTimeFormat(i18n.language, {
|
||||
month: "short",
|
||||
day: "numeric",
|
||||
year: "numeric",
|
||||
}).format(new Date(issue[config.key]))}
|
||||
</span>
|
||||
);
|
||||
case "child_progress": {
|
||||
const progress = childProgressMap.get(issue.id);
|
||||
return progress ? (
|
||||
<span className="inline-flex items-center gap-1.5 text-xs text-muted-foreground">
|
||||
<ProgressRing done={progress.done} total={progress.total} size={15} />
|
||||
{progress.done}/{progress.total}
|
||||
</span>
|
||||
) : (
|
||||
<span className="text-muted-foreground">{t(($) => $.table.empty_value)}</span>
|
||||
);
|
||||
}
|
||||
case "creator":
|
||||
return (
|
||||
<span className="flex min-w-0 items-center gap-1.5">
|
||||
<ActorAvatar
|
||||
actorType={issue.creator_type}
|
||||
actorId={issue.creator_id}
|
||||
size="sm"
|
||||
/>
|
||||
<span className="truncate">
|
||||
{getActorName(issue.creator_type, issue.creator_id)}
|
||||
</span>
|
||||
</span>
|
||||
);
|
||||
}
|
||||
return null;
|
||||
},
|
||||
};
|
||||
if (config.width !== undefined) definition.size = config.width;
|
||||
return definition;
|
||||
},
|
||||
[
|
||||
childProgressMap,
|
||||
columnLabel,
|
||||
getActorName,
|
||||
i18n.language,
|
||||
propertyById,
|
||||
setSortBy,
|
||||
setSortDirection,
|
||||
sortBy,
|
||||
sortDirection,
|
||||
t,
|
||||
toggleTableColumn,
|
||||
toggleTableParentCollapsed,
|
||||
updateIssue,
|
||||
],
|
||||
const openIssue = useCallback(
|
||||
(issueId: string) => navigation.push(paths.issueDetail(issueId)),
|
||||
[navigation, paths],
|
||||
);
|
||||
|
||||
const onSort = useCallback(
|
||||
(field: SortField, direction: "asc" | "desc") => {
|
||||
setSortBy(field);
|
||||
setSortDirection(direction);
|
||||
},
|
||||
[setSortBy, setSortDirection],
|
||||
);
|
||||
|
||||
// Fresh object every render is fine — cells read it through
|
||||
// table.options.meta at render time. What must NOT change per render are
|
||||
// the column defs' component identities below.
|
||||
const viewMeta: TableViewMeta = {
|
||||
childProgressMap,
|
||||
propertyById,
|
||||
properties,
|
||||
visibleIssueIds,
|
||||
editingCellKey,
|
||||
setEditingCellKey,
|
||||
updateIssue,
|
||||
openIssue,
|
||||
toggleTableParentCollapsed,
|
||||
handleIssueSelection,
|
||||
getActorName,
|
||||
columnLabel,
|
||||
sortBy,
|
||||
sortDirection,
|
||||
onSort,
|
||||
toggleTableColumn,
|
||||
};
|
||||
|
||||
const columns = useMemo<ColumnDef<IssueTableDisplayRow>[]>(
|
||||
() => [
|
||||
{
|
||||
@@ -1012,59 +1315,32 @@ export function TableView({
|
||||
minSize: 44,
|
||||
maxSize: 44,
|
||||
enableResizing: false,
|
||||
header: () => (
|
||||
<SelectAllCheckbox
|
||||
issueIds={visibleIssueIds}
|
||||
label={t(($) => $.table.select_all)}
|
||||
/>
|
||||
),
|
||||
cell: ({ row }) => {
|
||||
if (row.original.kind !== "issue") return null;
|
||||
const issue = row.original.issue;
|
||||
return (
|
||||
<IssueCheckbox
|
||||
checked={selection.selectedIds.has(issue.id)}
|
||||
label={t(($) => $.table.select_issue, {
|
||||
identifier: issue.identifier,
|
||||
})}
|
||||
onToggle={(shiftKey) => handleIssueSelection(issue.id, shiftKey)}
|
||||
/>
|
||||
);
|
||||
},
|
||||
header: IssueTableSelectHeader,
|
||||
cell: IssueTableSelectCell,
|
||||
},
|
||||
...visibleColumnConfigs.map(makeColumn),
|
||||
...visibleColumnConfigs.map((config): ColumnDef<IssueTableDisplayRow> => {
|
||||
const definition: ColumnDef<IssueTableDisplayRow> = {
|
||||
id: config.key,
|
||||
minSize: config.key === "title" ? 260 : 96,
|
||||
maxSize: 640,
|
||||
enableResizing: true,
|
||||
header: IssueTableHeaderCell,
|
||||
cell: IssueTableBodyCell,
|
||||
};
|
||||
if (config.width !== undefined) definition.size = config.width;
|
||||
return definition;
|
||||
}),
|
||||
{
|
||||
id: ADD_COLUMN_ID,
|
||||
size: 48,
|
||||
minSize: 48,
|
||||
maxSize: 48,
|
||||
enableResizing: false,
|
||||
header: () => (
|
||||
<TableColumnPicker
|
||||
properties={properties}
|
||||
trigger={
|
||||
<button
|
||||
type="button"
|
||||
aria-label={t(($) => $.table.columns.add)}
|
||||
className="rounded p-1 text-muted-foreground hover:bg-accent hover:text-foreground"
|
||||
>
|
||||
<Plus className="size-3.5" />
|
||||
</button>
|
||||
}
|
||||
/>
|
||||
),
|
||||
cell: () => null,
|
||||
header: IssueTableAddColumnHeader,
|
||||
cell: IssueTableEmptyCell,
|
||||
},
|
||||
],
|
||||
[
|
||||
handleIssueSelection,
|
||||
makeColumn,
|
||||
properties,
|
||||
selection.selectedIds,
|
||||
t,
|
||||
visibleColumnConfigs,
|
||||
visibleIssueIds,
|
||||
],
|
||||
[visibleColumnConfigs],
|
||||
);
|
||||
|
||||
const columnSizing = useMemo<ColumnSizingState>(
|
||||
@@ -1096,6 +1372,7 @@ export function TableView({
|
||||
columnSizing,
|
||||
columnPinning: { left: [SELECT_COLUMN_ID, "title"], right: [] },
|
||||
},
|
||||
meta: viewMeta as TableMeta<IssueTableDisplayRow>,
|
||||
onColumnSizingChange: handleColumnSizingChange,
|
||||
columnResizeMode: "onChange",
|
||||
});
|
||||
@@ -1332,7 +1609,7 @@ export function TableView({
|
||||
emptyMessage={t(($) => $.table.empty)}
|
||||
onRowClick={(row) => {
|
||||
if (row.original.kind === "issue") {
|
||||
navigation.push(paths.issueDetail(row.original.issue.id));
|
||||
openIssue(row.original.issue.id);
|
||||
}
|
||||
}}
|
||||
renderRow={(row) => {
|
||||
|
||||
@@ -115,6 +115,7 @@
|
||||
"sort_ascending": "Ascending",
|
||||
"sort_descending": "Descending",
|
||||
"toggle_sub_issues": "Toggle sub-issues",
|
||||
"rename_title": "Rename issue",
|
||||
"empty_value": "Empty",
|
||||
"quick_create_placeholder": "Add an issue…",
|
||||
"quick_create_submit": "Add",
|
||||
|
||||
@@ -113,6 +113,7 @@
|
||||
"sort_ascending": "昇順",
|
||||
"sort_descending": "降順",
|
||||
"toggle_sub_issues": "サブイシューを展開または折りたたむ",
|
||||
"rename_title": "イシュー名を変更",
|
||||
"empty_value": "空",
|
||||
"quick_create_placeholder": "イシューを追加…",
|
||||
"quick_create_submit": "追加",
|
||||
|
||||
@@ -113,6 +113,7 @@
|
||||
"sort_ascending": "오름차순",
|
||||
"sort_descending": "내림차순",
|
||||
"toggle_sub_issues": "하위 이슈 펼치기 또는 접기",
|
||||
"rename_title": "이슈 이름 변경",
|
||||
"empty_value": "비어 있음",
|
||||
"quick_create_placeholder": "이슈 추가…",
|
||||
"quick_create_submit": "추가",
|
||||
|
||||
@@ -113,6 +113,7 @@
|
||||
"sort_ascending": "升序",
|
||||
"sort_descending": "降序",
|
||||
"toggle_sub_issues": "展开或折叠子 issue",
|
||||
"rename_title": "重命名 issue",
|
||||
"empty_value": "空",
|
||||
"quick_create_placeholder": "添加 issue…",
|
||||
"quick_create_submit": "添加",
|
||||
|
||||
Reference in New Issue
Block a user