Click-next lists change page only after the page loaded

The audit log, ops events, rule submissions and session reports each
carried a copy of the same paging state. Next bumped the page number and
cursor stack before the request and kept them when it failed, so a failed
request showed "Page 3" over the rows of page 1.

useCursorPager (pure transitions in lib/cursorPager) owns rows, cursors
and the stack; the page number is derived from the stack. A move commits
only after its page loaded, and only the latest move commits. The fetch is
read through useLatest, which retires the per-page workarounds for the
token getter's changing identity.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
This commit is contained in:
Oliver Kieselbach
2026-09-28 20:47:56 +02:00
parent ffb09e60fa
commit 32f1fd4132
7 changed files with 371 additions and 242 deletions
@@ -1,12 +1,13 @@
"use client";
import { sessionUrl, deviceBlockUrl } from "@/lib/routes";
import { useCallback, useEffect, useState } from "react";
import { useEffect, useState } from "react";
import Link from "next/link";
import { api } from "@/lib/api";
import TruncatedLabel from "@/components/TruncatedLabel";
import { apiErrorText, fetchJson, nullOn404 } from "@/lib/apiClient";
import { extractContinuation } from "@/lib/paginationLink";
import type { CursorPage } from "@/lib/cursorPager";
import { useCursorPager } from "@/hooks/useCursorPager";
import { extractSessionId, buildAutoReason } from "./opsEventSessionHelpers";
import { SectionCardHeader } from "@/components/SectionCardHeader";
import type { OpsEventEntry, OpsEventListResponse } from "@/utils/wire-types.generated";
@@ -120,8 +121,6 @@ export function OpsEventsSection({
onSaveConfig,
savingConfig,
}: OpsEventsSectionProps) {
const [events, setEvents] = useState<OpsEvent[]>([]);
const [loading, setLoading] = useState(false);
const [selectedEvent, setSelectedEvent] = useState<OpsEvent | null>(null);
const [categoryFilter, setCategoryFilter] = useState<string>("");
const [searchQuery, setSearchQuery] = useState("");
@@ -129,15 +128,8 @@ export function OpsEventsSection({
const [dateFromIso, setDateFromIso] = useState<string>(defaultIsoDateFrom());
const [dateToIso, setDateToIso] = useState<string>(defaultIsoDateTo());
// Pattern B1 click-next replace state — backend pagination
const [continuation, setContinuation] = useState<string | null>(null);
const [nextLink, setNextLink] = useState<string | null>(null);
const [continuationStack, setContinuationStack] = useState<Array<string | null>>([]);
const [pageNumber, setPageNumber] = useState(1);
const fetchEvents = useCallback(async (cursor: string | null) => {
const fetchEvents = async (cursor: string | null): Promise<CursorPage<OpsEvent> | null> => {
try {
setLoading(true);
// 404 = the table does not exist yet: no events, not an error.
const data = await fetchJson<OpsEventListResponse>(
api.opsEvents.list(categoryFilter || undefined, {
@@ -148,51 +140,24 @@ export function OpsEventsSection({
}),
getAccessToken
).catch(nullOn404);
setEvents(data?.events ?? []);
setNextLink(data?.nextLink ?? null);
return { items: data?.events ?? [], nextLink: data?.nextLink ?? null };
} catch (err) {
setError(apiErrorText(err, "Failed to load ops events"));
} finally {
setLoading(false);
return null;
}
}, [categoryFilter, dateFromIso, dateToIso, getAccessToken, setError]);
};
const pager = useCursorPager(fetchEvents);
const { reset } = pager;
const events = pager.items;
const loading = !pager.settled || pager.pending !== null;
// Reset pagination + refetch whenever the filter window or category changes.
// fetchEvents is intentionally excluded from deps: getAccessToken's identity
// churns on every MSAL accounts-array refresh, which happens after each
// authenticatedFetch — leaving fetchEvents in deps causes the effect to
// re-fire after every successful page-N click, race a page-1 fetch against
// it, and snap the user back to page 1.
// Back to page 1 whenever the filter window or category changes.
useEffect(() => {
const run = async () => {
setContinuation(null);
setContinuationStack([]);
setPageNumber(1);
await fetchEvents(null);
await reset();
};
void run();
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [categoryFilter, dateFromIso, dateToIso]);
const handleNextPage = () => {
const nextCont = extractContinuation(nextLink);
if (!nextCont) return;
setContinuationStack(stack => [...stack, continuation]);
setContinuation(nextCont);
setPageNumber(n => n + 1);
fetchEvents(nextCont);
};
const handlePrevPage = () => {
if (continuationStack.length === 0) return;
const prev = continuationStack[continuationStack.length - 1];
setContinuationStack(stack => stack.slice(0, -1));
setContinuation(prev ?? null);
setPageNumber(n => Math.max(1, n - 1));
fetchEvents(prev ?? null);
};
const handleRefresh = () => fetchEvents(continuation);
}, [reset, categoryFilter, dateFromIso, dateToIso]);
// Search filter operates on the current backend page only — Pattern B1 shows
// one page at a time, so cross-page totals are intentionally not surfaced.
@@ -244,7 +209,7 @@ export function OpsEventsSection({
Configure Alerts
</Link>
<button
onClick={handleRefresh}
onClick={() => void pager.refresh()}
disabled={loading}
className="px-3 py-1.5 text-xs font-medium rounded-md border border-gray-300 dark:border-gray-600 text-gray-700 dark:text-gray-300 hover:bg-gray-100 dark:hover:bg-gray-600 disabled:opacity-40 transition-colors"
>
@@ -444,19 +409,19 @@ export function OpsEventsSection({
{/* Pagination (Pattern B1 — backend-driven) */}
<div className="flex items-center justify-between border-t border-gray-200 dark:border-gray-700 px-4 py-3 bg-gray-50 dark:bg-gray-700/50 rounded-b-md">
<span className="text-xs text-gray-500 dark:text-gray-400">
{filteredEvents.length} on page {pageNumber}{searchQuery && ` (filtered)`}{nextLink ? "" : " (last)"}
{filteredEvents.length} on page {pager.pageNumber}{searchQuery && ` (filtered)`}{pager.hasNext ? "" : " (last)"}
</span>
<div className="flex items-center gap-2">
<button
onClick={handlePrevPage}
disabled={continuationStack.length === 0 || loading}
onClick={() => void pager.prev()}
disabled={!pager.hasPrev || loading}
className="px-2.5 py-1 text-xs font-medium rounded-md border border-gray-300 dark:border-gray-600 text-gray-700 dark:text-gray-300 hover:bg-gray-100 dark:hover:bg-gray-600 disabled:opacity-40 disabled:cursor-not-allowed transition-colors"
>
Previous
</button>
<button
onClick={handleNextPage}
disabled={!nextLink || loading}
onClick={() => void pager.next()}
disabled={!pager.hasNext || loading}
className="px-2.5 py-1 text-xs font-medium rounded-md border border-gray-300 dark:border-gray-600 text-gray-700 dark:text-gray-300 hover:bg-gray-100 dark:hover:bg-gray-600 disabled:opacity-40 disabled:cursor-not-allowed transition-colors"
>
Next
@@ -5,7 +5,8 @@ import { useSearchParams } from "next/navigation";
import { api } from "@/lib/api";
import { apiErrorText, fetchJson, fetchOk, jsonBody, nullOn404 } from "@/lib/apiClient";
import TruncatedLabel from "@/components/TruncatedLabel";
import { extractContinuation } from "@/lib/paginationLink";
import type { CursorPage } from "@/lib/cursorPager";
import { useCursorPager } from "@/hooks/useCursorPager";
import { isGuid } from "@/utils/inputValidation";
import { trackEvent } from "@/lib/appInsights";
import { useCanMutatePlatform } from "@/hooks/useCanMutatePlatform";
@@ -84,18 +85,12 @@ export function RuleSubmissionsSection(props: RuleSubmissionsSectionProps) {
function RuleSubmissionsSectionInner({ getAccessToken, setError }: RuleSubmissionsSectionProps) {
// Decisions and the reseed are GA-only (GlobalAdminOnly routes); a Global Reader may look.
const canMutate = useCanMutatePlatform();
const [items, setItems] = useState<RuleSubmissionItem[]>([]);
const [loading, setLoading] = useState(true);
const [detail, setDetail] = useState<RuleSubmissionDetailResponse | null>(null);
const [detailLoading, setDetailLoading] = useState(false);
const [tenantFilterInput, setTenantFilterInput] = useState("");
const [tenantFilterApplied, setTenantFilterApplied] = useState<string | undefined>(undefined);
const [statusFilter, setStatusFilter] = useState<string>("");
const [continuation, setContinuation] = useState<string | null>(null);
const [nextLink, setNextLink] = useState<string | null>(null);
const [continuationStack, setContinuationStack] = useState<Array<string | null>>([]);
const [pageNumber, setPageNumber] = useState(1);
const openDetail = useCallback(async (submissionId: string) => {
try {
@@ -122,33 +117,29 @@ function RuleSubmissionsSectionInner({ getAccessToken, setError }: RuleSubmissio
void run();
}, [submissionIdParam, openDetail]);
const fetchPage = useCallback(async (cursor: string | null, filterTenantId: string | undefined, status: string) => {
const fetchPage = async (cursor: string | null): Promise<CursorPage<RuleSubmissionItem> | null> => {
try {
setLoading(true);
const data = await fetchJson<RuleSubmissionListResponse>(
api.ruleSubmissions.list({ tenantId: filterTenantId, status: status || undefined, pageSize: PAGE_SIZE, continuation: cursor ?? undefined }),
api.ruleSubmissions.list({ tenantId: tenantFilterApplied, status: statusFilter || undefined, pageSize: PAGE_SIZE, continuation: cursor ?? undefined }),
getAccessToken,
).catch(nullOn404);
setItems(data?.submissions ?? []);
setNextLink(data?.nextLink ?? null);
return { items: data?.submissions ?? [], nextLink: data?.nextLink ?? null };
} catch (err) {
setError(apiErrorText(err, "Failed to load rule submissions"));
} finally {
setLoading(false);
return null;
}
// getAccessToken churns on every MSAL refresh — see SessionReportsSection.
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [setError]);
};
const pager = useCursorPager(fetchPage);
const { reset, updateItems } = pager;
const items = pager.items;
const loading = !pager.settled || pager.pending !== null;
useEffect(() => {
const run = async () => {
setContinuation(null);
setContinuationStack([]);
setPageNumber(1);
await fetchPage(null, tenantFilterApplied, statusFilter);
await reset();
};
void run();
}, [tenantFilterApplied, statusFilter, fetchPage]);
}, [reset, tenantFilterApplied, statusFilter]);
const handleApplyTenantFilter = () => {
const trimmed = tenantFilterInput.trim();
@@ -160,33 +151,13 @@ function RuleSubmissionsSectionInner({ getAccessToken, setError }: RuleSubmissio
setTenantFilterApplied(trimmed || undefined);
};
const handleNextPage = () => {
const nextCont = extractContinuation(nextLink);
if (!nextCont) return;
setContinuationStack((stack) => [...stack, continuation]);
setContinuation(nextCont);
setPageNumber((n) => n + 1);
void fetchPage(nextCont, tenantFilterApplied, statusFilter);
};
const handlePrevPage = () => {
if (continuationStack.length === 0) return;
const prev = continuationStack[continuationStack.length - 1];
setContinuationStack((stack) => stack.slice(0, -1));
setContinuation(prev ?? null);
setPageNumber((n) => Math.max(1, n - 1));
void fetchPage(prev ?? null, tenantFilterApplied, statusFilter);
};
const handleRefresh = () => fetchPage(continuation, tenantFilterApplied, statusFilter);
const handleReviewed = (updated: RuleSubmissionItem) => {
setItems((prev) => prev.map((s) => (s.submissionId === updated.submissionId ? updated : s)));
updateItems((prev) => prev.map((s) => (s.submissionId === updated.submissionId ? updated : s)));
void openDetail(updated.submissionId);
};
const handleDeleted = (submissionId: string) => {
setItems((prev) => prev.filter((s) => s.submissionId !== submissionId));
updateItems((prev) => prev.filter((s) => s.submissionId !== submissionId));
setDetail(null);
};
@@ -226,7 +197,7 @@ function RuleSubmissionsSectionInner({ getAccessToken, setError }: RuleSubmissio
<option value="">All statuses</option>
{STATUS_FILTERS.map((s) => <option key={s} value={s}>{submissionStatusBadge(s).label}</option>)}
</select>
<button onClick={handleRefresh} disabled={loading} className="ml-auto px-2.5 py-1 text-xs font-medium rounded-md border border-gray-300 dark:border-gray-600 text-gray-700 dark:text-gray-300 hover:bg-gray-100 dark:hover:bg-gray-600 disabled:opacity-40 transition-colors">
<button onClick={() => void pager.refresh()} disabled={loading} className="ml-auto px-2.5 py-1 text-xs font-medium rounded-md border border-gray-300 dark:border-gray-600 text-gray-700 dark:text-gray-300 hover:bg-gray-100 dark:hover:bg-gray-600 disabled:opacity-40 transition-colors">
{loading ? "Loading..." : "Refresh"}
</button>
</div>
@@ -275,10 +246,10 @@ function RuleSubmissionsSectionInner({ getAccessToken, setError }: RuleSubmissio
</tbody>
</table>
<div className="flex items-center justify-between border-t border-gray-200 dark:border-gray-700 px-4 py-3 bg-gray-50 dark:bg-gray-700/50 rounded-b-md">
<span className="text-xs text-gray-500 dark:text-gray-400">{items.length} on page {pageNumber}{nextLink ? "" : " (last)"}</span>
<span className="text-xs text-gray-500 dark:text-gray-400">{items.length} on page {pager.pageNumber}{pager.hasNext ? "" : " (last)"}</span>
<div className="flex items-center gap-2">
<button onClick={handlePrevPage} disabled={continuationStack.length === 0 || loading} className="px-2.5 py-1 text-xs font-medium rounded-md border border-gray-300 dark:border-gray-600 text-gray-700 dark:text-gray-300 hover:bg-gray-100 dark:hover:bg-gray-600 disabled:opacity-40 disabled:cursor-not-allowed transition-colors">Previous</button>
<button onClick={handleNextPage} disabled={!nextLink || loading} className="px-2.5 py-1 text-xs font-medium rounded-md border border-gray-300 dark:border-gray-600 text-gray-700 dark:text-gray-300 hover:bg-gray-100 dark:hover:bg-gray-600 disabled:opacity-40 disabled:cursor-not-allowed transition-colors">Next</button>
<button onClick={() => void pager.prev()} disabled={!pager.hasPrev || loading} className="px-2.5 py-1 text-xs font-medium rounded-md border border-gray-300 dark:border-gray-600 text-gray-700 dark:text-gray-300 hover:bg-gray-100 dark:hover:bg-gray-600 disabled:opacity-40 disabled:cursor-not-allowed transition-colors">Previous</button>
<button onClick={() => void pager.next()} disabled={!pager.hasNext || loading} className="px-2.5 py-1 text-xs font-medium rounded-md border border-gray-300 dark:border-gray-600 text-gray-700 dark:text-gray-300 hover:bg-gray-100 dark:hover:bg-gray-600 disabled:opacity-40 disabled:cursor-not-allowed transition-colors">Next</button>
</div>
</div>
</div>
@@ -1,11 +1,12 @@
"use client";
import { Suspense, useCallback, useEffect, useState } from "react";
import { Suspense, useEffect, useState } from "react";
import { useSearchParams } from "next/navigation";
import { api } from "@/lib/api";
import { apiErrorText, fetchJson, fetchOk, jsonBody, nullOn404 } from "@/lib/apiClient";
import TruncatedLabel from "@/components/TruncatedLabel";
import { extractContinuation } from "@/lib/paginationLink";
import type { CursorPage } from "@/lib/cursorPager";
import { useCursorPager } from "@/hooks/useCursorPager";
import { isGuid } from "@/utils/inputValidation";
import { trackEvent } from "@/lib/appInsights";
import { useCanMutatePlatform } from "@/hooks/useCanMutatePlatform";
@@ -185,20 +186,36 @@ function SessionReportsSectionInner({
// Admin Note is GA-only (PATCH session-reports/{id}/note is GlobalAdminOnly). A read-only Global
// Reader may view reports + notes but not edit them.
const canMutate = useCanMutatePlatform();
const [reports, setReports] = useState<SessionReport[]>([]);
// true from the start: the fetch fires on mount, and a false initial value
// flashes the empty state for one paint.
const [loading, setLoading] = useState(true);
const [selectedReport, setSelectedReport] = useState<SessionReport | null>(null);
const [downloadingBlob, setDownloadingBlob] = useState<string | null>(null);
// Pattern B1 click-next replace state — backend pagination
const [tenantFilterInput, setTenantFilterInput] = useState("");
const [tenantFilterApplied, setTenantFilterApplied] = useState<string | undefined>(undefined);
const [continuation, setContinuation] = useState<string | null>(null);
const [nextLink, setNextLink] = useState<string | null>(null);
const [continuationStack, setContinuationStack] = useState<Array<string | null>>([]);
const [pageNumber, setPageNumber] = useState(1);
const fetchReports = async (cursor: string | null): Promise<CursorPage<SessionReport> | null> => {
try {
// 404 = table/container does not exist yet: no reports submitted so far, not an error.
const data = await fetchJson<SessionReportListResponse>(
api.reports.list({
tenantId: tenantFilterApplied,
pageSize: PAGE_SIZE,
continuation: cursor ?? undefined,
}),
getAccessToken,
).catch(nullOn404);
return { items: data?.reports ?? [], nextLink: data?.nextLink ?? null };
} catch (err) {
setError(apiErrorText(err, "Failed to load reports"));
return null;
}
};
const pager = useCursorPager(fetchReports);
const { reset, updateItems } = pager;
const reports = pager.items;
// Loading until the first fetch settles: the fetch fires on mount, and a false
// initial value flashes the empty state for one paint.
const loading = !pager.settled || pager.pending !== null;
// Deep link from GA notifications: ?reportId=… auto-opens the report modal once
// the first page has loaded (one-shot). Reports are listed newest-first, so a
@@ -254,47 +271,16 @@ function SessionReportsSectionInner({
const handleNoteSaved = (updated: SessionReport) => {
setSelectedReport(updated);
setReports(prev => prev.map(r => r.reportId === updated.reportId ? updated : r));
updateItems(prev => prev.map(r => r.reportId === updated.reportId ? updated : r));
};
const fetchReports = useCallback(async (cursor: string | null, filterTenantId: string | undefined) => {
try {
setLoading(true);
// 404 = table/container does not exist yet: no reports submitted so far, not an error.
const data = await fetchJson<SessionReportListResponse>(
api.reports.list({
tenantId: filterTenantId,
pageSize: PAGE_SIZE,
continuation: cursor ?? undefined,
}),
getAccessToken,
).catch(nullOn404);
setReports(data?.reports ?? []);
setNextLink(data?.nextLink ?? null);
} catch (err) {
setError(apiErrorText(err, "Failed to load reports"));
} finally {
setLoading(false);
}
}, [getAccessToken, setError]);
// Initial load + reload whenever the applied tenant filter changes.
// fetchReports is intentionally excluded from deps: getAccessToken's identity
// churns on every MSAL accounts-array refresh, which happens after each
// authenticatedFetch — leaving fetchReports in deps causes the effect to
// re-fire after every successful page-N click, race a page-1 fetch against
// it, and snap the user back to page 1.
useEffect(() => {
const run = async () => {
setContinuation(null);
setContinuationStack([]);
setPageNumber(1);
await fetchReports(null, tenantFilterApplied);
await reset();
};
void run();
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [tenantFilterApplied]);
}, [reset, tenantFilterApplied]);
const handleApplyTenantFilter = () => {
const trimmed = tenantFilterInput.trim();
@@ -311,26 +297,6 @@ function SessionReportsSectionInner({
setTenantFilterApplied(undefined);
};
const handleNextPage = () => {
const nextCont = extractContinuation(nextLink);
if (!nextCont) return;
setContinuationStack(stack => [...stack, continuation]);
setContinuation(nextCont);
setPageNumber(n => n + 1);
fetchReports(nextCont, tenantFilterApplied);
};
const handlePrevPage = () => {
if (continuationStack.length === 0) return;
const prev = continuationStack[continuationStack.length - 1];
setContinuationStack(stack => stack.slice(0, -1));
setContinuation(prev ?? null);
setPageNumber(n => Math.max(1, n - 1));
fetchReports(prev ?? null, tenantFilterApplied);
};
const handleRefresh = () => fetchReports(continuation, tenantFilterApplied);
return (
<div className="bg-gradient-to-br from-indigo-50 to-purple-50 dark:from-gray-800 dark:to-gray-800 border-2 border-indigo-300 dark:border-indigo-700 rounded-lg shadow-lg">
{/* Section Header */}
@@ -376,7 +342,7 @@ function SessionReportsSectionInner({
</span>
)}
<button
onClick={handleRefresh}
onClick={() => void pager.refresh()}
disabled={loading}
className="ml-auto px-2.5 py-1 text-xs font-medium rounded-md border border-gray-300 dark:border-gray-600 text-gray-700 dark:text-gray-300 hover:bg-gray-100 dark:hover:bg-gray-600 disabled:opacity-40 transition-colors"
>
@@ -460,19 +426,19 @@ function SessionReportsSectionInner({
{/* Pagination (Pattern B1 — backend-driven) */}
<div className="flex items-center justify-between border-t border-gray-200 dark:border-gray-700 px-4 py-3 bg-gray-50 dark:bg-gray-700/50 rounded-b-md">
<span className="text-xs text-gray-500 dark:text-gray-400">
{reports.length} on page {pageNumber}{nextLink ? "" : " (last)"}
{reports.length} on page {pager.pageNumber}{pager.hasNext ? "" : " (last)"}
</span>
<div className="flex items-center gap-2">
<button
onClick={handlePrevPage}
disabled={continuationStack.length === 0 || loading}
onClick={() => void pager.prev()}
disabled={!pager.hasPrev || loading}
className="px-2.5 py-1 text-xs font-medium rounded-md border border-gray-300 dark:border-gray-600 text-gray-700 dark:text-gray-300 hover:bg-gray-100 dark:hover:bg-gray-600 disabled:opacity-40 disabled:cursor-not-allowed transition-colors"
>
Previous
</button>
<button
onClick={handleNextPage}
disabled={!nextLink || loading}
onClick={() => void pager.next()}
disabled={!pager.hasNext || loading}
className="px-2.5 py-1 text-xs font-medium rounded-md border border-gray-300 dark:border-gray-600 text-gray-700 dark:text-gray-300 hover:bg-gray-100 dark:hover:bg-gray-600 disabled:opacity-40 disabled:cursor-not-allowed transition-colors"
>
Next
@@ -1,13 +1,14 @@
'use client';
import { Fragment, useCallback, useEffect, useState } from 'react';
import { Fragment, useEffect, useState } from 'react';
import { TableSkeleton } from '@/components/skeletons/TableSkeleton';
import { useAuth } from '../../contexts/AuthContext';
import { useNotifications } from '../../contexts/NotificationContext';
import { ProtectedRoute } from '../../components/ProtectedRoute';
import { scopedApi } from "@/lib/scopedApi";
import { extractContinuation } from "@/lib/paginationLink";
import type { CursorPage } from "@/lib/cursorPager";
import { useAggregatedAdminScope } from "@/hooks";
import { useCursorPager } from "@/hooks/useCursorPager";
import { TenantScopeSelector } from "@/components/TenantScopeSelector";
import { GlobalAdminBanner, globalAdminSubtitle } from "@/components/GlobalAdminBanner";
import { DocsLink } from "@/components/DocsLink";
@@ -69,9 +70,6 @@ export default function AuditPage() {
const { getAccessToken } = useAuth();
const { addNotification } = useNotifications();
const [logs, setLogs] = useState<AuditLogEntry[]>([]);
const [loading, setLoading] = useState(true);
const [refreshing, setRefreshing] = useState(false);
const [searchQuery, setSearchQuery] = useState('');
const [actionFilter, setActionFilter] = useState<ActionFilter>('DEFAULT');
const [entityTypeFilter, setEntityTypeFilter] = useState<EntityTypeFilter>('ALL');
@@ -81,12 +79,6 @@ export default function AuditPage() {
const [dateFromIso, setDateFromIso] = useState<string>(defaultIsoDateFrom());
const [dateToIso, setDateToIso] = useState<string>(defaultIsoDateTo());
// Pattern B1 click-next replace state
const [continuation, setContinuation] = useState<string | null>(null);
const [nextLink, setNextLink] = useState<string | null>(null);
const [continuationStack, setContinuationStack] = useState<Array<string | null>>([]);
const [pageNumber, setPageNumber] = useState(1);
// Cross-tenant scope: a GA/Reader gets the "All tenants" aggregate AND a per-tenant drill-down; a
// delegated ("MSP") admin gets the per-tenant dropdown only (no aggregate). selectedTenantId is the audit
// tenant filter ("" = GA aggregate). scopeKey changes on tenant/GA-mode change → drives the refetch.
@@ -101,18 +93,13 @@ export default function AuditPage() {
// an all-deletions page that the client would strip down to nothing.
const excludeDeletions = actionFilter === 'DEFAULT';
const fetchPage = useCallback(async (
nextContinuation: string | null,
isInitial: boolean,
) => {
const fetchPage = async (cursor: string | null): Promise<CursorPage<AuditLogEntry> | null> => {
try {
if (isInitial) setLoading(true); else setRefreshing(true);
const opts = {
dateFrom: dateFromIso,
dateTo: dateToIso,
pageSize: PAGE_SIZE,
continuation: nextContinuation ?? undefined,
continuation: cursor ?? undefined,
excludeDeletions,
};
// Cross-tenant: globalLogs with the selected tenant ("" → GA aggregate over all tenants; a managed
@@ -120,62 +107,30 @@ export default function AuditPage() {
// caller viewing their HOME tenant (routeGlobal false): the tenant-scoped logs.
const endpoint = scopedApi.auditLogs({ routeGlobal, selectedTenantId, effectiveTenantId }, opts);
const data = await fetchJson<AuditLogListResponse>(endpoint, getAccessToken);
setLogs(data.logs || []);
setNextLink(data.nextLink ?? null);
return { items: data.logs || [], nextLink: data.nextLink ?? null };
} catch (err) {
console.error('Error fetching audit logs:', err);
notifyApiError(addNotification, 'Backend Error', err, 'audit-fetch-error', 'Unable to load audit logs.');
} finally {
setLoading(false);
setRefreshing(false);
return null;
}
}, [dateFromIso, dateToIso, getAccessToken, routeGlobal, selectedTenantId, effectiveTenantId, excludeDeletions, addNotification]);
};
const pager = useCursorPager(fetchPage);
const { reset } = pager;
const logs = pager.items;
// Skeleton until the first load settles and on every reset; Next/Previous/Refresh keep the table on screen.
const loading = !pager.settled || pager.pending === 'reset';
const refreshing = pager.pending !== null && pager.pending !== 'reset';
// Initial / window-change fetch resets pagination state.
// fetchPage is intentionally excluded from deps: its identity churns whenever
// MSAL refreshes the `accounts` array (getAccessToken → useCallback → fetchPage),
// which happens after every authenticatedFetch. With fetchPage in deps the
// effect fires immediately after each successful Next click, resets the
// pagination state, and races a fresh page-1 fetch against the in-flight
// page-N fetch — giving the visible "Next does nothing" symptom. The
// useCallback closure is rebuilt from the same window deps the effect
// already tracks, so the latest fetchPage is invoked when the effect runs.
useEffect(() => {
// Wait until the scope's default selection settles (own tenant, or the first managed tenant for a
// delegated caller) so we don't fire a wasted request in the wrong scope. scopeKey changes on a
// tenant/GA-mode switch → refetch from page 1.
if (!scopeInitialized) return;
const run = async () => {
setContinuation(null);
setContinuationStack([]);
setPageNumber(1);
await fetchPage(null, true);
await reset();
};
void run();
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [scopeKey, scopeInitialized, dateFromIso, dateToIso, excludeDeletions]);
const handleRefresh = () => {
fetchPage(continuation, false);
};
const handleNextPage = () => {
const nextCont = extractContinuation(nextLink);
if (!nextCont) return;
setContinuationStack(stack => [...stack, continuation]);
setContinuation(nextCont);
setPageNumber(n => n + 1);
fetchPage(nextCont, false);
};
const handlePrevPage = () => {
if (continuationStack.length === 0) return;
const prev = continuationStack[continuationStack.length - 1];
setContinuationStack(stack => stack.slice(0, -1));
setContinuation(prev ?? null);
setPageNumber(n => Math.max(1, n - 1));
fetchPage(prev ?? null, false);
};
}, [reset, scopeKey, scopeInitialized, dateFromIso, dateToIso, excludeDeletions]);
// Client-side filters operate on the current page only — Pattern B1 shows one
// backend page at a time, so global counts are intentionally not surfaced.
@@ -254,8 +209,8 @@ export default function AuditPage() {
<div className="flex flex-wrap items-center gap-2">
<TenantScopeSelector scope={scope} allowAggregated />
<button
onClick={handleRefresh}
disabled={refreshing}
onClick={() => void pager.refresh()}
disabled={pager.pending !== null}
className="px-4 py-2 bg-white border border-gray-200 text-gray-700 rounded-lg hover:bg-gray-50 disabled:opacity-50 disabled:cursor-not-allowed transition-colors flex items-center space-x-2"
>
<svg className={`h-5 w-5 ${refreshing ? 'animate-spin' : ''}`} fill="none" viewBox="0 0 24 24" stroke="currentColor">
@@ -441,20 +396,20 @@ export default function AuditPage() {
{/* Pagination Controls (Pattern B1) */}
<div className="mt-4 flex items-center justify-between">
<div className="text-sm text-gray-700">
Page {pageNumber}
{nextLink ? '' : ' (last)'}
Page {pager.pageNumber}
{pager.hasNext ? '' : ' (last)'}
</div>
<div className="flex gap-2">
<button
onClick={handlePrevPage}
disabled={continuationStack.length === 0 || refreshing}
onClick={() => void pager.prev()}
disabled={!pager.hasPrev || pager.pending !== null}
className="px-4 py-2 bg-gray-200 text-gray-700 rounded-md hover:bg-gray-300 transition-colors disabled:opacity-50 disabled:cursor-not-allowed"
>
&larr; Previous
</button>
<button
onClick={handleNextPage}
disabled={!nextLink || refreshing}
onClick={() => void pager.next()}
disabled={!pager.hasNext || pager.pending !== null}
className="px-4 py-2 bg-green-600 text-white rounded-md hover:bg-green-700 transition-colors disabled:opacity-50 disabled:cursor-not-allowed"
>
Next &rarr;
@@ -0,0 +1,98 @@
"use client";
import { useCallback, useRef, useState } from "react";
import { useLatest } from "@/hooks/useLatest";
import {
afterSuccess,
cursorFor,
initialCursorPagerState,
pageNumberOf,
type CursorPage,
type CursorPagerState,
type PageMove,
} from "@/lib/cursorPager";
export interface CursorPager<T> {
/** Rows of the page on screen. */
items: T[];
/** Local edit of the rows on screen (e.g. a row updated in a detail dialog). */
updateItems: (update: (items: T[]) => T[]) => void;
pageNumber: number;
hasNext: boolean;
hasPrev: boolean;
/** The move in flight, if any. */
pending: PageMove | null;
/** The first load has finished, successfully or not. */
settled: boolean;
/** Load the first page (initial load and every filter change). */
reset: () => Promise<void>;
next: () => Promise<void>;
prev: () => Promise<void>;
refresh: () => Promise<void>;
}
/**
* Pattern B1 paging state (see lib/cursorPager). `fetchPage` loads the page at `cursor` and returns
* it, or null when the request failed — it reports the error itself; the page on screen then stays
* as it was. Only the latest move commits, so a slow response can never overwrite a newer page.
* `fetchPage` may change identity every render: the latest one is always called.
*/
export function useCursorPager<T>(
fetchPage: (cursor: string | null) => Promise<CursorPage<T> | null>,
): CursorPager<T> {
const fetchRef = useLatest(fetchPage);
// Moves read and write the ref (callbacks only, never during render); the state mirrors it for rendering.
const stateRef = useRef<CursorPagerState<T>>(initialCursorPagerState<T>());
const [state, setState] = useState<CursorPagerState<T>>(initialCursorPagerState<T>);
const [pending, setPending] = useState<PageMove | null>(null);
const [settled, setSettled] = useState(false);
const seqRef = useRef(0);
const commit = useCallback((next: CursorPagerState<T>) => {
stateRef.current = next;
setState(next);
}, []);
const run = useCallback(
async (move: PageMove) => {
const cursor = cursorFor(stateRef.current, move);
if (cursor === undefined) return;
const seq = ++seqRef.current;
setPending(move);
try {
const page = await fetchRef.current(cursor);
if (seq !== seqRef.current || page === null) return;
commit(afterSuccess(stateRef.current, move, cursor, page));
} finally {
if (seq === seqRef.current) {
setPending(null);
setSettled(true);
}
}
},
[commit, fetchRef],
);
const reset = useCallback(() => run("reset"), [run]);
const next = useCallback(() => run("next"), [run]);
const prev = useCallback(() => run("prev"), [run]);
const refresh = useCallback(() => run("refresh"), [run]);
const updateItems = useCallback(
(update: (items: T[]) => T[]) => commit({ ...stateRef.current, items: update(stateRef.current.items) }),
[commit],
);
return {
items: state.items,
updateItems,
pageNumber: pageNumberOf(state),
hasNext: cursorFor(state, "next") !== undefined,
hasPrev: cursorFor(state, "prev") !== undefined,
pending,
settled,
reset,
next,
prev,
refresh,
};
}
@@ -0,0 +1,104 @@
import { describe, expect, it } from "vitest";
import {
afterSuccess,
cursorFor,
initialCursorPagerState,
pageNumberOf,
type CursorPage,
type CursorPagerState,
type PageMove,
} from "../cursorPager";
/**
* Pattern B1 transitions behind hooks/useCursorPager. The audit page used to bump its page counter
* before the Next request and never took it back: a failed request left "Page 3" over the rows of
* page 1. Here a move only exists as (cursor to fetch) → (state once that page has loaded), so a
* failed move cannot change anything.
*/
const link = (cursor: string) => `/api/global/audit/logs?pageSize=15&continuation=${encodeURIComponent(cursor)}&dateFrom=x`;
const page = (items: string[], next: string | null): CursorPage<string> => ({ items, nextLink: next ? link(next) : null });
/** Drives a move the way the hook does: resolve the cursor, fetch, commit on success. */
function move(state: CursorPagerState<string>, m: PageMove, fetched: CursorPage<string> | null) {
const cursor = cursorFor(state, m);
if (cursor === undefined || fetched === null) return state;
return afterSuccess(state, m, cursor, fetched);
}
describe("cursorPager", () => {
it("starts empty on page 1", () => {
const s = initialCursorPagerState<string>();
expect(pageNumberOf(s)).toBe(1);
expect(s.items).toEqual([]);
expect(cursorFor(s, "next")).toBeUndefined();
expect(cursorFor(s, "prev")).toBeUndefined();
});
it("walks forward and back with the cursors the backend handed out", () => {
let s = move(initialCursorPagerState(), "reset", page(["a"], "c2"));
expect(s).toMatchObject({ items: ["a"], continuation: null, stack: [] });
expect(cursorFor(s, "next")).toBe("c2");
s = move(s, "next", page(["b"], "c3"));
expect(pageNumberOf(s)).toBe(2);
expect(s.continuation).toBe("c2");
s = move(s, "next", page(["c"], null));
expect(pageNumberOf(s)).toBe(3);
expect(cursorFor(s, "next")).toBeUndefined(); // last page
expect(cursorFor(s, "prev")).toBe("c2");
s = move(s, "prev", page(["b"], "c3"));
expect(pageNumberOf(s)).toBe(2);
expect(s.items).toEqual(["b"]);
expect(cursorFor(s, "prev")).toBeNull(); // back to the first page
s = move(s, "prev", page(["a"], "c2"));
expect(pageNumberOf(s)).toBe(1);
expect(cursorFor(s, "prev")).toBeUndefined();
});
it("keeps page, rows and cursors when the Next request fails", () => {
const onPage2 = move(move(initialCursorPagerState(), "reset", page(["a"], "c2")), "next", page(["b"], "c3"));
const afterFailure = move(onPage2, "next", null);
expect(afterFailure).toBe(onPage2);
expect(pageNumberOf(afterFailure)).toBe(2);
expect(cursorFor(afterFailure, "next")).toBe("c3"); // the same Next can be retried
});
it("refresh reloads the page on screen without moving", () => {
const onPage2 = move(move(initialCursorPagerState(), "reset", page(["a"], "c2")), "next", page(["b"], "c3"));
expect(cursorFor(onPage2, "refresh")).toBe("c2");
const refreshed = move(onPage2, "refresh", page(["b", "new"], null));
expect(pageNumberOf(refreshed)).toBe(2);
expect(refreshed.items).toEqual(["b", "new"]);
expect(refreshed.nextLink).toBeNull();
});
it("reset returns to the first page from anywhere", () => {
const onPage3 = move(
move(move(initialCursorPagerState(), "reset", page(["a"], "c2")), "next", page(["b"], "c3")),
"next",
page(["c"], null),
);
expect(cursorFor(onPage3, "reset")).toBeNull();
const reset = move(onPage3, "reset", page(["a2"], "c9"));
expect(pageNumberOf(reset)).toBe(1);
expect(reset).toMatchObject({ items: ["a2"], continuation: null, stack: [] });
});
it("reads the continuation out of an absolute nextLink too", () => {
const s = afterSuccess(initialCursorPagerState<string>(), "reset", null, {
items: [],
nextLink: "https://api.example.test/api/ops-events?continuation=abc%2B%2F%3D&pageSize=50",
});
expect(cursorFor(s, "next")).toBe("abc+/=");
});
});
@@ -0,0 +1,70 @@
import { extractContinuation } from "@/lib/paginationLink";
/**
* Click-next paging over a backend that hands out opaque continuation tokens (Pattern B1): one
* page on screen, Next follows its nextLink, Previous returns to the cursor it came from. Pure
* transitions, driven by hooks/useCursorPager.
*
* A move changes nothing until its page has loaded: a failed Next keeps the page, its number and
* its cursors, so the counter can never run ahead of the rows on screen.
*/
/** One backend page as the caller's fetch returns it. */
export interface CursorPage<T> {
items: T[];
nextLink: string | null;
}
export type PageMove = "reset" | "next" | "prev" | "refresh";
export interface CursorPagerState<T> {
items: T[];
/** Cursor of the page on screen; null = first page. */
continuation: string | null;
/** Cursors of the pages before it, oldest first. */
stack: Array<string | null>;
/** nextLink of the page on screen; null = last page. */
nextLink: string | null;
}
export function initialCursorPagerState<T>(): CursorPagerState<T> {
return { items: [], continuation: null, stack: [], nextLink: null };
}
/** The cursor a move has to fetch, or undefined when the move is not possible. */
export function cursorFor<T>(state: CursorPagerState<T>, move: PageMove): string | null | undefined {
switch (move) {
case "reset":
return null;
case "refresh":
return state.continuation;
case "next":
return extractContinuation(state.nextLink) ?? undefined;
case "prev":
return state.stack.length > 0 ? state.stack[state.stack.length - 1] : undefined;
}
}
/** The state once the page a move fetched at `cursor` has loaded. */
export function afterSuccess<T>(
state: CursorPagerState<T>,
move: PageMove,
cursor: string | null,
page: CursorPage<T>,
): CursorPagerState<T> {
const loaded = { items: page.items, nextLink: page.nextLink };
switch (move) {
case "reset":
return { ...loaded, continuation: null, stack: [] };
case "refresh":
return { ...loaded, continuation: state.continuation, stack: state.stack };
case "next":
return { ...loaded, continuation: cursor, stack: [...state.stack, state.continuation] };
case "prev":
return { ...loaded, continuation: cursor, stack: state.stack.slice(0, -1) };
}
}
export function pageNumberOf<T>(state: CursorPagerState<T>): number {
return state.stack.length + 1;
}