From b85081cdf2640e162f7208a06ce65e9dadc81d9a Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 6 Nov 2026 06:08:14 +0000 Subject: [PATCH 1/3] wip: audit log drawer opens the row's record page --- .../AuditLogPage.entryPage-30748.test.tsx | 219 ++++++++++++++++++ .../console/src/pages/system/AuditLogPage.tsx | 44 +++- ui/packages/i18n/src/locales/ar.ts | 2 + ui/packages/i18n/src/locales/de.ts | 2 + ui/packages/i18n/src/locales/en.ts | 2 + ui/packages/i18n/src/locales/es.ts | 2 + ui/packages/i18n/src/locales/fr.ts | 2 + ui/packages/i18n/src/locales/ja.ts | 2 + ui/packages/i18n/src/locales/ko.ts | 2 + ui/packages/i18n/src/locales/pt.ts | 2 + ui/packages/i18n/src/locales/ru.ts | 2 + ui/packages/i18n/src/locales/zh.ts | 2 + 12 files changed, 281 insertions(+), 2 deletions(-) create mode 100644 ui/apps/console/src/pages/system/AuditLogPage.entryPage-30748.test.tsx diff --git a/ui/apps/console/src/pages/system/AuditLogPage.entryPage-30748.test.tsx b/ui/apps/console/src/pages/system/AuditLogPage.entryPage-30748.test.tsx new file mode 100644 index 00000000000..a729483892e --- /dev/null +++ b/ui/apps/console/src/pages/system/AuditLogPage.entryPage-30748.test.tsx @@ -0,0 +1,219 @@ +/** + * ObjectUI + * Copyright (c) 2024-present ObjectStack Inc. + * + * This source code is licensed under the MIT license found in the + * LICENSE file in the root directory of this source tree. + */ + +/** + * The Audit Log drawer opens the row's own record page. + * + * A settings write records a `config_change` row that carries only a digest. + * The CRUD mirror writes its own rows about the same settings row, and those + * hold the value. Both name the settings row as `record_id`, and the + * platform's record page for `sys_audit_log` (`sys_audit_log_detail`, in + * `plugin-audit`) lists every ledger row with the row's `record_id`. The + * drawer had no way to reach that page: "Open as list" opens the object list, + * not a row. + * + * The router is real: the page mounts at `/apps/:appName/system/audit-log`, as + * `systemRoutes` mounts it, and the console's record route + * `/apps/:appName/:objectName/record/:recordId` holds a probe that reads back + * what the link handed it. + * + * Pinned: + * - on a `config_change` row the drawer links to that row's record page under + * the list's app, says that page lists the record's other changes, and the + * link lands there carrying the way back to this page; + * - the landed row names the settings row the mirror's update row names, so + * the page's same-record list holds the update row; + * - a row about no record still links to its own page, without the hint; + * - under zh the link and the hint read the zh pack. + * + * Not pinned here, and pinned where the code lives: the page's same-record + * list (`plugin-audit`'s `sys-audit-log-page.test.ts`, "the same-record list + * is the ledger, keyed on `record_id` on both sides") and the related list's + * `relationshipValueField` read (`plugin-detail`). + */ + +import '@testing-library/jest-dom/vitest'; +import { describe, it, expect, vi, afterEach, beforeEach } from 'vitest'; +import { render, screen, cleanup, act, fireEvent, within } from '@testing-library/react'; +import { MemoryRouter, Route, Routes, useLocation, useParams } from 'react-router-dom'; +import { I18nProvider } from '@object-ui/i18n'; +import { builtInLocales } from '@object-ui/i18n/locales'; + +const { ADAPTER } = vi.hoisted(() => ({ + ADAPTER: { + find: async () => ({ data: [], total: 0 }), + findOne: async () => null, + getObjectSchema: async () => undefined, + }, +})); + +vi.mock('@object-ui/app-shell', async (importOriginal) => ({ + ...(await importOriginal>()), + useAdapter: () => ADAPTER, +})); + +import { AuditLogPage } from './AuditLogPage'; + +/** The settings row both writers name. */ +const SETTINGS_ROW = 'set_mail_from_0001'; + +/** The `config_change` row: a digest, no value. */ +const CONFIG_CHANGE = { + id: 'log_cfg_0001', + created_at: '2026-11-06T10:00:01.000Z', + action: 'config_change', + object_name: 'sys_setting', + record_id: SETTINGS_ROW, + new_value: '{"digest":"sha256:9f2c"}', +}; + +/** The CRUD mirror's update row about the same settings row: it holds the value. */ +const MIRROR_UPDATE = { + id: 'log_upd_0002', + created_at: '2026-11-06T10:00:00.000Z', + action: 'update', + object_name: 'sys_setting', + record_id: SETTINGS_ROW, + old_value: '{"value":"noreply@old.example"}', + new_value: '{"value":"noreply@new.example"}', +}; + +/** A row about no record (an import run). */ +const IMPORT_RUN = { + id: 'log_imp_0003', + created_at: '2026-11-06T09:00:00.000Z', + action: 'import', + object_name: 'account', + record_id: null, +}; + +const LEDGER = [CONFIG_CHANGE, MIRROR_UPDATE, IMPORT_RUN]; + +beforeEach(() => { + vi.stubGlobal( + 'fetch', + vi.fn(async () => + new Response(JSON.stringify({ object: 'sys_audit_log', records: LEDGER, total: LEDGER.length }), { status: 200 }), + ), + ); +}); + +afterEach(() => { + cleanup(); + vi.unstubAllGlobals(); +}); + +async function settle(): Promise { + for (let i = 0; i < 6; i++) { + await act(async () => { + await new Promise((r) => setTimeout(r, 0)); + }); + } +} + +/** The console's record route, answering with what the link handed it. */ +function RecordRouteProbe() { + const { appName, objectName, recordId } = useParams(); + const from = (useLocation().state as { from?: { pathname?: string; label?: string } } | null)?.from; + return ( +
+ {appName} + {objectName} + {recordId} + {from?.pathname} + {from?.label} +
+ ); +} + +function renderAt(path = '/apps/setup/system/audit-log') { + return render( + + + } /> + } /> + + , + ); +} + +/** Opens the drawer on one ledger row. The table lists the rows in served order under its header row. */ +async function openDrawerOn(rowId: string): Promise { + await screen.findAllByRole('cell'); + await settle(); + const index = LEDGER.findIndex((r) => r.id === rowId); + const rows = screen.getAllByRole('row'); + expect(rows).toHaveLength(LEDGER.length + 1); + fireEvent.click(rows[index + 1]); + const drawer = await screen.findByRole('dialog'); + // The drawer is the chosen row's: it shows that row's id. + expect(within(drawer).getByText(rowId)).toBeInTheDocument(); + return drawer; +} + +describe('AuditLogPage — the drawer opens the row\'s record page', () => { + it('on a config_change row, links to that row\'s page and says it lists the record\'s other changes', async () => { + renderAt(); + const drawer = await openDrawerOn(CONFIG_CHANGE.id); + const link = within(drawer).getByRole('link', { name: /Open entry page/ }); + expect(link).toHaveAttribute('href', `/apps/setup/sys_audit_log/record/${CONFIG_CHANGE.id}`); + expect(within(drawer).getByText(builtInLocales.en.console.system.auditLog.openEntryPageHint)).toBeInTheDocument(); + }); + + it('the link lands on the record route with the row id and the way back to this page', async () => { + renderAt(); + const drawer = await openDrawerOn(CONFIG_CHANGE.id); + fireEvent.click(within(drawer).getByRole('link', { name: /Open entry page/ })); + expect(await screen.findByTestId('record-route')).toBeInTheDocument(); + expect(screen.getByTestId('app')).toHaveTextContent('setup'); + expect(screen.getByTestId('object')).toHaveTextContent('sys_audit_log'); + expect(screen.getByTestId('record')).toHaveTextContent(CONFIG_CHANGE.id); + expect(screen.getByTestId('from-path')).toHaveTextContent('/apps/setup/system/audit-log'); + expect(screen.getByTestId('from-label')).toHaveTextContent('Audit Log'); + // The landed row's `record_id` is the one the mirror's update row names, + // so the page's same-record list (keyed on `record_id` on both sides) + // holds the update row and its value. + const landed = LEDGER.find((r) => r.id === screen.getByTestId('record').textContent); + const sameRecord = LEDGER.filter((r) => r.record_id === landed?.record_id).map((r) => r.id); + expect(sameRecord).toContain(MIRROR_UPDATE.id); + }); + + it('keeps the app segment the page is mounted under', async () => { + renderAt('/apps/crm/system/audit-log'); + const drawer = await openDrawerOn(CONFIG_CHANGE.id); + expect(within(drawer).getByRole('link', { name: /Open entry page/ })) + .toHaveAttribute('href', `/apps/crm/sys_audit_log/record/${CONFIG_CHANGE.id}`); + }); + + it('a row about no record still links to its own page, without the hint', async () => { + renderAt(); + const drawer = await openDrawerOn(IMPORT_RUN.id); + expect(within(drawer).getByRole('link', { name: /Open entry page/ })) + .toHaveAttribute('href', `/apps/setup/sys_audit_log/record/${IMPORT_RUN.id}`); + expect(within(drawer).queryByText(builtInLocales.en.console.system.auditLog.openEntryPageHint)).toBeNull(); + }); + + it('under zh, the link and the hint read the zh pack', async () => { + const zhAudit = builtInLocales.zh.console.system.auditLog; + // Guard: real zh rows, not English copied over. + expect(zhAudit.openEntryPage).not.toBe(builtInLocales.en.console.system.auditLog.openEntryPage); + expect(zhAudit.openEntryPageHint).not.toBe(builtInLocales.en.console.system.auditLog.openEntryPageHint); + render( + + + + } /> + + + , + ); + const drawer = await openDrawerOn(CONFIG_CHANGE.id); + expect(within(drawer).getByRole('link', { name: new RegExp(zhAudit.openEntryPage) })).toBeInTheDocument(); + expect(within(drawer).getByText(zhAudit.openEntryPageHint)).toBeInTheDocument(); + }); +}); diff --git a/ui/apps/console/src/pages/system/AuditLogPage.tsx b/ui/apps/console/src/pages/system/AuditLogPage.tsx index 15c4495f17a..e39213e7a10 100644 --- a/ui/apps/console/src/pages/system/AuditLogPage.tsx +++ b/ui/apps/console/src/pages/system/AuditLogPage.tsx @@ -28,6 +28,8 @@ * - Paginated table (50/page) * - Row click opens a side drawer with full event details + JSON * diff of old_value → new_value when present + * - The drawer opens the row's own record page (`entryPageHref` below), + * which lists the other ledger rows about the same `record_id` * * Out of scope (future): * - Cross-tenant filter (single-tenant view assumed) @@ -37,7 +39,7 @@ */ import { Suspense, useCallback, useEffect, useId, useMemo, useState } from 'react'; -import { Link, useParams } from 'react-router-dom'; +import { Link, useLocation, useParams } from 'react-router-dom'; import { mergeFilterNodes, toFilterNodeSafely } from '@object-ui/core'; import { useDisplayLocale, useObjectTranslation } from '@object-ui/i18n'; import { @@ -261,6 +263,18 @@ function formatDate(s: string | null | undefined, locale: string): string { try { return new Date(s).toLocaleString(locale); } catch { return s; } } +/** + * The router path of one audit row's own record page: the console's standard + * record route for `sys_audit_log` under the list's app segment, where + * `RecordDetailView` mounts the page the platform serves for the ledger + * (`sys_audit_log_detail`, `plugin-audit`). That page lists every ledger row + * with the same `record_id`, so a `config_change` row, which carries only a + * digest, leads to the CRUD mirror's rows that hold the value. + */ +function entryPageHref(listBase: string, rowId: string): string { + return `${listBase}/record/${encodeURIComponent(rowId)}`; +} + function truncate(s: string | null | undefined, n = 24): string { if (!s) return '—'; return s.length > n ? `${s.slice(0, n - 1)}…` : s; @@ -478,6 +492,14 @@ export function AuditLogPage() { const listBase = `/apps/${encodeURIComponent(appName || 'setup')}/${AUDIT_OBJECT}`; const viewHref = (key: string) => `${listBase}/view/${encodeURIComponent(key)}`; const listHref = activePreset ? viewHref(activePreset.key) : listBase; + // The way back the entry page's crumb row draws (`state.from`), so an app + // whose navigation does not list `sys_audit_log` still leads back here. + const location = useLocation(); + const pageTitle = t('console.system.auditLog.title', { defaultValue: 'Audit Log' }); + const entryLinkState = useMemo( + () => ({ from: { pathname: `${location.pathname}${location.search}`, label: pageTitle } }), + [location.pathname, location.search, pageTitle], + ); return (
@@ -487,7 +509,7 @@ export function AuditLogPage() { one row across the page above the padded body. Below `sm` the header hides the subtitle and keeps the icon, title and Refresh. */} + {selected?.id && ( +
+ + {selected.record_id && ( +

+ {t('console.system.auditLog.openEntryPageHint', { + defaultValue: 'The entry page lists every change to the same record, newest first.', + })} +

+ )} +
+ )} + {selected && (
diff --git a/ui/packages/i18n/src/locales/ar.ts b/ui/packages/i18n/src/locales/ar.ts index 3d9049524c7..9722e54f15b 100644 --- a/ui/packages/i18n/src/locales/ar.ts +++ b/ui/packages/i18n/src/locales/ar.ts @@ -2894,6 +2894,8 @@ const ar = { title: 'سجل التدقيق', subtitle: 'نشاط النظام وتغييرات السجلات للقراءة فقط. الأحدث أولاً.', openAsList: 'فتح كقائمة', + openEntryPage: 'فتح صفحة الإدخال', + openEntryPageHint: 'تعرض صفحة الإدخال كل التغييرات على السجل نفسه، الأحدث أولاً.', openPresetAsList: 'فتح {{label}} كقائمة', presets: 'إعدادات مسبقة', presetOtherSource: 'يقرأ هذا العرض مصدر بيانات آخر.', diff --git a/ui/packages/i18n/src/locales/de.ts b/ui/packages/i18n/src/locales/de.ts index b443dce0fa1..233b35220f8 100644 --- a/ui/packages/i18n/src/locales/de.ts +++ b/ui/packages/i18n/src/locales/de.ts @@ -2640,6 +2640,8 @@ const de = { title: 'Audit-Protokoll', subtitle: 'Schreibgeschützte Systemaktivität und Datensatzänderungen. Neueste zuerst.', openAsList: 'Als Liste öffnen', + openEntryPage: 'Eintragsseite öffnen', + openEntryPageHint: 'Die Eintragsseite listet alle Änderungen am selben Datensatz auf, die neueste zuerst.', openPresetAsList: '{{label}} als Liste öffnen', presets: 'Voreinstellungen', presetOtherSource: 'Diese Ansicht liest eine andere Datenquelle.', diff --git a/ui/packages/i18n/src/locales/en.ts b/ui/packages/i18n/src/locales/en.ts index a6dfa62dc0d..98367adbed2 100644 --- a/ui/packages/i18n/src/locales/en.ts +++ b/ui/packages/i18n/src/locales/en.ts @@ -3197,6 +3197,8 @@ const en = { title: 'Audit Log', subtitle: 'Read-only system activity and record changes. Newest first.', openAsList: 'Open as list', + openEntryPage: 'Open entry page', + openEntryPageHint: 'The entry page lists every change to the same record, newest first.', openPresetAsList: 'Open {{label}} as list', presets: 'Presets', presetOtherSource: 'This view reads another data source.', diff --git a/ui/packages/i18n/src/locales/es.ts b/ui/packages/i18n/src/locales/es.ts index 35aa9986d54..1e99c709620 100644 --- a/ui/packages/i18n/src/locales/es.ts +++ b/ui/packages/i18n/src/locales/es.ts @@ -2705,6 +2705,8 @@ const es = { title: 'Registro de auditoría', subtitle: 'Actividad del sistema y cambios de registros, de solo lectura. Lo más reciente primero.', openAsList: 'Abrir como lista', + openEntryPage: 'Abrir la página de la entrada', + openEntryPageHint: 'La página de la entrada muestra todos los cambios del mismo registro, del más reciente al más antiguo.', openPresetAsList: 'Abrir {{label}} como lista', presets: 'Ajustes predefinidos', presetOtherSource: 'Esta vista lee otra fuente de datos.', diff --git a/ui/packages/i18n/src/locales/fr.ts b/ui/packages/i18n/src/locales/fr.ts index f8b55da1b0d..38c56c9791a 100644 --- a/ui/packages/i18n/src/locales/fr.ts +++ b/ui/packages/i18n/src/locales/fr.ts @@ -2701,6 +2701,8 @@ const fr = { title: 'Journal d’audit', subtitle: 'Activité système et modifications d’enregistrements, en lecture seule. Les plus récentes en premier.', openAsList: 'Ouvrir en liste', + openEntryPage: 'Ouvrir la page de l’entrée', + openEntryPageHint: 'La page de l’entrée liste toutes les modifications du même enregistrement, de la plus récente à la plus ancienne.', openPresetAsList: 'Ouvrir {{label}} en liste', presets: 'Préréglages', presetOtherSource: 'Cette vue lit une autre source de données.', diff --git a/ui/packages/i18n/src/locales/ja.ts b/ui/packages/i18n/src/locales/ja.ts index 14ecc4ca422..591333ae44d 100644 --- a/ui/packages/i18n/src/locales/ja.ts +++ b/ui/packages/i18n/src/locales/ja.ts @@ -2642,6 +2642,8 @@ const ja = { title: '監査ログ', subtitle: '読み取り専用のシステムアクティビティとレコード変更。新しい順に表示します。', openAsList: 'リストで開く', + openEntryPage: 'エントリページを開く', + openEntryPageHint: 'エントリページには、同じレコードへのすべての変更が新しい順に表示されます。', openPresetAsList: '{{label}} をリストで開く', presets: 'プリセット', presetOtherSource: 'このビューは別のデータソースを読み取ります。', diff --git a/ui/packages/i18n/src/locales/ko.ts b/ui/packages/i18n/src/locales/ko.ts index e1939a269f2..9c9ce11390f 100644 --- a/ui/packages/i18n/src/locales/ko.ts +++ b/ui/packages/i18n/src/locales/ko.ts @@ -2640,6 +2640,8 @@ const ko = { title: '감사 로그', subtitle: '읽기 전용 시스템 활동 및 레코드 변경 내역입니다. 최신 항목이 먼저 표시됩니다.', openAsList: '목록으로 열기', + openEntryPage: '항목 페이지 열기', + openEntryPageHint: '항목 페이지에는 같은 레코드의 모든 변경 사항이 최신순으로 표시됩니다.', openPresetAsList: '{{label}}을(를) 목록으로 열기', presets: '프리셋', presetOtherSource: '이 뷰는 다른 데이터 소스를 읽습니다.', diff --git a/ui/packages/i18n/src/locales/pt.ts b/ui/packages/i18n/src/locales/pt.ts index df1ffe391fa..2cf1606a0d0 100644 --- a/ui/packages/i18n/src/locales/pt.ts +++ b/ui/packages/i18n/src/locales/pt.ts @@ -2700,6 +2700,8 @@ const pt = { title: 'Log de auditoria', subtitle: 'Atividade do sistema e alterações de registros, somente leitura. Mais recentes primeiro.', openAsList: 'Abrir como lista', + openEntryPage: 'Abrir a página da entrada', + openEntryPageHint: 'A página da entrada lista todas as alterações do mesmo registro, da mais recente para a mais antiga.', openPresetAsList: 'Abrir {{label}} como lista', presets: 'Predefinições', presetOtherSource: 'Esta visualização lê outra fonte de dados.', diff --git a/ui/packages/i18n/src/locales/ru.ts b/ui/packages/i18n/src/locales/ru.ts index 2b2331f03d5..400e6acf084 100644 --- a/ui/packages/i18n/src/locales/ru.ts +++ b/ui/packages/i18n/src/locales/ru.ts @@ -2775,6 +2775,8 @@ const ru = { title: 'Журнал аудита', subtitle: 'Активность системы и изменения записей, только для чтения. Сначала новые.', openAsList: 'Открыть списком', + openEntryPage: 'Открыть страницу записи журнала', + openEntryPageHint: 'На странице записи журнала перечислены все изменения той же записи, сначала новые.', openPresetAsList: 'Открыть «{{label}}» списком', presets: 'Пресеты', presetOtherSource: 'Это представление читает другой источник данных.', diff --git a/ui/packages/i18n/src/locales/zh.ts b/ui/packages/i18n/src/locales/zh.ts index 4d46132b20b..dc7935449ba 100644 --- a/ui/packages/i18n/src/locales/zh.ts +++ b/ui/packages/i18n/src/locales/zh.ts @@ -2733,6 +2733,8 @@ const zh = { title: '审计日志', subtitle: '只读的系统活动与记录变更,最新的在前。', openAsList: '以列表打开', + openEntryPage: '打开条目页', + openEntryPageHint: '条目页按时间倒序列出同一记录的全部变更。', openPresetAsList: '以列表打开 {{label}}', presets: '预设', presetOtherSource: '此视图读取的是其他数据源。', From ee3ec7bb3acd565d043eb67635460d9bf3841d04 Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 6 Nov 2026 06:13:41 +0000 Subject: [PATCH 2/3] chore: changeset for the audit log entry page link Co-authored-by: Claude Claude-Session: https://claude.ai/code/session_01L9qibwL8YVfPjy1sSSSEkH --- .changeset/30748-audit-log-entry-page.md | 11 +++++++++++ 1 file changed, 11 insertions(+) create mode 100644 .changeset/30748-audit-log-entry-page.md diff --git a/.changeset/30748-audit-log-entry-page.md b/.changeset/30748-audit-log-entry-page.md new file mode 100644 index 00000000000..461eb417432 --- /dev/null +++ b/.changeset/30748-audit-log-entry-page.md @@ -0,0 +1,11 @@ +--- +'@object-ui/console': patch +'@object-ui/i18n': patch +--- + +feat(console): the Setup → Audit Log drawer opens the selected row's own record page, which lists the other changes to the same record + +Clause-②: no +Metadata-protocol: none + +The Audit Log drawer gains an "Open entry page" link to the console's record route for `sys_audit_log` and the row's id, under the app the page is mounted in, carrying the way back to the Audit Log. The platform's record page for the ledger lists every row with the same `record_id`, so from a settings write's `config_change` row, which carries only a digest, an administrator now reaches the CRUD mirror's rows that hold the old and new value. On a row that names a record the drawer says so under the link. `@object-ui/i18n` ships the two strings in all ten built-in languages (`console.system.auditLog.openEntryPage`, `console.system.auditLog.openEntryPageHint`). From b0f67dde55a3dba640fd3b90374c810a4b3ab62c Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 6 Nov 2026 06:45:10 +0000 Subject: [PATCH 3/3] chore: declare the changeset's language-pack widening Co-authored-by: Claude Claude-Session: https://claude.ai/code/session_01L9qibwL8YVfPjy1sSSSEkH --- .changeset/30748-audit-log-entry-page.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.changeset/30748-audit-log-entry-page.md b/.changeset/30748-audit-log-entry-page.md index 461eb417432..5e85b93768a 100644 --- a/.changeset/30748-audit-log-entry-page.md +++ b/.changeset/30748-audit-log-entry-page.md @@ -5,7 +5,7 @@ feat(console): the Setup → Audit Log drawer opens the selected row's own record page, which lists the other changes to the same record -Clause-②: no +Clause-②: yes (widening) — two new console keys (`console.system.auditLog.openEntryPage`, `console.system.auditLog.openEntryPageHint`) ship in every published `@object-ui/i18n` pack; nothing is narrowed. Metadata-protocol: none The Audit Log drawer gains an "Open entry page" link to the console's record route for `sys_audit_log` and the row's id, under the app the page is mounted in, carrying the way back to the Audit Log. The platform's record page for the ledger lists every row with the same `record_id`, so from a settings write's `config_change` row, which carries only a digest, an administrator now reaches the CRUD mirror's rows that hold the old and new value. On a row that names a record the drawer says so under the link. `@object-ui/i18n` ships the two strings in all ten built-in languages (`console.system.auditLog.openEntryPage`, `console.system.auditLog.openEntryPageHint`).