mirror of
https://github.com/okieselbach/AutopilotMonitor.git
synced 2026-10-24 04:32:32 +00:00
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:
@@ -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"
|
||||
>
|
||||
← 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 →
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
Reference in New Issue
Block a user