Review follow-ups on the tier-shift fix. The table moves to a module-level RELATIVE_UNITS: it is constant, so rebuilding it per call was pure garbage on a path that runs once per rendered timestamp across a virtualized feed. Its ascending order is load-bearing and was unstated — sorting it largest-first would make every timestamp under a year read "just now", silently, the same shape as the bug this branch fixes. It is now documented and asserted, and the suite derives its tier coverage and translation checks from the table itself rather than from its own case list, so a new tier cannot slip in uncovered. The audit log read the empty string for an unreadable date as a value and rendered a blank cell; it now uses the `|| "—"` idiom the channel table already uses, so missing and unreadable dates look alike.
96 lines
3.2 KiB
TypeScript
96 lines
3.2 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) : ""}
|
|
>
|
|
{/* `|| "—"` not a null check: relativeTime is empty for a missing OR unreadable date. */}
|
|
{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>
|
|
);
|
|
},
|
|
},
|
|
];
|
|
}
|