Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
11 changes: 11 additions & 0 deletions .changeset/30748-audit-log-entry-page.md
Original file line number Diff line number Diff line change
@@ -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-②: 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`).
219 changes: 219 additions & 0 deletions ui/apps/console/src/pages/system/AuditLogPage.entryPage-30748.test.tsx
Original file line number Diff line number Diff line change
@@ -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<Record<string, unknown>>()),
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<void> {
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 (
<div data-testid="record-route">
<span data-testid="app">{appName}</span>
<span data-testid="object">{objectName}</span>
<span data-testid="record">{recordId}</span>
<span data-testid="from-path">{from?.pathname}</span>
<span data-testid="from-label">{from?.label}</span>
</div>
);
}

function renderAt(path = '/apps/setup/system/audit-log') {
return render(
<MemoryRouter initialEntries={[path]}>
<Routes>
<Route path="/apps/:appName/system/audit-log" element={<AuditLogPage />} />
<Route path="/apps/:appName/:objectName/record/:recordId" element={<RecordRouteProbe />} />
</Routes>
</MemoryRouter>,
);
}

/** 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<HTMLElement> {
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(
<I18nProvider config={{ defaultLanguage: 'zh', detectBrowserLanguage: false }} persistLanguage={false}>
<MemoryRouter initialEntries={['/apps/setup/system/audit-log']}>
<Routes>
<Route path="/apps/:appName/system/audit-log" element={<AuditLogPage />} />
</Routes>
</MemoryRouter>
</I18nProvider>,
);
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();
});
});
44 changes: 42 additions & 2 deletions ui/apps/console/src/pages/system/AuditLogPage.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand All @@ -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 {
Expand Down Expand Up @@ -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;
Expand Down Expand Up @@ -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 (
<div>
Expand All @@ -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. */}
<ConsolePageHeader
title={t('console.system.auditLog.title', { defaultValue: 'Audit Log' })}
title={pageTitle}
subtitle={t('console.system.auditLog.subtitle', {
defaultValue: 'Read-only system activity and record changes. Newest first.',
})}
Expand Down Expand Up @@ -741,6 +763,24 @@ export function AuditLogPage() {
</SheetDescription>
</SheetHeader>

{selected?.id && (
<div className="mt-4 space-y-1.5">
<Button asChild variant="outline" size="sm">
<Link to={entryPageHref(listBase, selected.id)} state={entryLinkState}>
<ExternalLink className="h-4 w-4 mr-1" />
{t('console.system.auditLog.openEntryPage', { defaultValue: 'Open entry page' })}
</Link>
</Button>
{selected.record_id && (
<p className="text-xs text-muted-foreground">
{t('console.system.auditLog.openEntryPageHint', {
defaultValue: 'The entry page lists every change to the same record, newest first.',
})}
</p>
)}
</div>
)}

{selected && (
<div className="mt-6 space-y-5">
<div className="grid grid-cols-2 gap-3 text-sm">
Expand Down
2 changes: 2 additions & 0 deletions ui/packages/i18n/src/locales/ar.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2894,6 +2894,8 @@ const ar = {
title: 'سجل التدقيق',
subtitle: 'نشاط النظام وتغييرات السجلات للقراءة فقط. الأحدث أولاً.',
openAsList: 'فتح كقائمة',
openEntryPage: 'فتح صفحة الإدخال',
openEntryPageHint: 'تعرض صفحة الإدخال كل التغييرات على السجل نفسه، الأحدث أولاً.',
openPresetAsList: 'فتح {{label}} كقائمة',
presets: 'إعدادات مسبقة',
presetOtherSource: 'يقرأ هذا العرض مصدر بيانات آخر.',
Expand Down
2 changes: 2 additions & 0 deletions ui/packages/i18n/src/locales/de.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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.',
Expand Down
2 changes: 2 additions & 0 deletions ui/packages/i18n/src/locales/en.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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.',
Expand Down
2 changes: 2 additions & 0 deletions ui/packages/i18n/src/locales/es.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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.',
Expand Down
2 changes: 2 additions & 0 deletions ui/packages/i18n/src/locales/fr.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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.',
Expand Down
2 changes: 2 additions & 0 deletions ui/packages/i18n/src/locales/ja.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2642,6 +2642,8 @@ const ja = {
title: '監査ログ',
subtitle: '読み取り専用のシステムアクティビティとレコード変更。新しい順に表示します。',
openAsList: 'リストで開く',
openEntryPage: 'エントリページを開く',
openEntryPageHint: 'エントリページには、同じレコードへのすべての変更が新しい順に表示されます。',
openPresetAsList: '{{label}} をリストで開く',
presets: 'プリセット',
presetOtherSource: 'このビューは別のデータソースを読み取ります。',
Expand Down
2 changes: 2 additions & 0 deletions ui/packages/i18n/src/locales/ko.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2640,6 +2640,8 @@ const ko = {
title: '감사 로그',
subtitle: '읽기 전용 시스템 활동 및 레코드 변경 내역입니다. 최신 항목이 먼저 표시됩니다.',
openAsList: '목록으로 열기',
openEntryPage: '항목 페이지 열기',
openEntryPageHint: '항목 페이지에는 같은 레코드의 모든 변경 사항이 최신순으로 표시됩니다.',
openPresetAsList: '{{label}}을(를) 목록으로 열기',
presets: '프리셋',
presetOtherSource: '이 뷰는 다른 데이터 소스를 읽습니다.',
Expand Down
2 changes: 2 additions & 0 deletions ui/packages/i18n/src/locales/pt.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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.',
Expand Down
2 changes: 2 additions & 0 deletions ui/packages/i18n/src/locales/ru.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2775,6 +2775,8 @@ const ru = {
title: 'Журнал аудита',
subtitle: 'Активность системы и изменения записей, только для чтения. Сначала новые.',
openAsList: 'Открыть списком',
openEntryPage: 'Открыть страницу записи журнала',
openEntryPageHint: 'На странице записи журнала перечислены все изменения той же записи, сначала новые.',
openPresetAsList: 'Открыть «{{label}}» списком',
presets: 'Пресеты',
presetOtherSource: 'Это представление читает другой источник данных.',
Expand Down
Loading
Loading