Files
siftlode/frontend/src/components/auditColumns.tsx
T
peter 7e88b0af0c fix(audit): wrap long audit details instead of truncating
A `truncate` (nowrap) cell in the auto-layout DataTable sizes the whole table to
its widest row, so one long summary/change stretched the table past the card and
the row rules bled into the page. break-words keeps every row inside the card.
2026-07-18 02:43:17 +02:00

95 lines
3.1 KiB
TypeScript

import type { TFunction } from "i18next";
import { relativeTime, formatDate } from "../lib/format";
import i18n from "../i18n";
import type { AuditRow } from "../lib/api";
import type { Column } from "./DataTable";
function actionLabel(t: TFunction, action: string): string {
// Canonical key → localized label; falls back to the raw key for any unmapped action.
return t(`auditActions.${action}`, action);
}
function fmt(v: unknown): string {
return v == null || v === "" ? "—" : String(v);
}
/** A compact before→after (or after-only) rendering of the JSON snapshots, e.g. "role: user → admin". */
function changeText(row: AuditRow): string | null {
const { before, after } = row;
if (before && after) {
return Object.keys(after)
.map((k) => `${k}: ${fmt(before[k])} → ${fmt(after[k])}`)
.join(", ");
}
if (after) {
return Object.entries(after)
.map(([k, v]) => `${k}: ${fmt(v)}`)
.join(", ");
}
return null;
}
/** Columns for the admin audit-log DataTable. `actionOptions` are the distinct actions present in
* the data (built by the page) so the Action column's select filter only offers real values. */
export function auditColumns(
t: TFunction,
actionOptions: { value: string; label: string }[],
): Column<AuditRow>[] {
return [
{
key: "when",
header: t("audit.cols.when"),
nowrap: true,
sortable: true,
sortValue: (r) => r.created_at ?? "",
cardPrimary: true,
render: (r) => (
<span
className="text-muted tabular-nums"
title={r.created_at ? formatDate(r.created_at, i18n.language) : ""}
>
{r.created_at ? relativeTime(r.created_at) : "—"}
</span>
),
},
{
key: "actor",
header: t("audit.cols.actor"),
sortable: true,
sortValue: (r) => r.actor ?? "",
filter: { kind: "text", get: (r) => r.actor ?? t("audit.system") },
render: (r) =>
r.actor ? (
<span className="truncate">{r.actor}</span>
) : (
<span className="text-muted italic">{t("audit.system")}</span>
),
},
{
key: "action",
header: t("audit.cols.action"),
nowrap: true,
sortable: true,
sortValue: (r) => actionLabel(t, r.action),
filter: { kind: "select", options: actionOptions, test: (r, v) => r.action === v },
render: (r) => <span className="font-medium">{actionLabel(t, r.action)}</span>,
},
{
key: "details",
header: t("audit.cols.details"),
render: (r) => {
const change = changeText(r);
return (
// break-words (wrap), NOT truncate: in the auto-layout table a `truncate` (nowrap) cell
// sizes to its longest row, so one long summary/change stretched the whole table past the
// card (the row rules bled into the page). Wrapping keeps every row inside the container.
<div className="min-w-0">
{r.summary && <div className="break-words">{r.summary}</div>}
{change && <div className="text-[11px] text-muted break-words">{change}</div>}
</div>
);
},
},
];
}