Skip to content

Commit 65a8d4f

Browse files
fix(console): show the declared action label in the audit log's Action column and drawer (#28962)
<!-- claude-projects-attribution: {"github_login":"zhuangjianguo"} --> _Requested by **Steedos** · [project thread](https://claude.ai/code/project/chan_018ETKaNwMZCNRYD7D8Q9Ubb?thread=cmsg_018ETKaNwMZCNRYD7D8Q9Ubb1icerCPt86vQXmeZ9u36z5)_ Fixes #28895 Clause-②: no Metadata-protocol: none ## 维护者速读 - **改了什么**:Setup › 审计日志的「操作」列徽章和详情抽屉标题里的徽章,改为显示 `sys_audit_log.action` 声明选项的标签(中文界面下即「更新」「创建」)。没有声明的 action 值仍显示原始存储值。 - **为什么改**:同页的「操作」筛选器早已用声明选项的标签(`actionFilterOptionsOf`),表格和抽屉却直接打印存储值 `update` / `create`,中文界面下两者对不上(#28895,staging 实测 46 个单元格)。 - **风险与回滚**:低。只改两处徽章文字,徽章颜色、筛选器和列表查询都不变;没有新的元数据键,没有新门禁。回滚即还原本 PR。 - **先看**:`ui/apps/console/src/pages/system/auditLogActions.ts`(新的 `actionLabelOf`)、`ui/apps/console/src/pages/system/AuditLogPage.tsx`(两处调用)、`AuditLogPage.actionLabels-28895.test.tsx`。 - **两分钟验证**:`pnpm --dir ui exec vitest run apps/console/src/pages/system/AuditLogPage.actionLabels-28895.test.tsx apps/console/src/pages/system/auditLogActions.test.ts`,预期 2 个文件、16 个测试全绿。 - **需要你做的**:无,席位落地。 ## What changes - **One label for an action, read from the filter's own choices.** `auditLogActions.ts` gains `actionLabelOf(options, action)`: the declared label of the action's option among `options`, else the stored value; a missing or empty action answers `undefined`. `options` is the page's existing `actionOptions` (`actionFilterOptionsOf` over the served `sys_audit_log` document), so there is no second map and the badge names an action exactly as the filter does, a blank declared label included. - **The two readers.** In `AuditLogPage.tsx` the Action column's badge and the detail drawer's title badge print `actionLabelOf(actionOptions, ...) ?? '—'` instead of the raw value. Badge tones (`actionBadgeVariant`) still key on the stored value. ## Tests - **New: `AuditLogPage.actionLabels-28895.test.tsx`, 3 cases.** A served `sys_audit_log` whose `action` options carry zh labels (创建 / 更新 / 删除) and a ledger with `update`, `create` and `nightly_purge` (declared by no option): - the Action cells read 更新, 创建, `nightly_purge` (acceptance 1 and 2, cell); - the drawer badge reads 更新 for the first row and `nightly_purge` for the undeclared one (acceptance 1 and 2, drawer); - with no `sys_audit_log` document served, the cells show the stored values. - **`auditLogActions.test.ts` gains `actionLabelOf`, 4 cases**: a declared label, a blank declared label reading as the value, the stored-value fallback (an undeclared value, an empty option list, an `Object.prototype` name), and `undefined` for a missing action. - **Changed: `AuditLogPage.actionFilter-13175.test.tsx`.** Its filter case read the badge text after filtering and pinned the raw values `login_failed` / `nightly_purge`; it now pins their declared labels (Sign-in refused / Nightly purge). The tone case's comment names the labels. ## Acceptance notes - **The Object column still shows API names** (`sys_flow_dispatch`). The card leaves it outside acceptance. The page holds object labels only as the Object filter's `Label (name)` combobox choices, so labelling the column means choosing a spelling for three sites (the cell, the drawer title, the target-record line), not a one-line reuse. Not done here; listed in the report for the seat. ## Verification All readings are on this branch. The fix is `7cbfbbe1`; the final head is `5e134182`, a merge of `main` (2 commits, `plugin-detail` and `spec`, none touching this diff's files). The audit log suites and the ratchet and changeset gates re-ran at the final head. <details><summary>Audit log suites · 8 of 8 files</summary> `pnpm exec vitest run --maxWorkers=2 apps/console/src/pages/system/AuditLogPage apps/console/src/pages/system/auditLogActions` from `ui/`, under `os-verify-lock`: Test Files 8 passed (8), Tests 49 passed (49), at `7cbfbbe1` and again at `5e134182`. `pnpm --filter @object-ui/console run type-check` at `7cbfbbe1`: exit 0. </details> <details><summary>Ablations · 2 legs, both red as predicted</summary> Each leg went through `scripts/ablation-replace.mjs` against the committed fix; each restore read blob == HEAD with `git diff HEAD` empty. 1. The Action cell back to the raw value: the cell case red (1 of 3). 2. The drawer badge back to the raw value: the drawer case red (1 of 3). </details> <details><summary>Derived gates</summary> `node scripts/pm/dispatch-gates.mjs --commands --repo objectstack-ai/objectstack` derived 33 commands at `7cbfbbe1`; all 33 ran with exit 0, and `--ran` reconciled 33 derived, 33 run, 0 NOT-MEASURED, 0 UNRUN. The slowest: `pnpm --dir ui exec vitest run scripts/` (169 passed, 2 skipped, 4900 tests, 278 s), `pnpm check:query-options-erasure` (292 s), `node scripts/check-comment-mask-corpus.mjs` (172 s), `pnpm check:type-check-debt` (161 s, 0 raw tsc errors). `node scripts/check-sdui-manifest.mjs`: exit 0. At `5e134182`: `pnpm check:type-check-debt`, `check-changeset-no-major --base origin/main` (with this body as the event), `check-sdui-manifest` and `check-empty-changeset --base origin/main` re-ran, each exit 0. </details> --- _Generated by [Claude Code](https://claude.ai/code/session_01QsR2jQnxoyK1uEFPjUqDDh)_ Co-authored-by: Claude <noreply@anthropic.com>
1 parent f7c545a commit 65a8d4f

6 files changed

Lines changed: 221 additions & 10 deletions

File tree

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,12 @@
1+
---
2+
'@object-ui/console': patch
3+
---
4+
5+
fix(console): the audit log's Action column and detail drawer show the declared action label
6+
7+
Clause-②: no
8+
Metadata-protocol: none
9+
10+
Setup › Audit Log printed each row's stored `sys_audit_log.action` value (`update`, `create`) in the Action column's badge and in the detail drawer's title badge, while the same page's Action filter already offered the declared option labels. In a Chinese console the column therefore read in lowercase English beside a filter reading 更新 / 创建.
11+
12+
Both badges now take the label of the row's action from the filter's own choices (`actionFilterOptionsOf`, through the new `actionLabelOf` in `auditLogActions.ts`), so the badge and the filter name an action alike. An action no option declares shows its stored value, as before. The badge tones, the filter and the list fetch are unchanged.

‎ui/apps/console/src/pages/system/AuditLogPage.actionFilter-13175.test.tsx‎

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -218,13 +218,13 @@ describe('AuditLogPage — the action filter offers the declared actions (object
218218
await chooseAction('Sign-in refused');
219219

220220
await waitFor(() => expect(lastFilter()).toEqual({ action: 'login_failed' }));
221-
await waitFor(() => expect(actionBadges().map((b) => b.text)).toEqual(['login_failed', 'login_failed']));
221+
await waitFor(() => expect(actionBadges().map((b) => b.text)).toEqual(['Sign-in refused', 'Sign-in refused']));
222222
expect(actionTrigger()).toHaveTextContent('Sign-in refused');
223223
expect(screen.getByRole('button', { name: /Clear filters \(1\)/ })).toBeInTheDocument();
224224

225225
await chooseAction('Nightly purge');
226226
await waitFor(() => expect(lastFilter()).toEqual({ action: 'nightly_purge' }));
227-
await waitFor(() => expect(actionBadges().map((b) => b.text)).toEqual(['nightly_purge']));
227+
await waitFor(() => expect(actionBadges().map((b) => b.text)).toEqual(['Nightly purge']));
228228

229229
await chooseAction('All actions');
230230
await waitFor(() => expect(lastFilter()).toBeNull());
@@ -241,7 +241,7 @@ describe('AuditLogPage — the action filter offers the declared actions (object
241241
expect(badge.classes).toContain('bg-primary');
242242
continue;
243243
}
244-
// login_failed, account_locked, nightly_purge: no local tone.
244+
// Sign-in refused, Account locked, Nightly purge: no local tone.
245245
expect(badge.classes, badge.text).toContain('text-foreground');
246246
for (const tone of toned) expect(badge.classes, badge.text).not.toContain(tone);
247247
}
Lines changed: 150 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,150 @@
1+
/**
2+
* ObjectUI
3+
* Copyright (c) 2024-present ObjectStack Inc.
4+
*
5+
* This source code is licensed under the MIT license found in the
6+
* LICENSE file in the root directory of this source tree.
7+
*/
8+
9+
/**
10+
* The audit log's Action column and detail drawer show the declared label of
11+
* a row's action, the label the same page's Action filter already offers.
12+
*
13+
* Both used to print the stored value (`update`, `create`), so in a Chinese
14+
* console the column read in lowercase English beside a filter that read
15+
* 更新 / 创建.
16+
*
17+
* The transport is stubbed at the seams the page reads through:
18+
* - the console's metadata cache (`useMetadata().objects`) serves the
19+
* `sys_audit_log` document as a zh console receives it: its `action`
20+
* options carry translated labels, and `nightly_purge` is stored on a row
21+
* but declared by no option;
22+
* - `fetch` answers `/data/sys_audit_log` with the ledger below;
23+
* - the console adapter (`useAdapter`) serves `sys_user` to the actor lookup.
24+
*
25+
* Pinned:
26+
* - each Action cell shows its action's declared label, and an action no
27+
* option declares shows its stored value;
28+
* - the detail drawer's badge shows the same label, for a declared action
29+
* and for an undeclared one;
30+
* - with no `sys_audit_log` document served, the cells show stored values.
31+
*/
32+
33+
import '@testing-library/jest-dom/vitest';
34+
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
35+
import { act, cleanup, fireEvent, render, screen, waitFor, within } from '@testing-library/react';
36+
import { MemoryRouter } from 'react-router-dom';
37+
38+
const { ADAPTER, METADATA, SERVED_OBJECTS } = vi.hoisted(() => {
39+
const AUDIT = Object.freeze({
40+
name: 'sys_audit_log',
41+
label: '审计日志',
42+
fields: {
43+
created_at: { type: 'datetime', label: '时间' },
44+
action: {
45+
type: 'select',
46+
label: '操作',
47+
options: [
48+
{ value: 'create', label: '创建' },
49+
{ value: 'update', label: '更新' },
50+
{ value: 'delete', label: '删除' },
51+
],
52+
},
53+
user_id: { type: 'lookup', reference: 'sys_user', label: '用户' },
54+
},
55+
});
56+
const SERVED_OBJECTS = Object.freeze([{ name: 'crm_account', label: '客户' }, AUDIT]) as readonly unknown[];
57+
return {
58+
ADAPTER: {
59+
find: async () => ({ data: [], total: 0 }),
60+
findOne: async () => null,
61+
getObjectSchema: async () => undefined,
62+
},
63+
SERVED_OBJECTS,
64+
// Mutable per test: the served objects.
65+
METADATA: { objects: SERVED_OBJECTS },
66+
};
67+
});
68+
69+
vi.mock('@object-ui/app-shell', async (importOriginal) => ({
70+
...(await importOriginal<Record<string, unknown>>()),
71+
useAdapter: () => ADAPTER,
72+
useMetadata: () => ({ objects: METADATA.objects }),
73+
}));
74+
75+
// Imported after the mocks so the page picks them up.
76+
import { AuditLogPage } from './AuditLogPage';
77+
78+
/** The stored ledger, newest first: two declared actions and one no option declares. */
79+
const LEDGER = [
80+
{ id: 'log_1', created_at: '2026-11-02T03:00:00.000Z', action: 'update', object_name: 'crm_account', record_id: 'acc_1', user_id: null },
81+
{ id: 'log_2', created_at: '2026-11-02T02:00:00.000Z', action: 'create', object_name: 'crm_account', record_id: 'acc_2', user_id: null },
82+
{ id: 'log_3', created_at: '2026-11-02T01:00:00.000Z', action: 'nightly_purge', object_name: 'crm_account', record_id: 'acc_3', user_id: null },
83+
];
84+
85+
beforeEach(() => {
86+
METADATA.objects = SERVED_OBJECTS;
87+
vi.stubGlobal(
88+
'fetch',
89+
vi.fn(async () => new Response(JSON.stringify({ object: 'sys_audit_log', records: LEDGER, total: LEDGER.length }), { status: 200 })),
90+
);
91+
});
92+
93+
afterEach(() => {
94+
cleanup();
95+
vi.unstubAllGlobals();
96+
});
97+
98+
function renderPage(): void {
99+
render(<MemoryRouter><AuditLogPage /></MemoryRouter>);
100+
}
101+
102+
/** The body rows of the log table. */
103+
function bodyRows(): HTMLElement[] {
104+
return screen.getAllByRole('row').slice(1);
105+
}
106+
107+
/** The Action cell's badge text, by row. */
108+
function actionCells(): string[] {
109+
return bodyRows().map((row) => row.querySelectorAll('td')[1]?.textContent ?? '');
110+
}
111+
112+
/** Open the detail drawer of the row at `index` and answer the drawer's title. */
113+
async function openDrawer(index: number): Promise<HTMLElement> {
114+
await act(async () => {
115+
fireEvent.click(bodyRows()[index]!);
116+
});
117+
const dialog = await screen.findByRole('dialog');
118+
// The title's first child is the action badge; the object name follows it.
119+
const title = within(dialog).getByRole('heading');
120+
return title.firstElementChild as HTMLElement;
121+
}
122+
123+
describe('AuditLogPage — the Action column and drawer show the declared action label', () => {
124+
it('labels each Action cell with its declared option, and an undeclared action with its stored value', async () => {
125+
renderPage();
126+
await waitFor(() => expect(actionCells()).toHaveLength(LEDGER.length));
127+
128+
expect(actionCells()).toEqual(['更新', '创建', 'nightly_purge']);
129+
});
130+
131+
it('labels the drawer badge as the row does', async () => {
132+
renderPage();
133+
await waitFor(() => expect(actionCells()).toHaveLength(LEDGER.length));
134+
135+
expect(await openDrawer(0)).toHaveTextContent(/^更新$/);
136+
cleanup();
137+
138+
renderPage();
139+
await waitFor(() => expect(actionCells()).toHaveLength(LEDGER.length));
140+
expect(await openDrawer(2)).toHaveTextContent(/^nightly_purge$/);
141+
});
142+
143+
it('shows stored values when no sys_audit_log document is served', async () => {
144+
METADATA.objects = [{ name: 'crm_account', label: '客户' }];
145+
renderPage();
146+
await waitFor(() => expect(actionCells()).toHaveLength(LEDGER.length));
147+
148+
expect(actionCells()).toEqual(['update', 'create', 'nightly_purge']);
149+
});
150+
});

‎ui/apps/console/src/pages/system/AuditLogPage.tsx‎

Lines changed: 6 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -73,7 +73,7 @@ import {
7373
import { LookupField, getLazyFieldWidget, objectRefOptionLabel } from '@object-ui/fields';
7474
import { ConsolePageHeader, useAdapter, useMetadata } from '@object-ui/app-shell';
7575
import { Search, X, AlertCircle, ScrollText, ExternalLink } from 'lucide-react';
76-
import { actionBadgeVariant, actionFilterOptionsOf } from './auditLogActions';
76+
import { actionBadgeVariant, actionFilterOptionsOf, actionLabelOf } from './auditLogActions';
7777

7878
const SERVER_URL = (import.meta.env.VITE_SERVER_URL || '').replace(/\/$/, '');
7979
const API_BASE = `${SERVER_URL}/api/v1`;
@@ -321,7 +321,9 @@ export function AuditLogPage() {
321321
const { objects, getTypeStatus } = useMetadata();
322322
const presets = useMemo(() => auditPresetsOf(auditObjectOf(objects)), [objects]);
323323
// The Action filter offers what the same document declares for
324-
// `sys_audit_log.action` (objectui#13175), never a list kept here.
324+
// `sys_audit_log.action` (objectui#13175), never a list kept here. The
325+
// Action column and the detail drawer label a row's action from the same
326+
// choices (`actionLabelOf`), so the badge reads as the filter does.
325327
const actionOptions = useMemo(() => actionFilterOptionsOf(auditObjectOf(objects)), [objects]);
326328
// The Object filter picks from the same served objects (objectui#13165).
327329
const objectOptions = useMemo(() => objectFilterOptionsOf(objects, displayLocale), [objects, displayLocale]);
@@ -657,7 +659,7 @@ export function AuditLogPage() {
657659
onClick={() => setSelected(r)}
658660
>
659661
<TableCell className="font-mono text-xs">{formatDate(r.created_at, displayLocale)}</TableCell>
660-
<TableCell><Badge variant={variant}>{r.action || '—'}</Badge></TableCell>
662+
<TableCell><Badge variant={variant}>{actionLabelOf(actionOptions, r.action) ?? '—'}</Badge></TableCell>
661663
<TableCell>{r.object_name || '—'}</TableCell>
662664
<TableCell className="font-mono text-xs">{truncate(r.record_id, 18)}</TableCell>
663665
<TableCell><ActorCell face={actorOf(r)} /></TableCell>
@@ -696,7 +698,7 @@ export function AuditLogPage() {
696698
<SheetHeader>
697699
<SheetTitle className="flex items-center gap-2">
698700
<Badge variant={actionBadgeVariant(selected?.action)}>
699-
{selected?.action || '—'}
701+
{actionLabelOf(actionOptions, selected?.action) ?? '—'}
700702
</Badge>
701703
{selected?.object_name || t('console.system.auditLog.eventFallback', { defaultValue: 'Audit Event' })}
702704
</SheetTitle>

‎ui/apps/console/src/pages/system/auditLogActions.test.ts‎

Lines changed: 32 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -27,7 +27,7 @@
2727
*/
2828
import { describe, expect, it } from 'vitest';
2929
import * as auditLogActions from './auditLogActions';
30-
import { actionBadgeVariant, actionFilterOptionsOf } from './auditLogActions';
30+
import { actionBadgeVariant, actionFilterOptionsOf, actionLabelOf } from './auditLogActions';
3131

3232
/**
3333
* `sys_audit_log.action` as objectstack's `sys-audit-log.object.ts` declares it
@@ -141,3 +141,34 @@ describe('actionBadgeVariant (objectui#13175)', () => {
141141
expect(actionBadgeVariant('constructor')).toBe('outline');
142142
});
143143
});
144+
145+
describe('actionLabelOf', () => {
146+
const options = actionFilterOptionsOf(
147+
auditObject([
148+
{ label: '更新', value: 'update' },
149+
{ label: '创建', value: 'create' },
150+
{ label: ' ', value: 'account_locked' },
151+
]),
152+
);
153+
154+
it('labels a declared action with its declared option label', () => {
155+
expect(actionLabelOf(options, 'update')).toBe('更新');
156+
expect(actionLabelOf(options, 'create')).toBe('创建');
157+
});
158+
159+
it('labels an action whose declared label is blank with its value, as the filter does', () => {
160+
expect(actionLabelOf(options, 'account_locked')).toBe('account_locked');
161+
});
162+
163+
it('falls back to the stored value for an action no option declares', () => {
164+
expect(actionLabelOf(options, 'nightly_purge')).toBe('nightly_purge');
165+
expect(actionLabelOf([], 'update')).toBe('update');
166+
expect(actionLabelOf(options, 'constructor')).toBe('constructor');
167+
});
168+
169+
it('answers undefined for a missing or empty action, so the page shows its dash', () => {
170+
expect(actionLabelOf(options, undefined)).toBeUndefined();
171+
expect(actionLabelOf(options, null)).toBeUndefined();
172+
expect(actionLabelOf(options, '')).toBeUndefined();
173+
});
174+
});

‎ui/apps/console/src/pages/system/auditLogActions.ts‎

Lines changed: 18 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
/**
2-
* The Audit Log's `sys_audit_log.action` helpers (the Action filter's choices
3-
* and the action badge's tone), split into their own module — rather than
2+
* The Audit Log's `sys_audit_log.action` helpers (the Action filter's choices,
3+
* the action badge's label and its tone), split into their own module — rather than
44
* living inline in AuditLogPage.tsx — so they can be imported by tests
55
* without pulling a component export into a non-component file (React Fast
66
* Refresh wants component files to only export components).
@@ -51,6 +51,22 @@ export function actionFilterOptionsOf(objectDef: unknown): ActionFilterOption[]
5151
return options;
5252
}
5353

54+
/**
55+
* The label a row's action shows in the Action column and the detail drawer:
56+
* the declared label of its option among `options` (the filter's choices from
57+
* `actionFilterOptionsOf`, so the badge and the filter name an action alike),
58+
* else the stored value itself, so an action the server writes without
59+
* declaring it still reads as what was stored. A missing or empty action
60+
* answers `undefined` and the page shows its dash.
61+
*/
62+
export function actionLabelOf(
63+
options: readonly ActionFilterOption[],
64+
action: string | null | undefined,
65+
): string | undefined {
66+
if (!action) return undefined;
67+
return options.find((o) => o.value === action)?.label ?? action;
68+
}
69+
5470
export type ActionBadgeVariant = 'default' | 'secondary' | 'destructive' | 'outline';
5571

5672
/**

0 commit comments

Comments
 (0)