refactor(admin): share the audit log loading and paging between desktop and phone

Both audit log panels now read one useAuditLog hook for the first page, refresh, load more and the row labels. The desktop table and the phone cards keep their own markup.
This commit is contained in:
Maurice
2026-10-09 10:00:32 +02:00
parent 40fa8c6fe6
commit a52179f685
4 changed files with 243 additions and 180 deletions
+4 -89
View File
@@ -1,21 +1,9 @@
import React, { useCallback, useEffect, useState } from 'react'
import { adminApi } from '../../api/client'
import React from 'react'
import { useTranslation } from '../../i18n'
import { RefreshCw, ClipboardList, Loader2 } from 'lucide-react'
import { fs } from '../shared/DialogShell'
import { SETTINGS_BUTTON, SettingsCard, SettingsHint, StatusPill } from '../Settings/settingsKit'
interface AuditEntry {
id: number
created_at: string
user_id: number | null
username: string | null
user_email: string | null
action: string
resource: string | null
details: Record<string, unknown> | null
ip: string | null
}
import { useAuditLog } from './useAuditLog'
interface AuditLogPanelProps {
serverTimezone?: string
@@ -26,81 +14,8 @@ const TD = 'px-3.5 py-2.5 align-top'
const CODE = 'font-geist tabular-nums'
export default function AuditLogPanel({ serverTimezone }: AuditLogPanelProps): React.ReactElement {
const { t, locale } = useTranslation()
const [entries, setEntries] = useState<AuditEntry[]>([])
const [total, setTotal] = useState(0)
const [offset, setOffset] = useState(0)
const [loading, setLoading] = useState(true)
const limit = 100
const loadFirstPage = useCallback(async () => {
setLoading(true)
try {
const data = await adminApi.auditLog({ limit, offset: 0 }) as {
entries: AuditEntry[]
total: number
}
setEntries(data.entries || [])
setTotal(data.total ?? 0)
setOffset(0)
} catch {
setEntries([])
setTotal(0)
setOffset(0)
} finally {
setLoading(false)
}
}, [])
const loadMore = useCallback(async () => {
const nextOffset = offset + limit
setLoading(true)
try {
const data = await adminApi.auditLog({ limit, offset: nextOffset }) as {
entries: AuditEntry[]
total: number
}
setEntries((prev) => [...prev, ...(data.entries || [])])
setTotal(data.total ?? 0)
setOffset(nextOffset)
} catch {
/* keep existing */
} finally {
setLoading(false)
}
}, [offset])
useEffect(() => {
loadFirstPage()
}, [loadFirstPage])
const fmtTime = (iso: string) => {
try {
return new Date(iso.endsWith('Z') ? iso : iso + 'Z').toLocaleString(locale, {
dateStyle: 'short',
timeStyle: 'medium',
timeZone: serverTimezone || undefined,
})
} catch {
return iso
}
}
const fmtDetails = (d: Record<string, unknown> | null) => {
if (!d || Object.keys(d).length === 0) return '—'
try {
return JSON.stringify(d)
} catch {
return '—'
}
}
const userLabel = (e: AuditEntry) => {
if (e.username) return e.username
if (e.user_email) return e.user_email
if (e.user_id != null) return `#${e.user_id}`
return '—'
}
const { t } = useTranslation()
const { entries, total, loading, loadFirstPage, loadMore, fmtTime, fmtDetails, userLabel } = useAuditLog(serverTimezone)
let body: React.ReactNode
if (loading && entries.length === 0) {
@@ -0,0 +1,120 @@
import { act, renderHook, waitFor } from '@testing-library/react';
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
import { adminApi } from '../../api/client';
import { type AuditEntry, auditUserLabel, fmtAuditDetails, useAuditLog } from './useAuditLog';
// FE-HOOK-AUDIT-001 to FE-HOOK-AUDIT-008
vi.mock('../../i18n', () => ({ useTranslation: () => ({ t: (k: string) => k, locale: 'en-US' }) }));
function entry(id: number, over: Partial<AuditEntry> = {}): AuditEntry {
return {
id,
created_at: '2025-06-01T10:30:00Z',
user_id: 5,
username: 'alice',
user_email: 'alice@example.com',
action: 'trip.create',
resource: '/trips/42',
details: null,
ip: '127.0.0.1',
...over,
};
}
const auditLog = () => vi.mocked(adminApi.auditLog);
beforeEach(() => {
vi.spyOn(adminApi, 'auditLog').mockResolvedValue({ entries: [entry(1)], total: 150 });
});
afterEach(() => vi.restoreAllMocks());
describe('useAuditLog', () => {
it('FE-HOOK-AUDIT-001: loads the first page of 100 on mount', async () => {
const { result } = renderHook(() => useAuditLog());
expect(result.current.loading).toBe(true);
await waitFor(() => expect(result.current.loading).toBe(false));
expect(auditLog()).toHaveBeenCalledWith({ limit: 100, offset: 0 });
expect(result.current.entries.map((e) => e.id)).toEqual([1]);
expect(result.current.total).toBe(150);
});
it('FE-HOOK-AUDIT-002: load more appends the next page and advances the offset each time', async () => {
const { result } = renderHook(() => useAuditLog());
await waitFor(() => expect(result.current.loading).toBe(false));
auditLog().mockResolvedValueOnce({ entries: [entry(2)], total: 150 });
await act(() => result.current.loadMore());
expect(auditLog()).toHaveBeenLastCalledWith({ limit: 100, offset: 100 });
expect(result.current.entries.map((e) => e.id)).toEqual([1, 2]);
auditLog().mockResolvedValueOnce({ entries: [entry(3)], total: 150 });
await act(() => result.current.loadMore());
expect(auditLog()).toHaveBeenLastCalledWith({ limit: 100, offset: 200 });
});
it('FE-HOOK-AUDIT-003: a failed load more keeps what is already listed and the offset', async () => {
const { result } = renderHook(() => useAuditLog());
await waitFor(() => expect(result.current.loading).toBe(false));
auditLog().mockRejectedValueOnce(new Error('down'));
await act(() => result.current.loadMore());
expect(result.current.entries.map((e) => e.id)).toEqual([1]);
expect(result.current.loading).toBe(false);
auditLog().mockResolvedValueOnce({ entries: [], total: 150 });
await act(() => result.current.loadMore());
expect(auditLog()).toHaveBeenLastCalledWith({ limit: 100, offset: 100 });
});
it('FE-HOOK-AUDIT-004: refresh starts over from offset 0 after paging', async () => {
const { result } = renderHook(() => useAuditLog());
await waitFor(() => expect(result.current.loading).toBe(false));
auditLog().mockResolvedValueOnce({ entries: [entry(2)], total: 150 });
await act(() => result.current.loadMore());
auditLog().mockResolvedValueOnce({ entries: [entry(9)], total: 1 });
await act(() => result.current.loadFirstPage());
expect(result.current.entries.map((e) => e.id)).toEqual([9]);
auditLog().mockResolvedValueOnce({ entries: [], total: 1 });
await act(() => result.current.loadMore());
expect(auditLog()).toHaveBeenLastCalledWith({ limit: 100, offset: 100 });
});
it('FE-HOOK-AUDIT-005: a failed first page empties the list and the total', async () => {
auditLog().mockRejectedValue(new Error('down'));
const { result } = renderHook(() => useAuditLog());
await waitFor(() => expect(result.current.loading).toBe(false));
expect(result.current.entries).toEqual([]);
expect(result.current.total).toBe(0);
});
it('FE-HOOK-AUDIT-006: a missing entries array or total reads as empty', async () => {
auditLog().mockResolvedValue({});
const { result } = renderHook(() => useAuditLog());
await waitFor(() => expect(result.current.loading).toBe(false));
expect(result.current.entries).toEqual([]);
expect(result.current.total).toBe(0);
});
it('FE-HOOK-AUDIT-007: fmtTime reads a bare timestamp as UTC and honours the server timezone', async () => {
const { result } = renderHook(() => useAuditLog('Asia/Tokyo'));
await waitFor(() => expect(result.current.loading).toBe(false));
const expected = new Date('2025-06-01T10:30:00Z').toLocaleString('en-US', {
dateStyle: 'short',
timeStyle: 'medium',
timeZone: 'Asia/Tokyo',
});
expect(result.current.fmtTime('2025-06-01T10:30:00')).toBe(expected);
expect(result.current.fmtTime('2025-06-01T10:30:00Z')).toBe(expected);
});
it('FE-HOOK-AUDIT-008: details and user labels fall back the way both shells show them', () => {
expect(fmtAuditDetails(null)).toBe('—');
expect(fmtAuditDetails({})).toBe('—');
expect(fmtAuditDetails({ a: 1 })).toBe('{"a":1}');
const circular: Record<string, unknown> = {};
circular.self = circular;
expect(fmtAuditDetails(circular)).toBe('—');
expect(auditUserLabel(entry(1))).toBe('alice');
expect(auditUserLabel(entry(1, { username: null }))).toBe('alice@example.com');
expect(auditUserLabel(entry(1, { username: null, user_email: null }))).toBe('#5');
expect(auditUserLabel(entry(1, { username: null, user_email: null, user_id: null }))).toBe('—');
});
});
+115
View File
@@ -0,0 +1,115 @@
import { useCallback, useEffect, useRef, useState } from 'react';
import { adminApi } from '../../api/client';
import { useTranslation } from '../../i18n';
export interface AuditEntry {
id: number;
created_at: string;
user_id: number | null;
username: string | null;
user_email: string | null;
action: string;
resource: string | null;
details: Record<string, unknown> | null;
ip: string | null;
}
const PAGE_SIZE = 100;
/** A details object as one line of JSON, or a dash when there is nothing to show. */
export function fmtAuditDetails(d: Record<string, unknown> | null): string {
if (!d || Object.keys(d).length === 0) return '—';
try {
return JSON.stringify(d);
} catch {
return '—';
}
}
/** Who did it: the username, else the email, else the bare user id. */
export function auditUserLabel(e: AuditEntry): string {
if (e.username) return e.username;
if (e.user_email) return e.user_email;
if (e.user_id != null) return `#${e.user_id}`;
return '—';
}
/**
* The audit log behind both admin shells: the first page on mount, a refresh that starts
* over and a "load more" that appends the next page. The desktop table and the phone
* cards render their own markup over it.
*/
export function useAuditLog(serverTimezone?: string) {
const { locale } = useTranslation();
const [entries, setEntries] = useState<AuditEntry[]>([]);
const [total, setTotal] = useState(0);
const [loading, setLoading] = useState(true);
// Never rendered, only carried between pages, so loadMore cannot capture a page number
// that is already spent.
const offsetRef = useRef(0);
const loadFirstPage = useCallback(async () => {
setLoading(true);
try {
const data = (await adminApi.auditLog({ limit: PAGE_SIZE, offset: 0 })) as {
entries: AuditEntry[];
total: number;
};
setEntries(data.entries || []);
setTotal(data.total ?? 0);
offsetRef.current = 0;
} catch {
setEntries([]);
setTotal(0);
offsetRef.current = 0;
} finally {
setLoading(false);
}
}, []);
const loadMore = useCallback(async () => {
const nextOffset = offsetRef.current + PAGE_SIZE;
setLoading(true);
try {
const data = (await adminApi.auditLog({ limit: PAGE_SIZE, offset: nextOffset })) as {
entries: AuditEntry[];
total: number;
};
setEntries((prev) => [...prev, ...(data.entries || [])]);
setTotal(data.total ?? 0);
offsetRef.current = nextOffset;
} catch {
/* keep existing */
} finally {
setLoading(false);
}
}, []);
useEffect(() => {
loadFirstPage();
}, [loadFirstPage]);
const fmtTime = (iso: string) => {
try {
return new Date(iso.endsWith('Z') ? iso : iso + 'Z').toLocaleString(locale, {
dateStyle: 'short',
timeStyle: 'medium',
timeZone: serverTimezone || undefined,
});
} catch {
return iso;
}
};
return {
entries,
total,
loading,
loadFirstPage,
loadMore,
fmtTime,
fmtDetails: fmtAuditDetails,
userLabel: auditUserLabel,
};
}
@@ -1,103 +1,16 @@
import React, { useCallback, useEffect, useRef, useState } from 'react'
import React from 'react'
import { RefreshCw, ClipboardList } from 'lucide-react'
import { adminApi } from '../../../api/client'
import { useAuditLog } from '../../../components/Admin/useAuditLog'
import { useTranslation } from '../../../i18n'
import { MAdminButton } from './MAdminUi'
interface AuditEntry {
id: number
created_at: string
user_id: number | null
username: string | null
user_email: string | null
action: string
resource: string | null
details: Record<string, unknown> | null
ip: string | null
}
interface AuditLogPanelProps {
serverTimezone?: string
}
export default function MAdminAuditLogPanel({ serverTimezone }: AuditLogPanelProps): React.ReactElement {
const { t, locale } = useTranslation()
const [entries, setEntries] = useState<AuditEntry[]>([])
const [total, setTotal] = useState(0)
const [loading, setLoading] = useState(true)
// Never rendered, only carried between pages — a ref keeps loadMore from
// capturing a page number that is already spent.
const offsetRef = useRef(0)
const limit = 100
const loadFirstPage = useCallback(async () => {
setLoading(true)
try {
const data = (await adminApi.auditLog({ limit, offset: 0 })) as {
entries: AuditEntry[]
total: number
}
setEntries(data.entries || [])
setTotal(data.total ?? 0)
offsetRef.current = 0
} catch {
setEntries([])
setTotal(0)
offsetRef.current = 0
} finally {
setLoading(false)
}
}, [])
const loadMore = useCallback(async () => {
const nextOffset = offsetRef.current + limit
setLoading(true)
try {
const data = (await adminApi.auditLog({ limit, offset: nextOffset })) as {
entries: AuditEntry[]
total: number
}
setEntries((prev) => [...prev, ...(data.entries || [])])
setTotal(data.total ?? 0)
offsetRef.current = nextOffset
} catch {
/* keep existing */
} finally {
setLoading(false)
}
}, [])
useEffect(() => {
loadFirstPage()
}, [loadFirstPage])
const fmtTime = (iso: string) => {
try {
return new Date(iso.endsWith('Z') ? iso : iso + 'Z').toLocaleString(locale, {
dateStyle: 'short',
timeStyle: 'medium',
timeZone: serverTimezone || undefined,
})
} catch {
return iso
}
}
const fmtDetails = (d: Record<string, unknown> | null) => {
if (!d || Object.keys(d).length === 0) return '—'
try {
return JSON.stringify(d)
} catch {
return '—'
}
}
const userLabel = (e: AuditEntry) => {
if (e.username) return e.username
if (e.user_email) return e.user_email
if (e.user_id != null) return `#${e.user_id}`
return '—'
}
const { t } = useTranslation()
const { entries, total, loading, loadFirstPage, loadMore, fmtTime, fmtDetails, userLabel } = useAuditLog(serverTimezone)
return (
<div className="space-y-3">