mirror of
https://github.com/multica-ai/multica.git
synced 2026-07-26 04:25:46 +02:00
* 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>
155 lines
4.8 KiB
TypeScript
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");
|
|
}
|