diff --git a/packages/views/issues/components/pickers/custom-property-picker.tsx b/packages/views/issues/components/pickers/custom-property-picker.tsx index 8a58db2b3..5ad72fa82 100644 --- a/packages/views/issues/components/pickers/custom-property-picker.tsx +++ b/packages/views/issues/components/pickers/custom-property-picker.tsx @@ -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( diff --git a/packages/views/issues/components/table-inline-title.test.tsx b/packages/views/issues/components/table-inline-title.test.tsx index 7dee04c06..2791798a8 100644 --- a/packages/views/issues/components/table-inline-title.test.tsx +++ b/packages/views/issues/components/table-inline-title.test.tsx @@ -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 ( + { + 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(); + 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. +
+ +
, + ); 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(); + + 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. +
+ +
, + ); + + 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 ( + + ); + } + const view = render(); + + fireEvent.click(screen.getByRole("button", { name: "Rename issue" })); const input = screen.getByRole("textbox"); fireEvent.change(input, { target: { value: "Local draft" } }); - view.rerender(); + view.rerender(); expect(screen.getByRole("textbox")).toHaveValue("Local draft"); fireEvent.keyDown(screen.getByRole("textbox"), { key: "Escape" }); diff --git a/packages/views/issues/components/table-view-editing.test.tsx b/packages/views/issues/components/table-view-editing.test.tsx new file mode 100644 index 000000000..088c608cd --- /dev/null +++ b/packages/views/issues/components/table-view-editing.test.tsx @@ -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">) => ( + {children} + ), + useNavigation: () => ({ push: vi.fn(), pathname: "/" }), +})); + +vi.mock("@multica/core/paths", async () => { + const actual = await vi.importActual( + "@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(), + toggle: () => {}, + select: () => {}, + deselect: () => {}, + clear: () => {}, +}; + +function Harness({ + issues, + childProgressMap, + surfaceKey, +}: { + issues: Issue[]; + childProgressMap: Map; + surfaceKey: string; +}) { + return ( + + + 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(), + }) + } + /> + + + ); +} + +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(); + const surfaceKey = `test-surface-${Math.floor(Math.random() * 1e9)}`; + + const view = renderWithI18n( + + + , + ); + + 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( + + ()} + surfaceKey={surfaceKey} + /> + , + ); + + 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( + , + ); + + unmount(); + + expect(setEditingCellKey).toHaveBeenCalledWith(null); + }); + + it("leaves the key untouched when a different cell unmounts", () => { + const setEditingCellKey = vi.fn(); + const { unmount } = render( + , + ); + + 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( + , + ); + expect(setEditingCellKey).not.toHaveBeenCalled(); + + rerender( + , + ); + expect(setEditingCellKey).not.toHaveBeenCalled(); + + unmount(); + expect(setEditingCellKey).toHaveBeenCalledTimes(1); + expect(setEditingCellKey).toHaveBeenCalledWith(null); + }); +}); diff --git a/packages/views/issues/components/table-view-model.test.ts b/packages/views/issues/components/table-view-model.test.ts index 2c615e043..202b81b0d 100644 --- a/packages/views/issues/components/table-view-model.test.ts +++ b/packages/views/issues/components/table-view-model.test.ts @@ -7,7 +7,9 @@ import { calculateIssueTableColumn, getIssueTableSelectionRange, isTableStructureSuspended, + refreshFrozenTableRows, shouldAutoLoadNextWindowPage, + type IssueTableDisplayRow, } from "./table-view-model"; function makeIssue(id: string, overrides: Partial = {}): 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 = [ diff --git a/packages/views/issues/components/table-view-model.ts b/packages/views/issues/components/table-view-model.ts index dbd8e643a..db14aaaec 100644 --- a/packages/views/issues/components/table-view-model.ts +++ b/packages/views/issues/components/table-view-model.ts @@ -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, +): 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, diff --git a/packages/views/issues/components/table-view.tsx b/packages/views/issues/components/table-view.tsx index f04c1c491..f14f42844 100644 --- a/packages/views/issues/components/table-view.tsx +++ b/packages/views/issues/components/table-view.tsx @@ -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; + /** 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) => 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({
{ + gestureStartedWhileEditingRef.current = editingRef.current; + }} + onClickCapture={(event) => { + if (editing || gestureStartedWhileEditingRef.current) { + event.stopPropagation(); + } + gestureStartedWhileEditingRef.current = false; + }} > {row.hasChildren ? ( + <> + + + )}
); } -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 (
{ - if (!open) setEditing(false); + onOpenChange={(next) => { + if (!next) onOpenChange(false); }} triggerRender={ + } + /> + ); +} + +function IssueTableEmptyCell() { + return null; +} + +function IssueTableHeaderCell({ + column, + table, +}: HeaderContext) { + 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 ( + 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) { + 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) => + meta.updateIssue(issue.id, updates); + + const propertyId = propertyIdFromViewKey(key); + if (propertyId) { + const property = meta.propertyById.get(propertyId); + if (!property) return null; + return ( +
+ +
+ ); + } + switch (key) { + case "title": + return ( + meta.openIssue(issue.id)} + onToggleParent={() => meta.toggleTableParentCollapsed(issue.id)} + toggleLabel={t(($) => $.table.toggle_sub_issues)} + renameLabel={t(($) => $.table.rename_title)} + /> + ); + case "identifier": + return ( + {issue.identifier} + ); + case "status": + return ( +
+ +
+ ); + case "priority": + return ( +
+ +
+ ); + case "assignee": + return ( +
+ +
+ ); + case "labels": + return ( + + ); + case "project": + return ( +
+ + } + /> +
+ ); + case "start_date": + return ( +
+ +
+ ); + case "due_date": + return ( +
+ +
+ ); + case "created_at": + case "updated_at": + return ( + + {new Intl.DateTimeFormat(i18n.language, { + month: "short", + day: "numeric", + year: "numeric", + }).format(new Date(issue[key]))} + + ); + case "child_progress": { + const progress = meta.childProgressMap.get(issue.id); + return progress ? ( + + + {progress.done}/{progress.total} + + ) : ( + {t(($) => $.table.empty_value)} + ); + } + case "creator": + return ( + + + + {meta.getActorName(issue.creator_type, issue.creator_id)} + + + ); + } + 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(null); + // The one cell whose editor (picker popup / rename input) is open — see + // TableViewMeta.editingCellKey. + const [editingCellKey, setEditingCellKey] = useState(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(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 => { - 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 = { - id: config.key, - minSize: config.key === "title" ? 260 : 96, - maxSize: 640, - enableResizing: true, - header: () => ( - { - 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) => - updateIssue(issue.id, updates); - - if (property) { - return ( -
- -
- ); - } - switch (config.key) { - case "title": - return ( - - toggleTableParentCollapsed(issue.id) - } - toggleLabel={t(($) => $.table.toggle_sub_issues)} - /> - ); - case "identifier": - return {issue.identifier}; - case "status": - return ( -
- -
- ); - case "priority": - return ( -
- -
- ); - case "assignee": - return ( -
- -
- ); - case "labels": - return ; - case "project": - return ( -
- } - /> -
- ); - case "start_date": - return ( -
- -
- ); - case "due_date": - return ( -
- -
- ); - case "created_at": - case "updated_at": - return ( - - {new Intl.DateTimeFormat(i18n.language, { - month: "short", - day: "numeric", - year: "numeric", - }).format(new Date(issue[config.key]))} - - ); - case "child_progress": { - const progress = childProgressMap.get(issue.id); - return progress ? ( - - - {progress.done}/{progress.total} - - ) : ( - {t(($) => $.table.empty_value)} - ); - } - case "creator": - return ( - - - - {getActorName(issue.creator_type, issue.creator_id)} - - - ); - } - 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[]>( () => [ { @@ -1012,59 +1315,32 @@ export function TableView({ minSize: 44, maxSize: 44, enableResizing: false, - header: () => ( - $.table.select_all)} - /> - ), - cell: ({ row }) => { - if (row.original.kind !== "issue") return null; - const issue = row.original.issue; - return ( - $.table.select_issue, { - identifier: issue.identifier, - })} - onToggle={(shiftKey) => handleIssueSelection(issue.id, shiftKey)} - /> - ); - }, + header: IssueTableSelectHeader, + cell: IssueTableSelectCell, }, - ...visibleColumnConfigs.map(makeColumn), + ...visibleColumnConfigs.map((config): ColumnDef => { + const definition: ColumnDef = { + 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: () => ( - $.table.columns.add)} - className="rounded p-1 text-muted-foreground hover:bg-accent hover:text-foreground" - > - - - } - /> - ), - cell: () => null, + header: IssueTableAddColumnHeader, + cell: IssueTableEmptyCell, }, ], - [ - handleIssueSelection, - makeColumn, - properties, - selection.selectedIds, - t, - visibleColumnConfigs, - visibleIssueIds, - ], + [visibleColumnConfigs], ); const columnSizing = useMemo( @@ -1096,6 +1372,7 @@ export function TableView({ columnSizing, columnPinning: { left: [SELECT_COLUMN_ID, "title"], right: [] }, }, + meta: viewMeta as TableMeta, 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) => { diff --git a/packages/views/locales/en/issues.json b/packages/views/locales/en/issues.json index 9291388fe..74b6ca338 100644 --- a/packages/views/locales/en/issues.json +++ b/packages/views/locales/en/issues.json @@ -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", diff --git a/packages/views/locales/ja/issues.json b/packages/views/locales/ja/issues.json index 94664f19a..a7710a06d 100644 --- a/packages/views/locales/ja/issues.json +++ b/packages/views/locales/ja/issues.json @@ -113,6 +113,7 @@ "sort_ascending": "昇順", "sort_descending": "降順", "toggle_sub_issues": "サブイシューを展開または折りたたむ", + "rename_title": "イシュー名を変更", "empty_value": "空", "quick_create_placeholder": "イシューを追加…", "quick_create_submit": "追加", diff --git a/packages/views/locales/ko/issues.json b/packages/views/locales/ko/issues.json index dcea2a379..337a8db85 100644 --- a/packages/views/locales/ko/issues.json +++ b/packages/views/locales/ko/issues.json @@ -113,6 +113,7 @@ "sort_ascending": "오름차순", "sort_descending": "내림차순", "toggle_sub_issues": "하위 이슈 펼치기 또는 접기", + "rename_title": "이슈 이름 변경", "empty_value": "비어 있음", "quick_create_placeholder": "이슈 추가…", "quick_create_submit": "추가", diff --git a/packages/views/locales/zh-Hans/issues.json b/packages/views/locales/zh-Hans/issues.json index d3ab446b2..c13161952 100644 --- a/packages/views/locales/zh-Hans/issues.json +++ b/packages/views/locales/zh-Hans/issues.json @@ -113,6 +113,7 @@ "sort_ascending": "升序", "sort_descending": "降序", "toggle_sub_issues": "展开或折叠子 issue", + "rename_title": "重命名 issue", "empty_value": "空", "quick_create_placeholder": "添加 issue…", "quick_create_submit": "添加",