mirror of
https://github.com/multica-ai/multica.git
synced 2026-08-12 19:06:06 +02:00
tokens.css defined colours, radii and font families but not a single --text-* step, so font sizes had no baseline to align to and grew wherever they were needed: 51 distinct sizes across web + desktop, 370 written as arbitrary values, six at half a pixel (10.5 / 11.5 / 12.5 / 13.5 / 14.5 / 15.5px). text-xs and text-sm carried nearly all UI text while the range between them — 11, 13, 15px — could only be reached with arbitrary values. Hierarchy does not come from having more sizes; past a handful, each extra size makes the hierarchy blurrier. Add ten role-named steps, each with its own line-height so leading cannot fragment the way size did, and move every product-UI call site onto them. Steps are named for what the text is for, not for a t-shirt size, because that is what keeps the scale from drifting again. Six steps deliberately keep the exact size/line-height pairs of the Tailwind defaults they replace, so the ~1,900-call-site rename moves nothing on screen. The visible changes are confined to former arbitrary values snapping to a step: 8/9/10px -> micro (11px) on badges and overlines; 17 -> 18; 22 -> 24; 30 (text-3xl) -> 36 on headings and stat numbers; 12.8px -> label (13px) on small buttons and toggles. Half-pixel sizes are gone. This supersedes #6108, which was reverted by #6116 because the sidebar group labels rendered at the inherited 16px. The cause was not the scale but cn(): `text-<x>` is ambiguous in Tailwind, and tailwind-merge resolves it against a table listing only the default sizes, so it filed every role step under text-colour and dropped whichever of `text-caption` / `text-sidebar-foreground/70` came first. Registering the steps as a font-size class group restores the real conflict groups — size beats size, colour beats colour, the two coexist — and a test pins the list against the scale, since the failure is silent in source. Hand-written CSS is covered too. The transcript kept a 12.5px body long after every Tailwind call site was on the scale, so the "no half-pixel sizes" claim was true of the classes and false of the product; the editor's prose, code and mermaid ramps had the same blind spot, and seven of their eight values already equalled a step exactly. All now reference var(--text-*). The guard test reads raw `font-size:` declarations as well as class names, exempting only the 16px iOS input-zoom workaround in base.css and the landing pages' marketing ramp. apps/mobile (own NativeWind config) and apps/docs (fumadocs' own type system) keep Tailwind's default scale and are untouched. Landing display type (rem/clamp, 2.2-6.4rem) stays on its separate ramp, as do four decorative emoji / serif-hero sizes. Verified on a running local stack: pinned sidebar rows and group labels measure 12px/16px, nav items 14px/20px — identical to pre-migration. An audit of every rendered font size across the product surfaces finds nothing off the scale; the only exceptions are avatar initials and emoji, which actor-avatar.tsx sizes proportionally to the avatar diameter by design. Co-authored-by: Lambda <lambda@multica.ai> Co-authored-by: multica-agent <github@multica.ai>
2470 lines
81 KiB
TypeScript
2470 lines
81 KiB
TypeScript
"use client";
|
|
|
|
import {
|
|
useCallback,
|
|
useEffect,
|
|
useLayoutEffect,
|
|
useMemo,
|
|
useRef,
|
|
useState,
|
|
} from "react";
|
|
import {
|
|
DndContext,
|
|
KeyboardSensor,
|
|
PointerSensor,
|
|
closestCenter,
|
|
useDndContext,
|
|
useSensor,
|
|
useSensors,
|
|
type DragEndEvent,
|
|
} from "@dnd-kit/core";
|
|
import { restrictToHorizontalAxis } from "@dnd-kit/modifiers";
|
|
import {
|
|
SortableContext,
|
|
horizontalListSortingStrategy,
|
|
sortableKeyboardCoordinates,
|
|
useSortable,
|
|
} from "@dnd-kit/sortable";
|
|
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,
|
|
ArrowUp,
|
|
ChevronDown,
|
|
ChevronRight,
|
|
Download,
|
|
EyeOff,
|
|
GripVertical,
|
|
Loader2,
|
|
Pencil,
|
|
Plus,
|
|
Search,
|
|
X,
|
|
} from "lucide-react";
|
|
import { toast } from "sonner";
|
|
import { DataTable } from "@multica/ui/components/ui/data-table";
|
|
import { Button } from "@multica/ui/components/ui/button";
|
|
import { Input } from "@multica/ui/components/ui/input";
|
|
import {
|
|
DropdownMenu,
|
|
DropdownMenuContent,
|
|
DropdownMenuGroup,
|
|
DropdownMenuItem,
|
|
DropdownMenuLabel,
|
|
DropdownMenuSeparator,
|
|
DropdownMenuTrigger,
|
|
} from "@multica/ui/components/ui/dropdown-menu";
|
|
import {
|
|
TableCell,
|
|
TableRow,
|
|
} from "@multica/ui/components/ui/table";
|
|
import { Skeleton } from "@multica/ui/components/ui/skeleton";
|
|
import { cn } from "@multica/ui/lib/utils";
|
|
import { ApiError } from "@multica/core/api";
|
|
import { useWorkspaceId } from "@multica/core/hooks";
|
|
import { ALL_STATUSES } from "@multica/core/issues/config";
|
|
import {
|
|
issueKeys,
|
|
issueTableGroupsOptions,
|
|
issueTableRowPageOptions,
|
|
} from "@multica/core/issues/queries";
|
|
import {
|
|
TABLE_SYSTEM_COLUMNS,
|
|
propertyIdFromViewKey,
|
|
type SortField,
|
|
type TableColumnKey,
|
|
type TableSystemColumnKey,
|
|
} from "@multica/core/issues/stores/view-store";
|
|
import { useViewStore } from "@multica/core/issues/stores/view-store-context";
|
|
import { propertyListOptions } from "@multica/core/properties";
|
|
import { useWorkspacePaths } from "@multica/core/paths";
|
|
import { buildActorNameResolver, useActorName } from "@multica/core/workspace/hooks";
|
|
import {
|
|
agentListOptions,
|
|
memberListOptions,
|
|
squadListOptions,
|
|
} from "@multica/core/workspace/queries";
|
|
import type {
|
|
Issue,
|
|
IssueProperty,
|
|
IssuePropertyValue,
|
|
IssueStatus,
|
|
IssueTableGroupDescriptor,
|
|
IssueTableGroupSpec,
|
|
IssueTableQuerySpec,
|
|
IssueTableRowsResponse,
|
|
Project,
|
|
UpdateIssueRequest,
|
|
} from "@multica/core/types";
|
|
import {
|
|
useInfiniteQuery,
|
|
useQueries,
|
|
useQuery,
|
|
useQueryClient,
|
|
type UseQueryResult,
|
|
} from "@tanstack/react-query";
|
|
import { ActorAvatar } from "../../common/actor-avatar";
|
|
import { LabelChip } from "../../labels/label-chip";
|
|
import { useNavigation } from "../../navigation";
|
|
import { ProjectPicker } from "../../projects/components/project-picker";
|
|
import { useT } from "../../i18n";
|
|
import { useIssueSurfaceActionsOptional } from "../surface/actions-context";
|
|
import { useIssueSurfaceSelection } from "../surface/selection-context";
|
|
import type { IssueCreateDefaults } from "../surface/types";
|
|
import { ProgressRing } from "./progress-ring";
|
|
import {
|
|
AssigneePicker,
|
|
DueDatePicker,
|
|
LabelPicker,
|
|
PriorityPicker,
|
|
StartDatePicker,
|
|
StatusPicker,
|
|
} from "./pickers";
|
|
import { CustomPropertyValueEditor } from "./pickers/custom-property-picker";
|
|
import {
|
|
buildIssueTableCsv,
|
|
getIssueTableSelectionRange,
|
|
IssueTableExportIntegrityError,
|
|
refreshFrozenTableRows,
|
|
type IssueTableDisplayRow,
|
|
} from "./table-view-model";
|
|
import type { ChildProgress } from "./list-row";
|
|
import { ListLoadMoreFooter } from "./list-load-more-footer";
|
|
import { IssueAgentActivityIndicator } from "./issue-agent-activity-indicator";
|
|
|
|
// Enough placeholder rows to cover a typical viewport; the virtualizer only
|
|
// mounts what fits, so overshooting costs nothing.
|
|
const SKELETON_ROW_COUNT = 12;
|
|
|
|
const SELECT_COLUMN_ID = "__select";
|
|
const ADD_COLUMN_ID = "__add";
|
|
|
|
type TableViewProps = {
|
|
serverQuery: IssueTableQuerySpec;
|
|
childProgressMap: Map<string, ChildProgress>;
|
|
search: string;
|
|
onSearchChange: (query: string) => void;
|
|
onLoadedIssuesChange: (issues: Issue[]) => void;
|
|
onCreateIssue: (defaults: IssueCreateDefaults) => void;
|
|
exportIssues: () => Promise<Issue[]>;
|
|
resolveExportLookups: (needs: {
|
|
projects: boolean;
|
|
childProgress: boolean;
|
|
}) => Promise<{
|
|
projectMap: Map<string, Project>;
|
|
childProgressMap: Map<string, ChildProgress>;
|
|
}>;
|
|
};
|
|
|
|
type ServerBranch = {
|
|
key: string;
|
|
groupKey: string | null;
|
|
parentId: string | null;
|
|
ancestorIds: string[];
|
|
cursors: Array<string | null>;
|
|
};
|
|
|
|
type ServerBranchState = {
|
|
identity: string;
|
|
structureIdentity: string;
|
|
branches: Map<string, ServerBranch>;
|
|
};
|
|
|
|
type ServerBranchPageTarget = {
|
|
branch: ServerBranch;
|
|
cursor: string | null;
|
|
};
|
|
|
|
type ServerBranchData = {
|
|
rows: IssueTableRowsResponse["rows"];
|
|
total: number;
|
|
nextCursor: string | null;
|
|
headUpdatedAt: number;
|
|
headFetching: boolean;
|
|
loading: boolean;
|
|
error: boolean;
|
|
placeholder: boolean;
|
|
};
|
|
|
|
type LoadedIssueState = {
|
|
membershipIdentity: string;
|
|
issues: Map<string, Issue>;
|
|
};
|
|
|
|
function serverBranchKey(groupKey: string | null, parentId: string | null) {
|
|
return `${groupKey ?? "ungrouped"}::${parentId ?? "root"}`;
|
|
}
|
|
|
|
function sameStringArray(left: string[], right: string[]) {
|
|
return (
|
|
left.length === right.length &&
|
|
left.every((value, index) => value === right[index])
|
|
);
|
|
}
|
|
|
|
/** Rebase the branch graph synchronously when a query changes.
|
|
*
|
|
* Filters/search/sort keep the same group/hierarchy structure, so preserving
|
|
* branch identities while resetting every cursor to the head lets React Query
|
|
* show the previous rows during the new request. A real structure change
|
|
* (group kind or hierarchy) discards incompatible group/parent identities. */
|
|
function rebaseServerBranchState(
|
|
previous: ServerBranchState,
|
|
identity: string,
|
|
structureIdentity: string,
|
|
usesServerGrouping: boolean,
|
|
): ServerBranchState {
|
|
if (previous.identity === identity) return previous;
|
|
|
|
const branches =
|
|
previous.structureIdentity === structureIdentity
|
|
? new Map(
|
|
[...previous.branches].map(([key, branch]) => [
|
|
key,
|
|
{ ...branch, cursors: [null] },
|
|
]),
|
|
)
|
|
: new Map<string, ServerBranch>();
|
|
|
|
if (!usesServerGrouping) {
|
|
const key = serverBranchKey(null, null);
|
|
if (!branches.has(key)) {
|
|
branches.set(key, {
|
|
key,
|
|
groupKey: null,
|
|
parentId: null,
|
|
ancestorIds: [],
|
|
cursors: [null],
|
|
});
|
|
}
|
|
}
|
|
|
|
return { identity, structureIdentity, branches };
|
|
}
|
|
|
|
function tableGroupSpec(grouping: string): IssueTableGroupSpec {
|
|
if (grouping === "status") return { kind: "status" };
|
|
if (grouping === "assignee") return { kind: "assignee" };
|
|
const propertyId = propertyIdFromViewKey(grouping);
|
|
if (propertyId) return { kind: "property", property_id: propertyId };
|
|
return { kind: "none" };
|
|
}
|
|
|
|
type ColumnLabelKey =
|
|
| "title"
|
|
| "identifier"
|
|
| "status"
|
|
| "priority"
|
|
| "assignee"
|
|
| "labels"
|
|
| "project"
|
|
| "start_date"
|
|
| "due_date"
|
|
| "created_at"
|
|
| "updated_at"
|
|
| "child_progress"
|
|
| "creator";
|
|
|
|
const SORTABLE_COLUMNS: Partial<Record<TableSystemColumnKey, SortField>> = {
|
|
title: "title",
|
|
status: "status",
|
|
priority: "priority",
|
|
start_date: "start_date",
|
|
due_date: "due_date",
|
|
created_at: "created_at",
|
|
updated_at: "updated_at",
|
|
};
|
|
|
|
function stopRowNavigation(event: React.SyntheticEvent) {
|
|
event.stopPropagation();
|
|
}
|
|
|
|
function SelectAllCheckbox({
|
|
issueIds,
|
|
label,
|
|
}: {
|
|
issueIds: string[];
|
|
label: string;
|
|
}) {
|
|
const selection = useIssueSurfaceSelection();
|
|
const ref = useRef<HTMLInputElement>(null);
|
|
const selectedCount = issueIds.filter((id) => selection.selectedIds.has(id)).length;
|
|
const checked = issueIds.length > 0 && selectedCount === issueIds.length;
|
|
|
|
useEffect(() => {
|
|
if (ref.current) {
|
|
ref.current.indeterminate = selectedCount > 0 && !checked;
|
|
}
|
|
}, [checked, selectedCount]);
|
|
|
|
return (
|
|
<input
|
|
ref={ref}
|
|
type="checkbox"
|
|
aria-label={label}
|
|
checked={checked}
|
|
onChange={() =>
|
|
checked ? selection.deselect(issueIds) : selection.select(issueIds)
|
|
}
|
|
className="size-3.5 cursor-pointer accent-primary"
|
|
/>
|
|
);
|
|
}
|
|
|
|
function IssueCheckbox({
|
|
checked,
|
|
label,
|
|
onToggle,
|
|
}: {
|
|
checked: boolean;
|
|
label: string;
|
|
onToggle: (shiftKey: boolean) => void;
|
|
}) {
|
|
return (
|
|
<input
|
|
type="checkbox"
|
|
aria-label={label}
|
|
checked={checked}
|
|
onClick={(event) => {
|
|
event.stopPropagation();
|
|
onToggle(event.shiftKey);
|
|
}}
|
|
onChange={() => undefined}
|
|
className="size-3.5 cursor-pointer accent-primary"
|
|
/>
|
|
);
|
|
}
|
|
|
|
function SortableColumnHeader({
|
|
columnKey,
|
|
label,
|
|
sortField,
|
|
sortBy,
|
|
sortDirection,
|
|
onSort,
|
|
onHide,
|
|
ascendingLabel,
|
|
descendingLabel,
|
|
hideLabel,
|
|
reorderLabel,
|
|
}: {
|
|
columnKey: TableColumnKey;
|
|
label: string;
|
|
sortField?: SortField;
|
|
sortBy: SortField;
|
|
sortDirection: "asc" | "desc";
|
|
onSort: (field: SortField, direction: "asc" | "desc") => void;
|
|
onHide?: () => void;
|
|
ascendingLabel: string;
|
|
descendingLabel: string;
|
|
hideLabel: string;
|
|
reorderLabel: string;
|
|
}) {
|
|
const sortable = columnKey !== "title";
|
|
const { attributes, listeners, setNodeRef, transform, transition, isDragging } =
|
|
useSortable({ id: columnKey, disabled: !sortable });
|
|
const active = sortField === sortBy;
|
|
// Any column in flight, not only this one: the neighbours shift to open a
|
|
// gap, and each is clipped by its own cell just the same.
|
|
const isReordering = useDndContext().active != null;
|
|
const nodeRef = useRef<HTMLDivElement | null>(null);
|
|
|
|
// The cell clips its own content, which is what made a dragged column look
|
|
// like it vanished rather than travelled. The clip only earns its keep at
|
|
// rest, capping a label wider than its column, so it is lifted for the length
|
|
// of a reorder and the column in hand is raised over its neighbours.
|
|
//
|
|
// Only overflow and stacking are touched. Transforming the <th> itself would
|
|
// carry the header's full height along, but `transform` on a table cell is a
|
|
// corner of the spec browsers take liberties with — Chromium lifts the cell
|
|
// out of the table's box model and its geometry stops matching the row. The
|
|
// wrapper below is padded out to the cell's size instead.
|
|
useLayoutEffect(() => {
|
|
const cell = nodeRef.current?.closest("th");
|
|
if (!cell || !isReordering) return;
|
|
cell.style.overflow = "visible";
|
|
if (isDragging) cell.style.zIndex = "20";
|
|
return () => {
|
|
cell.style.removeProperty("overflow");
|
|
cell.style.removeProperty("z-index");
|
|
};
|
|
}, [isDragging, isReordering]);
|
|
|
|
return (
|
|
<div
|
|
ref={(node) => {
|
|
nodeRef.current = node;
|
|
setNodeRef(node);
|
|
}}
|
|
// Horizontal travel only. dnd-kit's layout animation also hands back
|
|
// scaleX/scaleY — old rect over new rect — to tween an item into the
|
|
// shape of the slot it landed in. Between two tabs of equal width that
|
|
// ratio is 1 and never shows; between two columns it is not, so a 174px
|
|
// column swapping with a 96px one gets stretched to 1.8x on the way.
|
|
// Reordering columns changes no column's width, so there is nothing for
|
|
// a shape tween to say here. The move and the settle stay animated
|
|
// through `transition`.
|
|
style={{
|
|
transform: transform ? `translate3d(${transform.x}px, 0, 0)` : undefined,
|
|
transition,
|
|
}}
|
|
// The wrapper spans the cell's own box — the negative margins undo the
|
|
// <th>'s padding and put it back inside — so it renders exactly as at
|
|
// rest while being what travels: a header-sized block rather than the
|
|
// line of text in it. Height is derived rather than fixed at h-8: the
|
|
// strip is taller than the cell's nominal height once row borders are in.
|
|
className={cn(
|
|
"group/header -mx-4 -my-2 flex h-[calc(100%+1rem)] min-w-0 items-center px-4",
|
|
isDragging && "opacity-60",
|
|
)}
|
|
>
|
|
{sortable && (
|
|
<button
|
|
type="button"
|
|
aria-label={reorderLabel}
|
|
className={cn(
|
|
"-ml-2 mr-0.5 rounded p-0.5 text-muted-foreground/50 opacity-0 hover:bg-accent hover:text-muted-foreground group-hover/header:opacity-100 focus-visible:opacity-100",
|
|
isDragging ? "cursor-grabbing opacity-100" : "cursor-grab",
|
|
)}
|
|
{...attributes}
|
|
{...listeners}
|
|
>
|
|
<GripVertical className="size-3" />
|
|
</button>
|
|
)}
|
|
<DropdownMenu>
|
|
<DropdownMenuTrigger className="flex min-w-0 items-center gap-1 rounded px-1.5 py-1 hover:bg-accent">
|
|
<span className="truncate">{label}</span>
|
|
{active &&
|
|
(sortDirection === "asc" ? (
|
|
<ArrowUp className="size-3 shrink-0" />
|
|
) : (
|
|
<ArrowDown className="size-3 shrink-0" />
|
|
))}
|
|
</DropdownMenuTrigger>
|
|
<DropdownMenuContent align="start" className="w-40">
|
|
{sortField && (
|
|
<>
|
|
<DropdownMenuItem onClick={() => onSort(sortField, "asc")}>
|
|
<ArrowUp />
|
|
{ascendingLabel}
|
|
</DropdownMenuItem>
|
|
<DropdownMenuItem onClick={() => onSort(sortField, "desc")}>
|
|
<ArrowDown />
|
|
{descendingLabel}
|
|
</DropdownMenuItem>
|
|
</>
|
|
)}
|
|
{sortField && onHide && <DropdownMenuSeparator />}
|
|
{onHide && (
|
|
<DropdownMenuItem onClick={onHide}>
|
|
<EyeOff />
|
|
{hideLabel}
|
|
</DropdownMenuItem>
|
|
)}
|
|
</DropdownMenuContent>
|
|
</DropdownMenu>
|
|
</div>
|
|
);
|
|
}
|
|
|
|
export function TableColumnPicker({
|
|
properties,
|
|
trigger,
|
|
}: {
|
|
properties: IssueProperty[];
|
|
trigger: React.ReactElement;
|
|
}) {
|
|
const { t } = useT("issues");
|
|
const [search, setSearch] = useState("");
|
|
const tableColumns = useViewStore((state) => state.tableColumns);
|
|
const toggleTableColumn = useViewStore((state) => state.toggleTableColumn);
|
|
const selected = useMemo(
|
|
() => new Set(tableColumns.map((column) => column.key)),
|
|
[tableColumns],
|
|
);
|
|
const query = search.trim().toLocaleLowerCase();
|
|
const systemColumns = TABLE_SYSTEM_COLUMNS.filter((key) =>
|
|
t(($) => $.table.columns[key as ColumnLabelKey])
|
|
.toLocaleLowerCase()
|
|
.includes(query),
|
|
);
|
|
const visibleProperties = properties.filter((property) =>
|
|
property.name.toLocaleLowerCase().includes(query),
|
|
);
|
|
|
|
return (
|
|
<DropdownMenu>
|
|
<DropdownMenuTrigger render={trigger} />
|
|
<DropdownMenuContent align="end" className="w-64 p-0">
|
|
<div className="border-b p-2">
|
|
<Input
|
|
value={search}
|
|
onChange={(event) => setSearch(event.target.value)}
|
|
onKeyDown={(event) => {
|
|
if (event.key !== "Escape") event.stopPropagation();
|
|
}}
|
|
placeholder={t(($) => $.table.columns.search_placeholder)}
|
|
className="h-7"
|
|
/>
|
|
</div>
|
|
<div className="max-h-80 overflow-y-auto p-1">
|
|
{systemColumns.length > 0 && (
|
|
<DropdownMenuGroup>
|
|
<DropdownMenuLabel>
|
|
{t(($) => $.table.columns.system_section)}
|
|
</DropdownMenuLabel>
|
|
{systemColumns.map((key) => (
|
|
<DropdownMenuItem
|
|
key={key}
|
|
disabled={key === "title"}
|
|
onClick={(event) => {
|
|
event.preventDefault();
|
|
toggleTableColumn(key);
|
|
}}
|
|
>
|
|
<input
|
|
type="checkbox"
|
|
checked={selected.has(key)}
|
|
readOnly
|
|
className="size-3.5 accent-primary"
|
|
/>
|
|
{t(($) => $.table.columns[key as ColumnLabelKey])}
|
|
</DropdownMenuItem>
|
|
))}
|
|
</DropdownMenuGroup>
|
|
)}
|
|
{visibleProperties.length > 0 && (
|
|
<>
|
|
{systemColumns.length > 0 && <DropdownMenuSeparator />}
|
|
<DropdownMenuGroup>
|
|
<DropdownMenuLabel>
|
|
{t(($) => $.table.columns.property_section)}
|
|
</DropdownMenuLabel>
|
|
{visibleProperties.map((property) => {
|
|
const key = `property:${property.id}` as const;
|
|
return (
|
|
<DropdownMenuItem
|
|
key={property.id}
|
|
onClick={(event) => {
|
|
event.preventDefault();
|
|
toggleTableColumn(key);
|
|
}}
|
|
>
|
|
<input
|
|
type="checkbox"
|
|
checked={selected.has(key)}
|
|
readOnly
|
|
className="size-3.5 accent-primary"
|
|
/>
|
|
<span className="truncate">{property.name}</span>
|
|
</DropdownMenuItem>
|
|
);
|
|
})}
|
|
</DropdownMenuGroup>
|
|
</>
|
|
)}
|
|
{systemColumns.length === 0 && visibleProperties.length === 0 && (
|
|
<p className="px-2 py-6 text-center text-caption text-muted-foreground">
|
|
{t(($) => $.table.columns.no_results)}
|
|
</p>
|
|
)}
|
|
</div>
|
|
</DropdownMenuContent>
|
|
</DropdownMenu>
|
|
);
|
|
}
|
|
|
|
export function TableIssueSearch({
|
|
value,
|
|
onChange,
|
|
placeholder,
|
|
clearLabel,
|
|
}: {
|
|
value: string;
|
|
onChange: (value: string) => void;
|
|
placeholder: string;
|
|
clearLabel: string;
|
|
}) {
|
|
return (
|
|
<div className="relative w-56 shrink-0">
|
|
<Search
|
|
aria-hidden
|
|
className="pointer-events-none absolute left-2 top-1/2 size-3.5 -translate-y-1/2 text-muted-foreground"
|
|
/>
|
|
<Input
|
|
type="text"
|
|
role="searchbox"
|
|
inputMode="search"
|
|
value={value}
|
|
onChange={(event) => onChange(event.target.value)}
|
|
aria-label={placeholder}
|
|
placeholder={placeholder}
|
|
className="h-7 pl-7 pr-7 text-caption"
|
|
/>
|
|
{value && (
|
|
<Button
|
|
type="button"
|
|
variant="ghost"
|
|
size="icon-xs"
|
|
aria-label={clearLabel}
|
|
onClick={() => onChange("")}
|
|
className="absolute right-0.5 top-0.5 text-muted-foreground"
|
|
>
|
|
<X className="size-3" />
|
|
</Button>
|
|
)}
|
|
</div>
|
|
);
|
|
}
|
|
|
|
export function InlineTitle({
|
|
row,
|
|
editing,
|
|
onEditingChange,
|
|
onUpdate,
|
|
onOpen,
|
|
onCreateSubIssue,
|
|
onToggleParent,
|
|
toggleLabel,
|
|
renameLabel,
|
|
createSubIssueLabel,
|
|
}: {
|
|
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;
|
|
onCreateSubIssue: () => void;
|
|
onToggleParent: () => void;
|
|
toggleLabel: string;
|
|
renameLabel: string;
|
|
createSubIssueLabel: string;
|
|
}) {
|
|
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
|
|
// overwrite text the user is actively composing.
|
|
if (!editingRef.current) setDraft(row.issue.title);
|
|
}, [row.issue.title]);
|
|
|
|
const commit = () => {
|
|
const title = draft.trim();
|
|
onEditingChange(false);
|
|
if (title && title !== row.issue.title) onUpdate({ title });
|
|
else setDraft(row.issue.title);
|
|
};
|
|
|
|
return (
|
|
<div
|
|
className="group/title relative flex min-w-0 items-center gap-1.5"
|
|
style={{ paddingLeft: row.depth * 18 }}
|
|
// 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={(event) => {
|
|
event.stopPropagation();
|
|
onToggleParent();
|
|
}}
|
|
>
|
|
{row.collapsed ? (
|
|
<ChevronRight className="size-3.5" />
|
|
) : (
|
|
<ChevronDown className="size-3.5" />
|
|
)}
|
|
</button>
|
|
) : (
|
|
<span className="w-4 shrink-0" />
|
|
)}
|
|
<span className="w-16 shrink-0 text-caption text-muted-foreground">
|
|
{row.issue.identifier}
|
|
</span>
|
|
<IssueAgentActivityIndicator issueId={row.issue.id} />
|
|
{editing ? (
|
|
<Input
|
|
autoFocus
|
|
value={draft}
|
|
onChange={(event) => setDraft(event.target.value)}
|
|
onBlur={commit}
|
|
onKeyDown={(event) => {
|
|
if (event.key === "Enter") commit();
|
|
if (event.key === "Escape") {
|
|
setDraft(row.issue.title);
|
|
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={(event) => {
|
|
event.stopPropagation();
|
|
onOpen();
|
|
}}
|
|
>
|
|
{row.issue.title}
|
|
</button>
|
|
{/* Lifted out of the flex flow, the way SidebarMenuAction is. Laid
|
|
* out inline these two reserved ~40px of the title column for
|
|
* buttons that are invisible until hovered — and title is the
|
|
* column with the least room to spare. The gradient fades the text
|
|
* running underneath rather than letting the icons sit on top of
|
|
* it; the sidebar has no need for one because its labels are short,
|
|
* but a title runs to the cell's edge. focus-within keeps them
|
|
* reachable by keyboard, where hover never fires. */}
|
|
{/* The fade has to be whatever the cell is painted with at the
|
|
* moment the actions show, and a hovered row is not the resting
|
|
* background — pinned cells switch to the muted mix on hover, so
|
|
* the gradient follows. */}
|
|
{/* Keyed to the title cell, not the row: these act on the title,
|
|
* and offering them from anywhere along a row puts them under the
|
|
* pointer while it is somewhere else entirely. The fade still
|
|
* follows the row's hover colour, since that is what the cell is
|
|
* painted with when they appear. */}
|
|
<span className="pointer-events-none absolute inset-y-0 right-0 flex items-center gap-0.5 bg-gradient-to-l from-background from-70% to-transparent pr-1 pl-8 opacity-0 transition-opacity group-hover:from-[color-mix(in_oklab,var(--muted)_50%,var(--background))] group-hover/title:pointer-events-auto group-hover/title:opacity-100 focus-within:pointer-events-auto focus-within:opacity-100">
|
|
<button
|
|
type="button"
|
|
aria-label={createSubIssueLabel}
|
|
className="rounded p-1 text-muted-foreground/60 hover:bg-accent hover:text-foreground"
|
|
onClick={(event) => {
|
|
event.stopPropagation();
|
|
onCreateSubIssue();
|
|
}}
|
|
>
|
|
<Plus className="size-3" />
|
|
</button>
|
|
<button
|
|
type="button"
|
|
aria-label={renameLabel}
|
|
className="rounded p-1 text-muted-foreground/60 hover:bg-accent hover:text-foreground"
|
|
onClick={(event) => {
|
|
event.stopPropagation();
|
|
setDraft(row.issue.title);
|
|
onEditingChange(true);
|
|
}}
|
|
>
|
|
<Pencil className="size-3" />
|
|
</button>
|
|
</span>
|
|
</>
|
|
)}
|
|
</div>
|
|
);
|
|
}
|
|
|
|
function LazyLabelCell({
|
|
issue,
|
|
open,
|
|
onOpenChange,
|
|
}: {
|
|
issue: Issue;
|
|
open: boolean;
|
|
onOpenChange: (open: boolean) => void;
|
|
}) {
|
|
const { t } = useT("issues");
|
|
const labels = issue.labels ?? [];
|
|
if (open) {
|
|
return (
|
|
<div onClick={stopRowNavigation}>
|
|
<LabelPicker
|
|
issueId={issue.id}
|
|
open
|
|
onOpenChange={(next) => {
|
|
if (!next) onOpenChange(false);
|
|
}}
|
|
triggerRender={<button type="button" className="flex max-w-full gap-1" />}
|
|
/>
|
|
</div>
|
|
);
|
|
}
|
|
return (
|
|
<button
|
|
type="button"
|
|
className="flex max-w-full items-center gap-1 overflow-hidden rounded px-1 py-0.5 hover:bg-accent"
|
|
onClick={(event) => {
|
|
event.stopPropagation();
|
|
onOpenChange(true);
|
|
}}
|
|
>
|
|
{labels.length > 0 ? (
|
|
<>
|
|
{labels.slice(0, 2).map((label) => (
|
|
<LabelChip key={label.id} label={label} />
|
|
))}
|
|
{labels.length > 2 && (
|
|
<span className="text-caption text-muted-foreground">+{labels.length - 2}</span>
|
|
)}
|
|
</>
|
|
) : (
|
|
<span className="text-muted-foreground">{t(($) => $.table.empty_value)}</span>
|
|
)}
|
|
</button>
|
|
);
|
|
}
|
|
|
|
// Extends the <tr> props so the virtualizer's measuring ref and data-index,
|
|
// which DataTable clones onto whatever renderRow returns, reach the element
|
|
// instead of being absorbed here. A group header is shorter than a data row,
|
|
// so it is exactly the kind of row the measurement exists for.
|
|
type IssueTableGroupRowProps = React.ComponentProps<"tr"> & {
|
|
group: Extract<IssueTableDisplayRow, { kind: "group" }>;
|
|
colSpan: number;
|
|
onToggle: () => void;
|
|
};
|
|
|
|
export function IssueTableGroupRow({
|
|
group,
|
|
colSpan,
|
|
onToggle,
|
|
...rowProps
|
|
}: IssueTableGroupRowProps) {
|
|
return (
|
|
<TableRow
|
|
{...rowProps}
|
|
className="bg-muted/40 hover:bg-muted/60"
|
|
onClick={onToggle}
|
|
>
|
|
<TableCell colSpan={colSpan} className="h-9 px-4 py-1.5">
|
|
<button
|
|
type="button"
|
|
className="sticky left-4 flex w-fit items-center gap-2 text-caption font-medium"
|
|
>
|
|
{group.collapsed ? (
|
|
<ChevronRight className="size-3.5" />
|
|
) : (
|
|
<ChevronDown className="size-3.5" />
|
|
)}
|
|
{group.label}
|
|
<span className="font-normal tabular-nums text-muted-foreground">
|
|
{group.count}
|
|
</span>
|
|
</button>
|
|
</TableCell>
|
|
</TableRow>
|
|
);
|
|
}
|
|
|
|
function propertyDisplayValue(
|
|
property: IssueProperty,
|
|
value: IssuePropertyValue | undefined,
|
|
) {
|
|
if (value === undefined) return "";
|
|
const options = property.config.options ?? [];
|
|
if (property.type === "select") {
|
|
return options.find((option) => option.id === value)?.name ?? "";
|
|
}
|
|
if (property.type === "multi_select") {
|
|
const ids = Array.isArray(value) ? value : [];
|
|
return options
|
|
.filter((option) => ids.includes(option.id))
|
|
.map((option) => option.name)
|
|
.join(", ");
|
|
}
|
|
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: (issue: Issue) => void;
|
|
createSubIssue: (issue: Issue) => 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,
|
|
);
|
|
// Placeholder rows go through the ordinary cell renderer so they inherit the
|
|
// real column widths, pinning and borders — the grid is already correct
|
|
// before any data arrives, so the rows swap in without shifting anything.
|
|
if (row.original.kind === "skeleton") {
|
|
return <Skeleton className="h-3.5 w-full" />;
|
|
}
|
|
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)}
|
|
onCreateSubIssue={() => meta.createSubIssue(issue)}
|
|
onToggleParent={() => meta.toggleTableParentCollapsed(issue.id)}
|
|
toggleLabel={t(($) => $.table.toggle_sub_issues)}
|
|
renameLabel={t(($) => $.table.rename_title)}
|
|
createSubIssueLabel={t(($) => $.actions.create_sub_issue)}
|
|
/>
|
|
);
|
|
case "identifier":
|
|
return (
|
|
<span className="text-caption 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-caption 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-caption 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({
|
|
serverQuery,
|
|
childProgressMap,
|
|
search,
|
|
onSearchChange,
|
|
onLoadedIssuesChange,
|
|
onCreateIssue,
|
|
exportIssues,
|
|
resolveExportLookups,
|
|
}: TableViewProps) {
|
|
const { t } = useT("issues");
|
|
const wsId = useWorkspaceId();
|
|
const queryClient = useQueryClient();
|
|
const navigation = useNavigation();
|
|
const paths = useWorkspacePaths();
|
|
const actions = useIssueSurfaceActionsOptional();
|
|
const selection = useIssueSurfaceSelection();
|
|
const { getActorName } = useActorName();
|
|
const {
|
|
data: properties = [],
|
|
isSuccess: propertyCatalogSettled,
|
|
} = useQuery(propertyListOptions(wsId));
|
|
const propertyById = useMemo(
|
|
() => new Map(properties.map((property) => [property.id, property])),
|
|
[properties],
|
|
);
|
|
const activePropertyIds = useMemo(
|
|
() => new Set(properties.map((property) => property.id)),
|
|
[properties],
|
|
);
|
|
const groupablePropertyIds = useMemo(
|
|
() =>
|
|
new Set(
|
|
properties
|
|
.filter((property) => ["select", "checkbox"].includes(property.type))
|
|
.map((property) => property.id),
|
|
),
|
|
[properties],
|
|
);
|
|
const tableColumns = useViewStore((state) => state.tableColumns);
|
|
const toggleTableColumn = useViewStore((state) => state.toggleTableColumn);
|
|
const reorderTableColumn = useViewStore((state) => state.reorderTableColumn);
|
|
const setTableColumnWidth = useViewStore((state) => state.setTableColumnWidth);
|
|
const tableGrouping = useViewStore((state) => state.tableGrouping);
|
|
const setTableGrouping = useViewStore((state) => state.setTableGrouping);
|
|
const tableCollapsedGroups = useViewStore((state) => state.tableCollapsedGroups);
|
|
const toggleTableGroupCollapsed = useViewStore(
|
|
(state) => state.toggleTableGroupCollapsed,
|
|
);
|
|
const tableCollapsedParents = useViewStore((state) => state.tableCollapsedParents);
|
|
const toggleTableParentCollapsed = useViewStore(
|
|
(state) => state.toggleTableParentCollapsed,
|
|
);
|
|
const tableHierarchy = useViewStore((state) => state.tableHierarchy);
|
|
const sortBy = useViewStore((state) => state.sortBy);
|
|
const setSortBy = useViewStore((state) => state.setSortBy);
|
|
const sortDirection = useViewStore((state) => state.sortDirection);
|
|
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 =
|
|
groupingPropertyId &&
|
|
propertyCatalogSettled &&
|
|
!groupablePropertyIds.has(groupingPropertyId)
|
|
? "none"
|
|
: tableGrouping;
|
|
useEffect(() => {
|
|
if (
|
|
!groupingPropertyId ||
|
|
!propertyCatalogSettled ||
|
|
groupablePropertyIds.has(groupingPropertyId)
|
|
) {
|
|
return;
|
|
}
|
|
setTableGrouping("none");
|
|
toast.info(t(($) => $.table.group_property_unavailable));
|
|
}, [
|
|
groupablePropertyIds,
|
|
groupingPropertyId,
|
|
propertyCatalogSettled,
|
|
setTableGrouping,
|
|
t,
|
|
]);
|
|
|
|
const serverGroupSpec = useMemo(
|
|
() => tableGroupSpec(effectiveTableGrouping),
|
|
[effectiveTableGrouping],
|
|
);
|
|
const usesServerGrouping = serverGroupSpec.kind !== "none";
|
|
const serverGroupsRequestGroup =
|
|
serverGroupSpec.kind === "none"
|
|
? ({ kind: "status" } as const)
|
|
: serverGroupSpec;
|
|
const serverGroupsQuery = useInfiniteQuery({
|
|
...issueTableGroupsOptions(
|
|
wsId,
|
|
serverQuery,
|
|
serverGroupsRequestGroup,
|
|
),
|
|
enabled: usesServerGrouping,
|
|
});
|
|
const {
|
|
data: serverGroupsData,
|
|
isPending: serverGroupsPending,
|
|
isError: serverGroupsError,
|
|
hasNextPage: hasNextServerGroupPage,
|
|
isFetchingNextPage: fetchingNextServerGroupPage,
|
|
refetch: refetchServerGroups,
|
|
fetchNextPage: fetchNextServerGroupPage,
|
|
} = serverGroupsQuery;
|
|
useEffect(() => {
|
|
const body =
|
|
serverGroupsQuery.error instanceof ApiError &&
|
|
serverGroupsQuery.error.body &&
|
|
typeof serverGroupsQuery.error.body === "object"
|
|
? (serverGroupsQuery.error.body as { error?: unknown })
|
|
: null;
|
|
if (
|
|
serverGroupsQuery.error instanceof ApiError &&
|
|
serverGroupsQuery.error.status === 422 &&
|
|
body?.error === "unsupported_group"
|
|
) {
|
|
setTableGrouping("none");
|
|
toast.info(t(($) => $.table.group_property_unavailable));
|
|
}
|
|
}, [serverGroupsQuery.error, setTableGrouping, t]);
|
|
const serverGroups = useMemo(
|
|
() => serverGroupsData?.pages.flatMap((page) => page.groups) ?? [],
|
|
[serverGroupsData?.pages],
|
|
);
|
|
const serverIdentity = useMemo(
|
|
() => JSON.stringify([serverQuery, serverGroupSpec, tableHierarchy]),
|
|
[serverGroupSpec, serverQuery, tableHierarchy],
|
|
);
|
|
const serverStructureIdentity = useMemo(
|
|
() => JSON.stringify([serverGroupSpec, tableHierarchy]),
|
|
[serverGroupSpec, tableHierarchy],
|
|
);
|
|
const collapsedGroupSet = useMemo(
|
|
() => new Set(tableCollapsedGroups),
|
|
[tableCollapsedGroups],
|
|
);
|
|
const collapsedParentSet = useMemo(
|
|
() => new Set(tableCollapsedParents),
|
|
[tableCollapsedParents],
|
|
);
|
|
const [serverBranchState, setServerBranchState] =
|
|
useState<ServerBranchState>({
|
|
identity: "",
|
|
structureIdentity: "",
|
|
branches: new Map(),
|
|
});
|
|
|
|
const rebasedServerBranchState = useMemo(
|
|
() =>
|
|
rebaseServerBranchState(
|
|
serverBranchState,
|
|
serverIdentity,
|
|
serverStructureIdentity,
|
|
usesServerGrouping,
|
|
),
|
|
[
|
|
serverBranchState,
|
|
serverIdentity,
|
|
serverStructureIdentity,
|
|
usesServerGrouping,
|
|
],
|
|
);
|
|
|
|
// Commit the synchronous rebase after render. Consumers use the derived
|
|
// state above immediately, so a filter/search/sort transition never has an
|
|
// empty frame while this effect catches state up.
|
|
useEffect(() => {
|
|
if (rebasedServerBranchState !== serverBranchState) {
|
|
setServerBranchState(rebasedServerBranchState);
|
|
}
|
|
}, [rebasedServerBranchState, serverBranchState]);
|
|
|
|
const activeServerBranches = rebasedServerBranchState.branches;
|
|
const serverBranchPlaceholderRef = useRef(
|
|
new Map<string, IssueTableRowsResponse>(),
|
|
);
|
|
const serverBranchPageTargets = useMemo<ServerBranchPageTarget[]>(
|
|
() =>
|
|
[...activeServerBranches.values()].flatMap((branch) =>
|
|
branch.cursors.map((cursor) => ({ branch, cursor })),
|
|
),
|
|
[activeServerBranches],
|
|
);
|
|
// useQueries compares and installs the supplied query list in an effect.
|
|
// Keeping this array stable prevents a settled branch from being installed
|
|
// again on every render (which can otherwise create a render loop once the
|
|
// virtualized table starts measuring rows).
|
|
const serverBranchQueries = useMemo(
|
|
() =>
|
|
serverBranchPageTargets.map(({ branch, cursor }) => {
|
|
const placeholder =
|
|
cursor === null
|
|
? serverBranchPlaceholderRef.current.get(
|
|
`${serverStructureIdentity}:${branch.key}`,
|
|
)
|
|
: undefined;
|
|
return {
|
|
...issueTableRowPageOptions(wsId, {
|
|
query: serverQuery,
|
|
group: serverGroupSpec,
|
|
group_key: branch.groupKey,
|
|
hierarchy: { enabled: tableHierarchy },
|
|
parent_id: branch.parentId,
|
|
page: { limit: 50, cursor },
|
|
}),
|
|
// QueriesObserver replaces observers by query hash, so
|
|
// keepPreviousData alone cannot bridge a changed table query inside
|
|
// useQueries. Retain the last settled head per structural branch to
|
|
// keep the previous table painted while the new query is pending.
|
|
...(placeholder ? { placeholderData: () => placeholder } : {}),
|
|
enabled:
|
|
(branch.groupKey === null ||
|
|
!collapsedGroupSet.has(branch.groupKey)) &&
|
|
!branch.ancestorIds.some((id) => collapsedParentSet.has(id)),
|
|
};
|
|
}),
|
|
[
|
|
collapsedGroupSet,
|
|
collapsedParentSet,
|
|
serverBranchPageTargets,
|
|
serverGroupSpec,
|
|
serverQuery,
|
|
serverStructureIdentity,
|
|
tableHierarchy,
|
|
wsId,
|
|
],
|
|
);
|
|
const combineServerBranchQueries = useCallback(
|
|
(results: Array<UseQueryResult<IssueTableRowsResponse, Error>>) => {
|
|
const byBranch: Record<string, ServerBranchData> = {};
|
|
for (let index = 0; index < serverBranchPageTargets.length; index += 1) {
|
|
const target = serverBranchPageTargets[index];
|
|
const result = results[index];
|
|
if (!target || !result) continue;
|
|
const current = byBranch[target.branch.key] ?? {
|
|
rows: [],
|
|
total: 0,
|
|
nextCursor: null,
|
|
headUpdatedAt: 0,
|
|
headFetching: false,
|
|
loading: false,
|
|
error: false,
|
|
placeholder: false,
|
|
};
|
|
const page = result.data;
|
|
if (page) {
|
|
current.rows.push(...page.rows);
|
|
if (target.cursor === null) current.total = page.total;
|
|
current.nextCursor = page.next_cursor;
|
|
}
|
|
if (target.cursor === null) {
|
|
current.headUpdatedAt = result.dataUpdatedAt;
|
|
current.headFetching = result.isFetching;
|
|
}
|
|
current.loading ||= result.isPending || result.isFetching;
|
|
current.error ||= result.isError;
|
|
current.placeholder ||= result.isPlaceholderData;
|
|
byBranch[target.branch.key] = current;
|
|
}
|
|
return byBranch;
|
|
},
|
|
[serverBranchPageTargets],
|
|
);
|
|
// `combine` is structurally shared by React Query. It must return only plain
|
|
// objects/arrays: Map instances and freshly-created retry closures cannot be
|
|
// shared, and make useQueries publish a different snapshot on every render.
|
|
const serverBranchData = useQueries({
|
|
queries: serverBranchQueries,
|
|
combine: combineServerBranchQueries,
|
|
});
|
|
|
|
useEffect(() => {
|
|
const next = new Map<string, IssueTableRowsResponse>();
|
|
for (const branch of activeServerBranches.values()) {
|
|
const key = `${serverStructureIdentity}:${branch.key}`;
|
|
const data = serverBranchData[branch.key];
|
|
if (!data || data.placeholder || data.loading || data.error) {
|
|
const previous = serverBranchPlaceholderRef.current.get(key);
|
|
if (previous) next.set(key, previous);
|
|
continue;
|
|
}
|
|
next.set(key, {
|
|
query_fingerprint: "__table_placeholder__",
|
|
group_key: branch.groupKey,
|
|
parent_id: branch.parentId,
|
|
total: data.total,
|
|
rows: data.rows,
|
|
branch_total: data.rows.length,
|
|
next_cursor: null,
|
|
});
|
|
}
|
|
// Bound placeholders to the active structural graph. Sort/filter/search
|
|
// transitions reuse these entries; old group/property configurations do
|
|
// not accumulate for the lifetime of the Table component.
|
|
serverBranchPlaceholderRef.current = next;
|
|
}, [
|
|
activeServerBranches,
|
|
serverBranchData,
|
|
serverStructureIdentity,
|
|
]);
|
|
|
|
// Re-derive ancestry from the current row graph after realtime re-parenting.
|
|
// Branch keys are parent ids, so an existing branch can move under a new
|
|
// collapsed ancestor without being re-activated by the viewport sentinel.
|
|
useEffect(() => {
|
|
const desiredAncestors = new Map<string, string[]>();
|
|
const visited = new Set<string>();
|
|
const visit = (
|
|
groupKey: string | null,
|
|
parentId: string | null,
|
|
ancestors: string[],
|
|
) => {
|
|
const key = serverBranchKey(groupKey, parentId);
|
|
if (visited.has(key)) return;
|
|
visited.add(key);
|
|
desiredAncestors.set(key, ancestors);
|
|
for (const row of serverBranchData[key]?.rows ?? []) {
|
|
if (row.direct_child_count > 0) {
|
|
visit(groupKey, row.issue.id, [...ancestors, row.issue.id]);
|
|
}
|
|
}
|
|
};
|
|
|
|
if (usesServerGrouping) {
|
|
for (const group of serverGroups) visit(group.key, null, []);
|
|
} else {
|
|
visit(null, null, []);
|
|
}
|
|
|
|
setServerBranchState((previous) => {
|
|
if (previous.identity !== serverIdentity) return previous;
|
|
let branches: Map<string, ServerBranch> | null = null;
|
|
for (const [key, ancestors] of desiredAncestors) {
|
|
const branch = previous.branches.get(key);
|
|
if (!branch || sameStringArray(branch.ancestorIds, ancestors)) continue;
|
|
branches ??= new Map(previous.branches);
|
|
branches.set(key, { ...branch, ancestorIds: ancestors });
|
|
}
|
|
return branches ? { ...previous, branches } : previous;
|
|
});
|
|
}, [serverBranchData, serverGroups, serverIdentity, usesServerGrouping]);
|
|
|
|
const activateServerBranch = useCallback(
|
|
(
|
|
groupKey: string | null,
|
|
parentId: string | null,
|
|
ancestorIds: string[],
|
|
) => {
|
|
setServerBranchState((previous) => {
|
|
if (previous.identity !== serverIdentity) return previous;
|
|
const key = serverBranchKey(groupKey, parentId);
|
|
const existing = previous.branches.get(key);
|
|
if (existing && sameStringArray(existing.ancestorIds, ancestorIds)) {
|
|
return previous;
|
|
}
|
|
const branches = new Map(previous.branches);
|
|
branches.set(
|
|
key,
|
|
existing
|
|
? { ...existing, ancestorIds }
|
|
: {
|
|
key,
|
|
groupKey,
|
|
parentId,
|
|
ancestorIds,
|
|
cursors: [null],
|
|
},
|
|
);
|
|
return { ...previous, branches };
|
|
});
|
|
},
|
|
[serverIdentity],
|
|
);
|
|
|
|
// A broad Table invalidation refetches every active page. As soon as the
|
|
// head starts refreshing, every later cursor is obsolete; discard them
|
|
// before their concurrent responses can create a duplicate or missing row.
|
|
// The revision check is a second line of defence for clients that restore a
|
|
// refreshed head directly into the cache without exposing a fetching frame.
|
|
const branchHeadRevisionRef = useRef<Record<string, number>>({});
|
|
useEffect(() => {
|
|
const previousRevisions = branchHeadRevisionRef.current;
|
|
const nextRevisions: Record<string, number> = {};
|
|
const branchesToTrim = new Set<string>();
|
|
for (const [key, branch] of activeServerBranches) {
|
|
const revision = serverBranchData[key]?.headUpdatedAt ?? 0;
|
|
if (revision === 0) continue;
|
|
nextRevisions[key] = revision;
|
|
const seen = previousRevisions[key];
|
|
if (
|
|
branch.cursors.length > 1 &&
|
|
(serverBranchData[key]?.headFetching ||
|
|
(seen !== undefined && seen !== revision))
|
|
) {
|
|
branchesToTrim.add(key);
|
|
}
|
|
}
|
|
// Keep only the current query's active branches. This bounds the revision
|
|
// bookkeeping across long sessions with many filter/search identities.
|
|
branchHeadRevisionRef.current = nextRevisions;
|
|
if (branchesToTrim.size === 0) return;
|
|
|
|
setServerBranchState((previous) => {
|
|
if (previous.identity !== serverIdentity) return previous;
|
|
let branches: Map<string, ServerBranch> | null = null;
|
|
for (const [key, branch] of previous.branches) {
|
|
if (branchesToTrim.has(key)) {
|
|
branches ??= new Map(previous.branches);
|
|
branches.set(key, { ...branch, cursors: [null] });
|
|
}
|
|
}
|
|
return branches ? { ...previous, branches } : previous;
|
|
});
|
|
}, [activeServerBranches, serverBranchData, serverIdentity]);
|
|
|
|
const loadNextServerBranchPage = useCallback(
|
|
(branchKey: string, cursor: string) => {
|
|
setServerBranchState((previous) => {
|
|
if (previous.identity !== serverIdentity) return previous;
|
|
const branch = previous.branches.get(branchKey);
|
|
if (!branch || branch.cursors.includes(cursor)) return previous;
|
|
const branches = new Map(previous.branches);
|
|
branches.set(branchKey, {
|
|
...branch,
|
|
cursors: [...branch.cursors, cursor],
|
|
});
|
|
return { ...previous, branches };
|
|
});
|
|
},
|
|
[serverIdentity],
|
|
);
|
|
|
|
const retryServerBranch = useCallback(
|
|
(branchKey: string) => {
|
|
const branch = activeServerBranches.get(branchKey);
|
|
if (!branch) return;
|
|
void queryClient.refetchQueries({
|
|
queryKey: issueKeys.tableRows(
|
|
wsId,
|
|
serverQuery,
|
|
serverGroupSpec,
|
|
branch.groupKey,
|
|
tableHierarchy,
|
|
branch.parentId,
|
|
),
|
|
exact: false,
|
|
type: "active",
|
|
});
|
|
},
|
|
[
|
|
activeServerBranches,
|
|
queryClient,
|
|
serverGroupSpec,
|
|
serverQuery,
|
|
tableHierarchy,
|
|
wsId,
|
|
],
|
|
);
|
|
|
|
const serverGroupLabel = useCallback(
|
|
(descriptor: IssueTableGroupDescriptor) => {
|
|
const value = descriptor.value;
|
|
if (value.kind === "status") {
|
|
if (ALL_STATUSES.includes(value.status as IssueStatus)) {
|
|
return t(($) => $.status[value.status as IssueStatus]);
|
|
}
|
|
// Installed clients can receive a status introduced by a newer
|
|
// backend. Keep the group usable instead of collapsing the response
|
|
// to the schema fallback or rendering an empty label.
|
|
return value.status;
|
|
}
|
|
if (value.kind === "assignee") {
|
|
return value.actor
|
|
? getActorName(value.actor.type, value.actor.id)
|
|
: t(($) => $.table.unassigned);
|
|
}
|
|
if (value.kind === "project") {
|
|
return value.project_id
|
|
? value.project_id
|
|
: t(($) => $.swimlane.no_project);
|
|
}
|
|
if (value.kind === "parent") {
|
|
if (value.value_state === "unset") {
|
|
return t(($) => $.swimlane.no_parent);
|
|
}
|
|
return value.parent?.title ?? t(($) => $.table.value_unavailable);
|
|
}
|
|
if (value.value_state === "unset") return t(($) => $.table.no_value);
|
|
if (value.value_state === "unavailable") {
|
|
return t(($) => $.table.value_unavailable);
|
|
}
|
|
const property = propertyById.get(value.property_id);
|
|
if (typeof value.value === "boolean") {
|
|
return value.value
|
|
? t(($) => $.pickers.custom_property.true_label)
|
|
: t(($) => $.pickers.custom_property.false_label);
|
|
}
|
|
return (
|
|
property?.config.options?.find((option) => option.id === value.value)
|
|
?.name ?? String(value.value ?? "")
|
|
);
|
|
},
|
|
[getActorName, propertyById, t],
|
|
);
|
|
|
|
const serverDisplayRows = useMemo<IssueTableDisplayRow[]>(() => {
|
|
const result: IssueTableDisplayRow[] = [];
|
|
const seenIssueIds = new Set<string>();
|
|
const appendBranch = (
|
|
groupKey: string | null,
|
|
parentId: string | null,
|
|
depth: number,
|
|
ancestorIds: string[],
|
|
) => {
|
|
const key = serverBranchKey(groupKey, parentId);
|
|
const data = serverBranchData[key];
|
|
if (!data) {
|
|
const registered = activeServerBranches.has(key);
|
|
result.push({
|
|
kind: "load_more",
|
|
key: `${registered ? "loading" : "activate"}:${key}`,
|
|
state: registered ? "loading" : "has_more",
|
|
total: 0,
|
|
onLoad: registered
|
|
? undefined
|
|
: () => activateServerBranch(groupKey, parentId, ancestorIds),
|
|
});
|
|
return;
|
|
}
|
|
if (data.rows.length === 0 && data.loading) {
|
|
result.push({
|
|
kind: "load_more",
|
|
key: `loading:${key}`,
|
|
state: "loading",
|
|
total: 0,
|
|
});
|
|
}
|
|
for (const row of data.rows) {
|
|
// A realtime move can briefly leave the same entity in old and new
|
|
// branch caches. Render the first authoritative position only; duplicate
|
|
// ids otherwise create duplicate React keys and duplicate selection.
|
|
if (seenIssueIds.has(row.issue.id)) continue;
|
|
seenIssueIds.add(row.issue.id);
|
|
const collapsed = collapsedParentSet.has(row.issue.id);
|
|
result.push({
|
|
kind: "issue",
|
|
key: row.issue.id,
|
|
issue: row.issue,
|
|
depth,
|
|
hasChildren: tableHierarchy && row.direct_child_count > 0,
|
|
collapsed,
|
|
});
|
|
if (tableHierarchy && row.direct_child_count > 0 && !collapsed) {
|
|
appendBranch(groupKey, row.issue.id, depth + 1, [
|
|
...ancestorIds,
|
|
row.issue.id,
|
|
]);
|
|
}
|
|
}
|
|
if (data.error) {
|
|
result.push({
|
|
kind: "load_more",
|
|
key: `retry:${key}`,
|
|
state: "error",
|
|
total: data.total,
|
|
onLoad: () => retryServerBranch(key),
|
|
});
|
|
} else if (data.nextCursor) {
|
|
const nextCursor = data.nextCursor;
|
|
result.push({
|
|
kind: "load_more",
|
|
key: `more:${key}:${nextCursor}`,
|
|
state: data.loading ? "loading" : "has_more",
|
|
total: data.total,
|
|
onLoad: () => loadNextServerBranchPage(key, nextCursor),
|
|
});
|
|
} else if (data.rows.length > 0) {
|
|
// Reaching the end is only worth marking on a branch that paginated;
|
|
// the footer applies that rule, so the row is pushed unconditionally
|
|
// and carries the total for it to judge by.
|
|
result.push({
|
|
kind: "load_more",
|
|
key: `end:${key}`,
|
|
state: "end",
|
|
total: data.total,
|
|
});
|
|
}
|
|
};
|
|
|
|
if (usesServerGrouping) {
|
|
for (const descriptor of serverGroups) {
|
|
const collapsed = collapsedGroupSet.has(descriptor.key);
|
|
result.push({
|
|
kind: "group",
|
|
key: descriptor.key,
|
|
label: serverGroupLabel(descriptor),
|
|
count: descriptor.count,
|
|
collapsed,
|
|
});
|
|
if (!collapsed) appendBranch(descriptor.key, null, 0, []);
|
|
}
|
|
} else {
|
|
appendBranch(null, null, 0, []);
|
|
}
|
|
if (
|
|
usesServerGrouping &&
|
|
serverGroups.length === 0 &&
|
|
serverGroupsPending
|
|
) {
|
|
result.push({
|
|
kind: "load_more",
|
|
key: "loading:groups",
|
|
state: "loading",
|
|
total: 0,
|
|
});
|
|
} else if (usesServerGrouping && serverGroupsError) {
|
|
result.push({
|
|
kind: "load_more",
|
|
key: "retry:groups",
|
|
state: "error",
|
|
total: 0,
|
|
onLoad: () => void refetchServerGroups(),
|
|
});
|
|
} else if (usesServerGrouping && hasNextServerGroupPage) {
|
|
result.push({
|
|
kind: "load_more",
|
|
key: "more:groups",
|
|
state: fetchingNextServerGroupPage ? "loading" : "has_more",
|
|
total: 0,
|
|
onLoad: () => void fetchNextServerGroupPage(),
|
|
});
|
|
}
|
|
|
|
// Nothing has landed yet and something is still in flight: show the grid
|
|
// filled with placeholders instead of one "Loading…" line, which reads as
|
|
// an empty table more than a loading one.
|
|
const isColdLoad =
|
|
!result.some((row) => row.kind === "issue") &&
|
|
result.some((row) => row.kind === "load_more" && row.state === "loading");
|
|
if (isColdLoad) {
|
|
return Array.from({ length: SKELETON_ROW_COUNT }, (_, index) => ({
|
|
kind: "skeleton" as const,
|
|
key: `skeleton:${index}`,
|
|
}));
|
|
}
|
|
|
|
return result;
|
|
}, [
|
|
collapsedGroupSet,
|
|
collapsedParentSet,
|
|
activeServerBranches,
|
|
activateServerBranch,
|
|
loadNextServerBranchPage,
|
|
retryServerBranch,
|
|
serverBranchData,
|
|
serverGroupLabel,
|
|
serverGroups,
|
|
serverGroupsPending,
|
|
serverGroupsError,
|
|
hasNextServerGroupPage,
|
|
fetchingNextServerGroupPage,
|
|
refetchServerGroups,
|
|
fetchNextServerGroupPage,
|
|
tableHierarchy,
|
|
usesServerGrouping,
|
|
]);
|
|
|
|
const tableMembershipIdentity = useMemo(
|
|
() =>
|
|
JSON.stringify([
|
|
serverQuery.scope,
|
|
serverQuery.filters,
|
|
serverQuery.search ?? "",
|
|
]),
|
|
[serverQuery.filters, serverQuery.scope, serverQuery.search],
|
|
);
|
|
const authoritativeLoadedIssues = useMemo(() => {
|
|
const byId = new Map<string, Issue>();
|
|
for (const branch of Object.values(serverBranchData)) {
|
|
// Previous-data placeholders keep the old table painted during a query
|
|
// transition, but they are not members of the new filter/search window.
|
|
if (branch.placeholder) continue;
|
|
for (const row of branch.rows) byId.set(row.issue.id, row.issue);
|
|
}
|
|
return [...byId.values()];
|
|
}, [serverBranchData]);
|
|
const [loadedIssueState, setLoadedIssueState] = useState<LoadedIssueState>({
|
|
membershipIdentity: tableMembershipIdentity,
|
|
issues: new Map(),
|
|
});
|
|
useEffect(() => {
|
|
setLoadedIssueState((previous) => {
|
|
const reset = previous.membershipIdentity !== tableMembershipIdentity;
|
|
const issues = reset
|
|
? new Map<string, Issue>()
|
|
: new Map(previous.issues);
|
|
let changed = reset;
|
|
for (const issue of authoritativeLoadedIssues) {
|
|
if (issues.get(issue.id) !== issue) {
|
|
issues.set(issue.id, issue);
|
|
changed = true;
|
|
}
|
|
}
|
|
return changed
|
|
? { membershipIdentity: tableMembershipIdentity, issues }
|
|
: previous;
|
|
});
|
|
}, [authoritativeLoadedIssues, tableMembershipIdentity]);
|
|
const loadedIssues = useMemo(
|
|
() =>
|
|
loadedIssueState.membershipIdentity === tableMembershipIdentity
|
|
? [...loadedIssueState.issues.values()]
|
|
: [],
|
|
[
|
|
loadedIssueState.issues,
|
|
loadedIssueState.membershipIdentity,
|
|
tableMembershipIdentity,
|
|
],
|
|
);
|
|
|
|
const visibleColumnConfigs = useMemo(
|
|
() =>
|
|
tableColumns.filter((column) => {
|
|
const propertyId = propertyIdFromViewKey(column.key);
|
|
return !propertyId || activePropertyIds.has(propertyId);
|
|
}),
|
|
[activePropertyIds, tableColumns],
|
|
);
|
|
|
|
// While a cell editor popup / rename input is open, hold the row structure
|
|
// still: server branch pagination and realtime refetches can rebuild or
|
|
// reorder the row list, moving the anchor row out of the virtualized render
|
|
// window and closing the popup the user just opened (MUL-5108). The snapshot
|
|
// freezes ORDER only; issue objects inside the rows keep tracking live
|
|
// server-query data 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 = serverDisplayRows;
|
|
const frozenRows = frozenRowsRef.current;
|
|
const issueById = useMemo(
|
|
() => new Map(authoritativeLoadedIssues.map((issue) => [issue.id, issue])),
|
|
[authoritativeLoadedIssues],
|
|
);
|
|
const displayRows = useMemo(
|
|
() =>
|
|
frozenRows && frozenRows !== serverDisplayRows
|
|
? refreshFrozenTableRows(frozenRows, issueById)
|
|
: serverDisplayRows,
|
|
[frozenRows, issueById, serverDisplayRows],
|
|
);
|
|
const visibleIssueIds = useMemo(
|
|
() =>
|
|
displayRows
|
|
.filter((row): row is Extract<IssueTableDisplayRow, { kind: "issue" }> => row.kind === "issue")
|
|
.map((row) => row.issue.id),
|
|
[displayRows],
|
|
);
|
|
useEffect(() => {
|
|
onLoadedIssuesChange(loadedIssues);
|
|
}, [loadedIssues, onLoadedIssuesChange]);
|
|
const selectedIssues = useMemo(
|
|
() => loadedIssues.filter((issue) => selection.selectedIds.has(issue.id)),
|
|
[loadedIssues, selection.selectedIds],
|
|
);
|
|
const handleIssueSelection = useCallback(
|
|
(issueId: string, shiftKey: boolean) => {
|
|
const range = shiftKey
|
|
? getIssueTableSelectionRange(
|
|
visibleIssueIds,
|
|
selectionAnchorRef.current,
|
|
issueId,
|
|
)
|
|
: null;
|
|
|
|
if (range) {
|
|
if (selection.selectedIds.has(issueId)) selection.deselect(range);
|
|
else selection.select(range);
|
|
return;
|
|
}
|
|
|
|
selection.toggle(issueId);
|
|
selectionAnchorRef.current = issueId;
|
|
},
|
|
[selection, visibleIssueIds],
|
|
);
|
|
|
|
useEffect(() => {
|
|
if (selection.selectedIds.size === 0) selectionAnchorRef.current = null;
|
|
}, [selection.selectedIds]);
|
|
|
|
const columnLabel = useCallback(
|
|
(key: TableColumnKey) => {
|
|
const propertyId = propertyIdFromViewKey(key);
|
|
if (propertyId) return propertyById.get(propertyId)?.name ?? t(($) => $.table.no_value);
|
|
return t(($) => $.table.columns[key as ColumnLabelKey]);
|
|
},
|
|
[propertyById, t],
|
|
);
|
|
|
|
const updateIssue = useCallback(
|
|
(issueId: string, updates: Partial<UpdateIssueRequest>) =>
|
|
actions?.updateIssue(issueId, updates),
|
|
[actions],
|
|
);
|
|
|
|
const openIssue = useCallback(
|
|
(issue: Issue) => {
|
|
const path = paths.issueDetail(issue.id);
|
|
if (navigation.openInNewTab) {
|
|
navigation.openInNewTab(path, issue.identifier, { activate: true });
|
|
return;
|
|
}
|
|
|
|
window.open(
|
|
navigation.getShareableUrl(path),
|
|
"_blank",
|
|
"noopener,noreferrer",
|
|
);
|
|
},
|
|
[navigation, paths],
|
|
);
|
|
|
|
const createSubIssue = useCallback(
|
|
(issue: Issue) =>
|
|
onCreateIssue({
|
|
parent_issue_id: issue.id,
|
|
parent_issue_identifier: issue.identifier,
|
|
...(issue.project_id ? { project_id: issue.project_id } : {}),
|
|
}),
|
|
[onCreateIssue],
|
|
);
|
|
|
|
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,
|
|
createSubIssue,
|
|
toggleTableParentCollapsed,
|
|
handleIssueSelection,
|
|
getActorName,
|
|
columnLabel,
|
|
sortBy,
|
|
sortDirection,
|
|
onSort,
|
|
toggleTableColumn,
|
|
};
|
|
|
|
const columns = useMemo<ColumnDef<IssueTableDisplayRow>[]>(
|
|
() => [
|
|
{
|
|
id: SELECT_COLUMN_ID,
|
|
size: 44,
|
|
minSize: 44,
|
|
maxSize: 44,
|
|
enableResizing: false,
|
|
header: IssueTableSelectHeader,
|
|
cell: IssueTableSelectCell,
|
|
},
|
|
...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: IssueTableAddColumnHeader,
|
|
cell: IssueTableEmptyCell,
|
|
},
|
|
],
|
|
[visibleColumnConfigs],
|
|
);
|
|
|
|
const columnSizing = useMemo<ColumnSizingState>(
|
|
() =>
|
|
Object.fromEntries(
|
|
visibleColumnConfigs
|
|
.filter((column) => column.width !== undefined)
|
|
.map((column) => [column.key, column.width!]),
|
|
),
|
|
[visibleColumnConfigs],
|
|
);
|
|
const handleColumnSizingChange = useCallback<OnChangeFn<ColumnSizingState>>(
|
|
(updater) => {
|
|
const next = typeof updater === "function" ? updater(columnSizing) : updater;
|
|
for (const column of visibleColumnConfigs) {
|
|
const width = next[column.key];
|
|
if (width !== column.width) setTableColumnWidth(column.key, width);
|
|
}
|
|
},
|
|
[columnSizing, setTableColumnWidth, visibleColumnConfigs],
|
|
);
|
|
|
|
const table = useReactTable({
|
|
data: displayRows,
|
|
columns,
|
|
getRowId: (row) => row.key,
|
|
getCoreRowModel: getCoreRowModel(),
|
|
state: {
|
|
columnSizing,
|
|
columnPinning: { left: [SELECT_COLUMN_ID, "title"], right: [] },
|
|
},
|
|
meta: viewMeta as TableMeta<IssueTableDisplayRow>,
|
|
onColumnSizingChange: handleColumnSizingChange,
|
|
columnResizeMode: "onChange",
|
|
});
|
|
|
|
const sensors = useSensors(
|
|
useSensor(PointerSensor, { activationConstraint: { distance: 4 } }),
|
|
useSensor(KeyboardSensor, { coordinateGetter: sortableKeyboardCoordinates }),
|
|
);
|
|
const handleDragEnd = useCallback(
|
|
({ active, over }: DragEndEvent) => {
|
|
if (!over || active.id === over.id) return;
|
|
reorderTableColumn(active.id as TableColumnKey, over.id as TableColumnKey);
|
|
},
|
|
[reorderTableColumn],
|
|
);
|
|
|
|
const handleExport = async (mode: "all" | "selected") => {
|
|
setExporting(mode);
|
|
try {
|
|
// Every lookup the file depends on is AWAITED here rather than read
|
|
// from render-time hook state — a cold or errored query must fail the
|
|
// export, not silently degrade it: with an unsettled catalog the
|
|
// user's property columns vanish from the file, and with unsettled
|
|
// directories every actor exports as "Unknown *" (round-2 review
|
|
// P2#4). fetchQuery throws on failure, which lands in the catch below.
|
|
const needsPropertyCatalog = tableColumns.some(
|
|
(column) => propertyIdFromViewKey(column.key) !== null,
|
|
);
|
|
// fetchQuery bypasses the options' `select`, so unwrap the response.
|
|
const exportProperties = needsPropertyCatalog
|
|
? (await queryClient.fetchQuery(propertyListOptions(wsId))).properties
|
|
: [];
|
|
const exportPropertyById = new Map(
|
|
exportProperties.map((property) => [property.id, property]),
|
|
);
|
|
// Configured columns against the RESOLVED catalog — only a property
|
|
// definition that is genuinely gone (archived/deleted) drops out.
|
|
const csvColumns = tableColumns.filter((column) => {
|
|
const propertyId = propertyIdFromViewKey(column.key);
|
|
return !propertyId || exportPropertyById.has(propertyId);
|
|
});
|
|
const needsActors = csvColumns.some(
|
|
(column) => column.key === "assignee" || column.key === "creator",
|
|
);
|
|
const [rows, exportLookups, exportActorName] = await Promise.all([
|
|
mode === "all" ? exportIssues() : Promise.resolve(selectedIssues),
|
|
resolveExportLookups({
|
|
projects: csvColumns.some((column) => column.key === "project"),
|
|
childProgress: csvColumns.some(
|
|
(column) => column.key === "child_progress",
|
|
),
|
|
}),
|
|
needsActors
|
|
? Promise.all([
|
|
queryClient.fetchQuery(memberListOptions(wsId)),
|
|
queryClient.fetchQuery(agentListOptions(wsId)),
|
|
queryClient.fetchQuery(squadListOptions(wsId)),
|
|
]).then(([members, agents, squads]) =>
|
|
buildActorNameResolver({ members, agents, squads }),
|
|
)
|
|
: Promise.resolve(getActorName),
|
|
]);
|
|
const headers = csvColumns.map((column) => {
|
|
const propertyId = propertyIdFromViewKey(column.key);
|
|
if (propertyId) return exportPropertyById.get(propertyId)?.name ?? "";
|
|
return columnLabel(column.key);
|
|
});
|
|
const csvRows = rows.map((issue) =>
|
|
csvColumns.map((column) => {
|
|
const propertyId = propertyIdFromViewKey(column.key);
|
|
if (propertyId) {
|
|
const property = exportPropertyById.get(propertyId);
|
|
return property
|
|
? propertyDisplayValue(property, issue.properties[propertyId])
|
|
: "";
|
|
}
|
|
switch (column.key) {
|
|
case "title":
|
|
return issue.title;
|
|
case "identifier":
|
|
return issue.identifier;
|
|
case "status":
|
|
return t(($) => $.status[issue.status]);
|
|
case "priority":
|
|
return t(($) => $.priority[issue.priority]);
|
|
case "assignee":
|
|
return issue.assignee_type && issue.assignee_id
|
|
? exportActorName(issue.assignee_type, issue.assignee_id)
|
|
: "";
|
|
case "labels":
|
|
return issue.labels?.map((label) => label.name).join(", ") ?? "";
|
|
case "project":
|
|
return issue.project_id
|
|
? exportLookups.projectMap.get(issue.project_id)?.title ?? ""
|
|
: "";
|
|
case "start_date":
|
|
case "due_date":
|
|
return issue[column.key] ?? "";
|
|
case "created_at":
|
|
case "updated_at":
|
|
return issue[column.key];
|
|
case "child_progress": {
|
|
const progress = exportLookups.childProgressMap.get(issue.id);
|
|
return progress ? `${progress.done}/${progress.total}` : "";
|
|
}
|
|
case "creator":
|
|
return exportActorName(issue.creator_type, issue.creator_id);
|
|
}
|
|
return "";
|
|
}),
|
|
);
|
|
const csv = buildIssueTableCsv(headers, csvRows);
|
|
const blob = new Blob(["\uFEFF", csv], { type: "text/csv;charset=utf-8" });
|
|
const url = URL.createObjectURL(blob);
|
|
const anchor = document.createElement("a");
|
|
anchor.href = url;
|
|
const filenamePrefix = mode === "all" ? "issues" : "issues-selected";
|
|
anchor.download = `${filenamePrefix}-${new Date().toISOString().slice(0, 10)}.csv`;
|
|
anchor.click();
|
|
URL.revokeObjectURL(url);
|
|
toast.success(t(($) => $.table.export_success, { count: rows.length }));
|
|
} catch (error) {
|
|
toast.error(
|
|
error instanceof Error &&
|
|
!(error instanceof IssueTableExportIntegrityError) &&
|
|
error.message
|
|
? error.message
|
|
: t(($) => $.table.export_failed),
|
|
);
|
|
} finally {
|
|
setExporting(null);
|
|
}
|
|
};
|
|
|
|
return (
|
|
<div className="flex min-h-0 flex-1 flex-col">
|
|
<div className="flex shrink-0 items-center gap-2 border-b px-3 py-1.5">
|
|
<TableIssueSearch
|
|
value={search}
|
|
onChange={onSearchChange}
|
|
placeholder={t(($) => $.table.search_placeholder)}
|
|
clearLabel={t(($) => $.table.search_clear)}
|
|
/>
|
|
<span className="mr-auto" />
|
|
<DropdownMenu>
|
|
<DropdownMenuTrigger
|
|
render={
|
|
<Button
|
|
variant="ghost"
|
|
size="sm"
|
|
className="h-7"
|
|
disabled={exporting !== null}
|
|
>
|
|
{exporting ? (
|
|
<Loader2 className="size-3.5 animate-spin" />
|
|
) : (
|
|
<Download className="size-3.5" />
|
|
)}
|
|
{t(($) => $.table.export)}
|
|
<ChevronDown className="size-3" />
|
|
</Button>
|
|
}
|
|
/>
|
|
<DropdownMenuContent align="end" className="w-52">
|
|
<DropdownMenuItem onClick={() => void handleExport("all")}>
|
|
<Download className="size-3.5" />
|
|
{t(($) => $.table.export_all)}
|
|
</DropdownMenuItem>
|
|
<DropdownMenuItem
|
|
disabled={selectedIssues.length === 0}
|
|
onClick={() => void handleExport("selected")}
|
|
>
|
|
<Download className="size-3.5" />
|
|
{t(($) => $.table.export_selected, {
|
|
count: selectedIssues.length,
|
|
})}
|
|
</DropdownMenuItem>
|
|
</DropdownMenuContent>
|
|
</DropdownMenu>
|
|
</div>
|
|
<DndContext
|
|
sensors={sensors}
|
|
collisionDetection={closestCenter}
|
|
// Columns only ever swap sideways, so the header should not follow the
|
|
// pointer up out of its own strip — same constraint the desktop tab bar
|
|
// puts on tab reordering.
|
|
modifiers={[restrictToHorizontalAxis]}
|
|
// Modifiers constrain the drag's movement but not its auto-scrolling,
|
|
// which reads raw pointer coordinates: drifting a few pixels vertically
|
|
// while dragging a header sent the rows scrolling underneath it. Zero
|
|
// on y removes an axis the gesture cannot act on. On x it stays, since
|
|
// a table wider than its viewport needs it to reach a distant slot, but
|
|
// the default 0.2 arms it a fifth of the way in from either edge, which
|
|
// is most of a wide header.
|
|
autoScroll={{ threshold: { x: 0.05, y: 0 } }}
|
|
onDragEnd={handleDragEnd}
|
|
>
|
|
<SortableContext
|
|
items={visibleColumnConfigs.map((column) => column.key)}
|
|
strategy={horizontalListSortingStrategy}
|
|
>
|
|
<DataTable
|
|
table={table}
|
|
virtualizeRows
|
|
emptyMessage={t(($) => $.table.empty)}
|
|
onRowClick={(row) => {
|
|
if (row.original.kind === "issue") {
|
|
openIssue(row.original.issue);
|
|
}
|
|
}}
|
|
renderRow={(row) => {
|
|
if (row.original.kind === "group") {
|
|
return (
|
|
<IssueTableGroupRow
|
|
group={row.original}
|
|
colSpan={table.getVisibleLeafColumns().length}
|
|
onToggle={() => toggleTableGroupCollapsed(row.original.key)}
|
|
/>
|
|
);
|
|
}
|
|
if (row.original.kind === "load_more") {
|
|
const loadMoreRow = row.original;
|
|
return (
|
|
<TableRow className="hover:bg-transparent">
|
|
<TableCell
|
|
colSpan={table.getVisibleLeafColumns().length}
|
|
className="p-0"
|
|
>
|
|
{/* The same footer Board / List / Swimlane end their
|
|
* columns with. Hand-rolling it here had left the table
|
|
* as the one surface where a failed page read as muted
|
|
* body text rather than an error, and where reaching the
|
|
* end of a paginated branch said nothing at all. The row
|
|
* only supplies the cell it lives in. */}
|
|
<div className="sticky left-0 w-full">
|
|
<ListLoadMoreFooter
|
|
hasMore={
|
|
loadMoreRow.state === "loading" ||
|
|
loadMoreRow.state === "has_more"
|
|
}
|
|
isLoading={loadMoreRow.state === "loading"}
|
|
total={loadMoreRow.total}
|
|
onLoadMore={() => loadMoreRow.onLoad?.()}
|
|
isError={loadMoreRow.state === "error"}
|
|
onRetry={loadMoreRow.onLoad}
|
|
/>
|
|
</div>
|
|
</TableCell>
|
|
</TableRow>
|
|
);
|
|
}
|
|
return null;
|
|
}}
|
|
className="min-h-0 flex-1"
|
|
/>
|
|
</SortableContext>
|
|
</DndContext>
|
|
</div>
|
|
);
|
|
}
|