Files
multica/packages/views/issues/components/table-view-model.ts
Multica Eve 8065cead85 MUL-5198: Restore server-backed issue table grouping (MUL-5100) (#5817)
* revert(issues): restore server-backed table grouping

Co-authored-by: multica-agent <github@multica.ai>

* test(skills): stabilize import completion coverage

Co-authored-by: multica-agent <github@multica.ai>

---------

Co-authored-by: Eve <eve@multica-ai.local>
Co-authored-by: multica-agent <github@multica.ai>
2026-07-23 12:03:27 +08:00

155 lines
4.8 KiB
TypeScript

import { propertyIdFromViewKey } from "@multica/core/issues/stores/view-store";
import type {
TableCalculation,
TableColumnKey,
} from "@multica/core/issues/stores/view-store";
import type {
Issue,
IssuePropertyValue,
} from "@multica/core/types";
/** Export must fail closed when paged Table responses cannot prove that the
* complete query window was collected. The UI translates this marker instead
* of exposing a protocol detail to users. */
export class IssueTableExportIntegrityError extends Error {
constructor() {
super("Table export response was incomplete");
this.name = "IssueTableExportIntegrityError";
}
}
export type IssueTableDisplayRow =
| {
kind: "group";
key: string;
label: string;
count: number;
collapsed: boolean;
}
| {
kind: "issue";
key: string;
issue: Issue;
depth: number;
hasChildren: boolean;
collapsed: boolean;
}
| {
kind: "load_more";
key: string;
label: string;
loading: boolean;
autoLoad?: boolean;
onLoad?: () => void;
};
export function getIssueTableSelectionRange(
issueIds: string[],
anchorId: string | null,
targetId: string,
): string[] | null {
if (!anchorId) return null;
const anchorIndex = issueIds.indexOf(anchorId);
const targetIndex = issueIds.indexOf(targetId);
if (anchorIndex === -1 || targetIndex === -1) return null;
const start = Math.min(anchorIndex, targetIndex);
const end = Math.max(anchorIndex, targetIndex);
return issueIds.slice(start, end + 1);
}
/**
* Refresh the issue objects inside a frozen row snapshot. While a cell editor
* popup is open the table renders a structural snapshot (row order, grouping,
* nesting stay put so the popup's anchor row cannot be reordered out of the
* virtualized render window mid-interaction), but the VALUES inside those
* rows must stay live — a multi-select toggle, for example, commits while the
* popup is open and its checkmark has to reflect the optimistic cache.
* Issues deleted from the live window keep their stale snapshot object; the
* structure catches up the moment the editor closes.
*/
export function refreshFrozenTableRows(
snapshot: IssueTableDisplayRow[],
issueById: ReadonlyMap<string, Issue>,
): IssueTableDisplayRow[] {
return snapshot.map((row) => {
if (row.kind !== "issue") return row;
const live = issueById.get(row.issue.id);
return live && live !== row.issue ? { ...row, issue: live } : row;
});
}
function columnValue(
issue: Issue,
columnKey: TableColumnKey,
): IssuePropertyValue | string | number | null | undefined {
const propertyId = propertyIdFromViewKey(columnKey);
if (propertyId) return issue.properties[propertyId];
switch (columnKey) {
case "identifier":
return issue.identifier;
case "title":
return issue.title;
case "status":
return issue.status;
case "priority":
return issue.priority;
case "assignee":
return issue.assignee_id;
case "labels":
return issue.labels?.map((label) => label.name).join(", ");
case "project":
return issue.project_id;
case "start_date":
return issue.start_date;
case "due_date":
return issue.due_date;
case "created_at":
return issue.created_at;
case "updated_at":
return issue.updated_at;
case "child_progress":
return undefined;
case "creator":
return issue.creator_id;
}
return undefined;
}
export function calculateIssueTableColumn(
issues: Issue[],
columnKey: TableColumnKey,
calculation: TableCalculation,
) {
if (calculation === "none") return null;
const values = issues
.map((issue) => columnValue(issue, columnKey))
.filter((value) => value !== undefined && value !== null && value !== "");
if (calculation === "count") return values.length;
const numbers = values.filter((value): value is number => typeof value === "number");
if (numbers.length === 0) return null;
const sum = numbers.reduce((total, value) => total + value, 0);
return calculation === "sum" ? sum : sum / numbers.length;
}
function escapeCsvCell(value: unknown) {
const raw = value == null ? "" : String(value);
// Spreadsheet apps execute string cells beginning with these characters as
// formulas. Prefix text (including headers) with an apostrophe so exported
// user-controlled titles/properties remain data. Preserve real numbers as
// numbers — a negative numeric value is not an injected formula string.
const text =
typeof value === "string" && /^[=+\-@\t\r]/.test(raw)
? `'${raw}`
: raw;
return /[",\n\r]/.test(text) ? `"${text.replaceAll('"', '""')}"` : text;
}
export function buildIssueTableCsv(
headers: string[],
rows: unknown[][],
) {
return [headers, ...rows]
.map((row) => row.map(escapeCsvCell).join(","))
.join("\r\n");
}