From 6dabc99b2e3d2271175b9d557cf96fa1389e4320 Mon Sep 17 00:00:00 2001 From: Astrid Gealer Date: Fri, 23 Oct 2026 23:11:21 +0000 Subject: [PATCH 1/2] feat(frontend): Load more on the History view, Approval Queue from all loaded pages The History view could only show the first page of rows and told the user to narrow the date range when the API capped the result. The History API is gaining keyset paging (has_more / next_cursor, #674), so the UI now offers a "Load more" button under the Purchase History table when the response reports has_more. Each click repeats the first page's filters with cursor=next_cursor, appends the rows, and re-renders both the history table and the Approval Queue from every loaded row. Rows repeated on a later page (an approval swept to expired between loads) replace their stale copy by purchase_id. The summary cards keep the first page's totals. The "results capped" banner is driven by has_more: hidden when Load more can reach the older rows, reworded to quote the actionable cap when truncated without more pages, and unchanged on servers that do not page yet, so this is a no-op until the backend ships #674. Refs #676 Co-Authored-By: Claude Opus 5.5 --- frontend/src/__tests__/api-history.test.ts | 8 ++ frontend/src/api/history.ts | 1 + frontend/src/api/types.ts | 2 + frontend/src/history-paging.ts | 77 ++++++++++++++++++ frontend/src/history.ts | 91 ++++++++++++++++++++-- frontend/src/index.html | 4 + frontend/src/styles/tables.css | 11 +++ frontend/src/types.ts | 12 +++ 8 files changed, 200 insertions(+), 6 deletions(-) create mode 100644 frontend/src/history-paging.ts diff --git a/frontend/src/__tests__/api-history.test.ts b/frontend/src/__tests__/api-history.test.ts index d73a4498c..3a667ee59 100644 --- a/frontend/src/__tests__/api-history.test.ts +++ b/frontend/src/__tests__/api-history.test.ts @@ -85,6 +85,14 @@ describe('API History Module', () => { ); }); + test('passes the paging cursor after the filters (issue #676)', async () => { + (apiRequest as jest.Mock).mockResolvedValue({}); + + await getHistory({ provider: 'aws', cursor: 'djEuYS4x' }); + + expect(apiRequest).toHaveBeenCalledWith('/history?provider=aws&cursor=djEuYS4x'); + }); + test('handles empty filters object', async () => { (apiRequest as jest.Mock).mockResolvedValue([]); diff --git a/frontend/src/api/history.ts b/frontend/src/api/history.ts index 9e5ec37d4..b8668344c 100644 --- a/frontend/src/api/history.ts +++ b/frontend/src/api/history.ts @@ -21,6 +21,7 @@ export async function getHistory(filters: HistoryFilters = {}): Promise 0) params.set('account_ids', filters.account_ids.join(',')); + if (filters.cursor) params.set('cursor', filters.cursor); const queryString = params.toString(); return apiRequest(`/history${queryString ? '?' + queryString : ''}`); diff --git a/frontend/src/api/types.ts b/frontend/src/api/types.ts index f3ad8bc6f..0c205a59f 100644 --- a/frontend/src/api/types.ts +++ b/frontend/src/api/types.ts @@ -294,6 +294,8 @@ export interface HistoryFilters { provider?: Provider; planId?: string; account_ids?: string[]; + /** next_cursor from the previous page, to fetch the rows after it. */ + cursor?: string; } // Config types diff --git a/frontend/src/history-paging.ts b/frontend/src/history-paging.ts new file mode 100644 index 000000000..da8d8b4a2 --- /dev/null +++ b/frontend/src/history-paging.ts @@ -0,0 +1,77 @@ +/** + * "Load more" paging for the Purchase History view (issue #676). + * + * The History API pages completed purchases and terminal executions with a + * keyset cursor (issue #674): a response with `has_more: true` carries a + * `next_cursor`, and repeating the request with `cursor=` returns + * the rows after it. Pending and in-flight executions are not paged; the first + * page lists all of them. Servers that predate paging omit `has_more`, so the + * control below stays hidden and the old "results are capped" banner remains. + * + * history.ts owns the fetch and the rendering; this module holds the pure + * helpers (cursor, row merge, banner text) and the button's DOM state. + */ + +import type { HistoryPurchase, HistoryResponse } from './types'; + +/** The cursor for the next page, or null when no further page can be fetched. */ +export function nextHistoryCursor(data: HistoryResponse): string | null { + if (data.has_more !== true) return null; + return typeof data.next_cursor === 'string' && data.next_cursor !== '' ? data.next_cursor : null; +} + +/** + * Append a newly fetched page to the rows already loaded. + * + * Rows are matched by purchase_id and the newest response wins: an approval + * listed as pending on page 1 can be swept to expired before a later page is + * read, and that later page then lists the same execution again in its new + * state. Keeping both would show one execution twice with conflicting + * statuses. Rows without a purchase_id cannot be matched and are kept as is. + */ +export function mergeHistoryPages( + loaded: HistoryPurchase[], + page: HistoryPurchase[], +): HistoryPurchase[] { + const incoming = new Set(); + for (const p of page) { + if (p.purchase_id) incoming.add(p.purchase_id); + } + const kept = loaded.filter(p => !p.purchase_id || !incoming.has(p.purchase_id)); + return [...kept, ...page]; +} + +/** + * Text of the "results capped" banner for a History response, or '' to hide + * it. + * + * - No `has_more` field: the server cannot page, so keep the #630 wording that + * tells the user to narrow the window. + * - `has_more` with a cursor: older rows are one click away, so the Load more + * button replaces the banner. + * - Truncated without more pages: rows paging cannot reach, i.e. the + * actionable class hit its cap or a listing failed. + */ +export function historyTruncationMessage(data: HistoryResponse): string { + if (!data.truncated) return ''; + if (nextHistoryCursor(data)) return ''; + if (data.has_more === false) { + return `Some rows could not be listed: pending and in-progress executions are capped at ${data.actionable_executions_limit ?? '?'}, or part of the history failed to load. Pending approvals and totals may be incomplete. Narrow the date range or filters, or reload, to see them.`; + } + return `Results are capped at the most recent ${data.limit ?? '?'} completed purchases and ${data.executions_limit ?? '?'} pending, failed or other non-completed executions. Older rows are not shown, so pending approvals and totals may be incomplete. Narrow the date range or filters to see them.`; +} + +/** + * Show or hide the Load more control below the history table. `busy` disables + * the button while a page is in flight so a double click cannot fetch the + * same cursor twice. + */ +export function renderLoadMoreControl(visible: boolean, busy = false): void { + const wrap = document.getElementById('history-load-more'); + if (wrap) wrap.hidden = !visible; + const btn = document.getElementById('history-load-more-btn') as HTMLButtonElement | null; + if (!btn) return; + btn.disabled = busy; + btn.textContent = busy ? 'Loading…' : 'Load more'; + btn.setAttribute('aria-busy', busy ? 'true' : 'false'); +} diff --git a/frontend/src/history.ts b/frontend/src/history.ts index b4c183d44..de6031900 100644 --- a/frontend/src/history.ts +++ b/frontend/src/history.ts @@ -11,6 +11,12 @@ import { switchTab } from './navigation'; import { confirmDialog } from './confirmDialog'; import { approvalDetailsUnavailableMessage, buildApprovalDetailsBody } from './approval-details'; import { showToast } from './toast'; +import { + historyTruncationMessage, + mergeHistoryPages, + nextHistoryCursor, + renderLoadMoreControl, +} from './history-paging'; import { getCurrentUser } from './state'; import { canAccess } from './permissions'; import { showSkeletonRows, teardownSkeleton } from './lib/skeleton'; @@ -47,6 +53,15 @@ const busyHistoryExecutions = new Set(); let historyMutationEpoch = 0; let activeStatusFilter: StatusFilter = 'all'; +// Load more paging (issue #676). historyLoadGeneration is bumped by every +// first-page load, so a Load more response that lands after the user changed +// a filter (or after a mutation reload) is dropped instead of being appended +// to the new result set. historyPaging holds the filters of the first page and +// the cursor of the last page loaded; null when there is nothing more to load. +let historyLoadGeneration = 0; +let historyPaging: { filters: api.HistoryFilters; cursor: string } | null = null; +let loadMoreInFlight = false; + // _fourEyesMode mirrors GlobalConfig.require_different_approver (issue #1005). // Gates the inline Approve button so a creator can't approve their own pending // purchase when dual-control is on. @@ -167,6 +182,7 @@ export function applyExecutionDeepLink(): boolean { * + Load History button — those stay here. */ export function setupHistoryHandlers(): void { + document.getElementById('history-load-more-btn')?.addEventListener('click', () => void loadMoreHistory()); state.subscribeProvider(() => void loadHistory()); state.subscribeAccount(() => void loadHistory()); // Re-render both tables when the amortize toggle flips (issue #1112). @@ -257,6 +273,7 @@ export function initHistoryDateRange(): void { */ export async function viewPlanHistory(planId: string): Promise { const requestEpoch = historyMutationEpoch; + resetHistoryPaging(); // skipDefaultLoad: the tab's own unscoped 7-day fetch would land after the // plan-scoped one below and overwrite it, and its date-range seeding is // exactly what the doc comment above says not to do here. @@ -266,8 +283,10 @@ export async function viewPlanHistory(planId: string): Promise { if (!canAccess('view', 'purchases')) return; try { + const filters: api.HistoryFilters = { planId }; + const generation = historyLoadGeneration; const [data] = await Promise.all([ - api.getHistory({ planId }) as unknown as Promise, + api.getHistory(filters) as unknown as Promise, refreshFourEyesMode(), ]); if (requestEpoch !== historyMutationEpoch) return; @@ -277,6 +296,7 @@ export async function viewPlanHistory(planId: string): Promise { renderApprovalQueue(purchases); renderHistoryList(purchases); snapDateInputsToPurchases(purchases); + updateHistoryPaging(generation, filters, data); } catch (error) { if (requestEpoch !== historyMutationEpoch) return; console.error('Failed to load plan history:', error); @@ -321,6 +341,8 @@ function snapDateInputsToPurchases(purchases: HistoryPurchase[]): void { */ export async function loadHistory(): Promise { const requestEpoch = historyMutationEpoch; + resetHistoryPaging(); + const generation = historyLoadGeneration; // Issue #344 T3: skeleton rows for the purchase-history table. 8 // rows matches the typical first-page row count so the skeleton // doesn't shrink dramatically when real data arrives. Column count @@ -370,6 +392,7 @@ export async function loadHistory(): Promise { const purchases = data.purchases || []; renderApprovalQueue(purchases); renderHistoryList(purchases); + updateHistoryPaging(generation, filters, data); } catch (error) { if (requestEpoch !== historyMutationEpoch) return; console.error('Failed to load history:', error); @@ -388,20 +411,76 @@ export async function loadHistory(): Promise { } } +/** + * Forget the previous result set's cursor and hide Load more. Called at the + * start of every first-page load; bumping the generation also invalidates a + * Load more request still in flight for the previous result set. + */ +function resetHistoryPaging(): void { + historyLoadGeneration++; + historyPaging = null; + renderLoadMoreControl(false); +} + +/** Remember where the next page starts and show Load more when there is one. */ +function updateHistoryPaging(generation: number, filters: api.HistoryFilters, data: HistoryResponse): void { + if (generation !== historyLoadGeneration) return; + const cursor = nextHistoryCursor(data); + historyPaging = cursor ? { filters, cursor } : null; + renderLoadMoreControl(historyPaging !== null); +} + +/** + * Fetch the page after the last one loaded, with the first page's filters, + * and append it (issue #676). Both tables re-render from every loaded row, so + * the Approval Queue is built from all loaded pages. The summary cards keep + * the first page's totals: a later page's summary covers only that page on + * servers without whole-scope totals (#675). + */ +export async function loadMoreHistory(): Promise { + const paging = historyPaging; + if (!paging || loadMoreInFlight) return; + const generation = historyLoadGeneration; + const requestEpoch = historyMutationEpoch; + loadMoreInFlight = true; + renderLoadMoreControl(true, true); + try { + const data = await (api.getHistory({ ...paging.filters, cursor: paging.cursor }) as unknown as Promise); + if (generation !== historyLoadGeneration) return; + // A mutation landed mid-flight and its reload will replace the rows; keep + // the cursor so the button still works if that reload has not started. + if (requestEpoch !== historyMutationEpoch) { + renderLoadMoreControl(true); + return; + } + const purchases = mergeHistoryPages(lastPurchases, data.purchases || []); + renderTruncationNotices(data); + renderApprovalQueue(purchases); + renderHistoryList(purchases); + updateHistoryPaging(generation, paging.filters, data); + } catch (error) { + if (generation !== historyLoadGeneration) return; + console.error('Failed to load more history:', error); + showToast({ message: `Failed to load more history: ${(error as Error).message}`, kind: 'error' }); + renderLoadMoreControl(true); + } finally { + loadMoreInFlight = false; + } +} + /** * Show or hide the "results capped" banner above the approval queue and the * history table. The API sets `truncated` when a fetch hit its row cap, in - * which case pending approvals and the summary totals can under-report. + * which case pending approvals and the summary totals can under-report. When + * the API can page (#674), Load more replaces the banner for rows it can reach. */ function renderTruncationNotices(data: HistoryResponse): void { - const message = data.truncated - ? `Results are capped at the most recent ${data.limit ?? '?'} completed purchases and ${data.executions_limit ?? '?'} pending, failed or other non-completed executions. Older rows are not shown, so pending approvals and totals may be incomplete. Narrow the date range or filters to see them.` - : ''; + const message = historyTruncationMessage(data); for (const id of ['purchases-truncation-notice', 'history-truncation-notice']) { const el = document.getElementById(id); if (!el) continue; el.textContent = message; - el.hidden = !data.truncated; + el.hidden = message === ''; } } diff --git a/frontend/src/index.html b/frontend/src/index.html index ebbbdd24c..cba2fcc04 100644 --- a/frontend/src/index.html +++ b/frontend/src/index.html @@ -253,6 +253,10 @@

Purchase History

+ + diff --git a/frontend/src/styles/tables.css b/frontend/src/styles/tables.css index 291bcf07c..ae551e727 100644 --- a/frontend/src/styles/tables.css +++ b/frontend/src/styles/tables.css @@ -64,6 +64,17 @@ tr.history-row-highlight:hover { margin-bottom: 1.5rem; } +/* Load more under the Purchase History table (issue #676) */ +.history-load-more { + display: flex; + justify-content: center; + margin-top: 1rem; +} + +.history-load-more[hidden] { + display: none; +} + /* Planned Purchases Section */ #planned-purchases-header { margin-top: 2rem; diff --git a/frontend/src/types.ts b/frontend/src/types.ts index cdb35630c..909eb1cd5 100644 --- a/frontend/src/types.ts +++ b/frontend/src/types.ts @@ -204,6 +204,18 @@ export interface HistoryResponse { limit?: number; /** Fixed row cap on non-completed executions. */ executions_limit?: number; + /** + * Row cap on actionable executions (pending, approved, running, ...). + * They are not paged: the first page lists them all, up to this cap. + */ + actionable_executions_limit?: number; + /** + * True when older rows follow this page (issue #674). Absent on servers + * that predate paging, which the UI treats as "cannot page". + */ + has_more?: boolean; + /** Opaque position to pass back as `cursor`; set only with has_more. */ + next_cursor?: string; } export interface HistorySummary { From febc80048781c0c71b7a84b92f62c38c89353643 Mon Sep 17 00:00:00 2001 From: Astrid Gealer Date: Fri, 23 Oct 2026 23:11:21 +0000 Subject: [PATCH 2/2] test(frontend): cover History Load more paging Jest cases with two pages: cursor and filters on the second request, row append, Approval Queue built from both pages, newest copy wins on a repeated purchase_id, a reload discarding an in-flight page, a failed page keeping the button for retry, and the banner text for each has_more shape. A Playwright spec clicks Load more against a mocked two-page /api/history. Refs #676 Co-Authored-By: Claude Opus 5.5 --- .../src/__tests__/history-load-more.test.ts | 187 ++++++++++++++++++ frontend/tests-e2e/history-load-more.spec.ts | 45 +++++ 2 files changed, 232 insertions(+) create mode 100644 frontend/src/__tests__/history-load-more.test.ts create mode 100644 frontend/tests-e2e/history-load-more.spec.ts diff --git a/frontend/src/__tests__/history-load-more.test.ts b/frontend/src/__tests__/history-load-more.test.ts new file mode 100644 index 000000000..581937578 --- /dev/null +++ b/frontend/src/__tests__/history-load-more.test.ts @@ -0,0 +1,187 @@ +/** + * Load more on the History view (issue #676): the API pages with has_more / + * next_cursor (#674); the UI appends each page and rebuilds both tables, so + * the Approval Queue covers every loaded page. + */ + +import { loadHistory, loadMoreHistory, setupHistoryHandlers } from '../history'; +import { historyTruncationMessage, mergeHistoryPages } from '../history-paging'; + +jest.mock('../api', () => ({ + getHistory: jest.fn(), + getConfig: jest.fn().mockResolvedValue({ global: {} }), + listAccounts: jest.fn().mockResolvedValue([]), + getDeploymentInfo: jest.fn().mockResolvedValue({}), +})); +jest.mock('../navigation', () => ({ switchTab: jest.fn() })); +jest.mock('../utils', () => ({ + formatCurrency: jest.fn((val) => `$${val || 0}`), + formatDate: jest.fn((val) => (val ? new Date(val).toLocaleDateString() : '')), + formatTerm: jest.fn((years) => (years == null ? '' : `${years} Year${years === 1 ? '' : 's'}`)), + escapeHtml: jest.fn((str) => str || ''), + escapeHtmlAttr: jest.fn((str: string | null | undefined) => str || ''), + amortizedMonthly: jest.fn(() => 0), +})); +jest.mock('../confirmDialog', () => ({ confirmDialog: jest.fn() })); +jest.mock('../toast', () => ({ showToast: jest.fn() })); +jest.mock('../state', () => ({ + getCurrentUser: jest.fn(), + getCurrentProvider: jest.fn().mockReturnValue('aws'), + getCurrentAccountIDs: jest.fn().mockReturnValue([]), + subscribeProvider: jest.fn().mockReturnValue(() => {}), + subscribeAccount: jest.fn().mockReturnValue(() => {}), + getAmortizeUpfront: jest.fn().mockReturnValue(false), + setAmortizeUpfront: jest.fn(), + subscribeAmortizeUpfront: jest.fn().mockReturnValue(() => {}), + getPurchaseHistoryColumnFilters: jest.fn().mockReturnValue({}), + setPurchaseHistoryColumnFilter: jest.fn(), + getApprovalQueueColumnFilters: jest.fn().mockReturnValue({}), + setApprovalQueueColumnFilter: jest.fn(), +})); +jest.mock('../recommendations', () => ({ getAccountName: jest.fn((id: string) => id) })); + +import * as api from '../api'; +import { showToast } from '../toast'; +import type { HistoryPurchase, HistoryResponse } from '../types'; + +const getHistory = api.getHistory as jest.Mock; + +function row(id: string, status = 'completed', ts = '2026-10-01T00:00:00Z'): HistoryPurchase { + return { + purchase_id: id, timestamp: ts, provider: 'aws', service: 'ec2', resource_type: 'm5.large', + region: 'us-east-1', count: 1, term: 1, upfront_cost: 0, monthly_cost: 1, estimated_savings: 1, status, + }; +} + +function page(purchases: HistoryPurchase[], extra: Partial = {}): HistoryResponse { + return { summary: { total_purchases: 99 }, purchases, ...extra }; +} + +function setupDOM(): void { + document.body.innerHTML = ` + + +
+ `; +} + +const loadMoreWrap = (): HTMLElement => document.getElementById('history-load-more')!; +const historyIds = (): string[] => Array.from(document.querySelectorAll('#history-list tr[data-execution-id]')) + .map(tr => tr.dataset['executionId'] || ''); +const queueText = (): string => document.getElementById('purchases-approval-queue')!.textContent || ''; + +describe('History Load more (issue #676)', () => { + beforeEach(() => { + setupDOM(); + jest.clearAllMocks(); + }); + + test('stays hidden when the server does not page', async () => { + getHistory.mockResolvedValueOnce(page([row('a')])); + await loadHistory(); + expect(loadMoreWrap().hidden).toBe(true); + }); + + test('appends the second page with the same filters plus the cursor, then hides', async () => { + getHistory.mockResolvedValueOnce(page([row('a'), row('b')], { truncated: true, has_more: true, next_cursor: 'c1' })); + await loadHistory(); + expect(loadMoreWrap().hidden).toBe(false); + expect(document.getElementById('history-truncation-notice')!.hidden).toBe(true); + + getHistory.mockResolvedValueOnce(page([row('c', 'failed')], { summary: { total_purchases: 1 }, has_more: false })); + await loadMoreHistory(); + + const firstFilters = getHistory.mock.calls[0][0]; + expect(getHistory.mock.calls[1][0]).toEqual({ ...firstFilters, cursor: 'c1' }); + expect(firstFilters.provider).toBe('aws'); + expect(historyIds()).toEqual(['a', 'b', 'c']); + expect(loadMoreWrap().hidden).toBe(true); + // Summary cards keep the first page's totals. + expect(document.getElementById('history-summary')!.textContent).toContain('99'); + }); + + test('builds the Approval Queue from every loaded page', async () => { + getHistory.mockResolvedValueOnce(page([row('p1', 'pending')], { has_more: true, next_cursor: 'c1' })); + await loadHistory(); + getHistory.mockResolvedValueOnce(page([row('p2', 'notified')], { has_more: false })); + await loadMoreHistory(); + const queueRows = document.querySelectorAll('#purchases-approval-queue tbody tr'); + expect(queueRows).toHaveLength(2); + }); + + test('a row repeated on a later page replaces the stale copy', async () => { + getHistory.mockResolvedValueOnce(page([row('x', 'pending'), row('a')], { has_more: true, next_cursor: 'c1' })); + await loadHistory(); + expect(queueText()).not.toContain('No pending approvals'); + getHistory.mockResolvedValueOnce(page([row('x', 'expired')], { has_more: false })); + await loadMoreHistory(); + expect(historyIds()).toEqual(['a', 'x']); + expect(queueText()).toContain('No pending approvals'); + }); + + test('a reload drops the cursor and discards a Load more response still in flight', async () => { + getHistory.mockResolvedValueOnce(page([row('a')], { has_more: true, next_cursor: 'c1' })); + await loadHistory(); + let resolveMore!: (v: HistoryResponse) => void; + getHistory.mockReturnValueOnce(new Promise(r => { resolveMore = r; })); + const more = loadMoreHistory(); + const btn = document.getElementById('history-load-more-btn') as HTMLButtonElement; + expect(btn.disabled).toBe(true); + // A second click while the page is in flight does not fetch again. + await loadMoreHistory(); + expect(getHistory).toHaveBeenCalledTimes(2); + + getHistory.mockResolvedValueOnce(page([row('n')])); + await loadHistory(); + resolveMore(page([row('stale')], { has_more: false })); + await more; + expect(historyIds()).toEqual(['n']); + expect(loadMoreWrap().hidden).toBe(true); + }); + + test('a failed page keeps the rows and the button for a retry', async () => { + getHistory.mockResolvedValueOnce(page([row('a')], { has_more: true, next_cursor: 'c1' })); + await loadHistory(); + jest.spyOn(console, 'error').mockImplementation(() => {}); + getHistory.mockRejectedValueOnce(new Error('boom')); + await loadMoreHistory(); + expect(showToast).toHaveBeenCalledWith(expect.objectContaining({ kind: 'error', message: expect.stringContaining('boom') })); + expect(historyIds()).toEqual(['a']); + const btn = document.getElementById('history-load-more-btn') as HTMLButtonElement; + expect(loadMoreWrap().hidden).toBe(false); + expect(btn.disabled).toBe(false); + + getHistory.mockResolvedValueOnce(page([row('b')], { has_more: false })); + await loadMoreHistory(); + expect(getHistory.mock.calls[2][0].cursor).toBe('c1'); + expect(historyIds()).toEqual(['a', 'b']); + }); + + test('the button is wired by setupHistoryHandlers', async () => { + setupHistoryHandlers(); + getHistory.mockResolvedValueOnce(page([row('a')], { has_more: true, next_cursor: 'c1' })); + await loadHistory(); + getHistory.mockResolvedValueOnce(page([row('b')], { has_more: false })); + document.getElementById('history-load-more-btn')!.click(); + await new Promise(r => setTimeout(r, 0)); + expect(getHistory.mock.calls[1][0].cursor).toBe('c1'); + }); +}); + +describe('history-paging helpers', () => { + test('mergeHistoryPages keeps rows without a purchase_id', () => { + const anon = { ...row('z'), purchase_id: undefined }; + expect(mergeHistoryPages([anon, row('a')], [anon, row('a', 'failed')]).map(p => p.status)) + .toEqual(['completed', 'completed', 'failed']); + }); + + test('banner text follows has_more', () => { + expect(historyTruncationMessage({ truncated: false })).toBe(''); + expect(historyTruncationMessage({ truncated: true, limit: 100, executions_limit: 100 })).toContain('Narrow the date range'); + expect(historyTruncationMessage({ truncated: true, has_more: true, next_cursor: 'c' })).toBe(''); + expect(historyTruncationMessage({ truncated: true, has_more: false, actionable_executions_limit: 1000 })) + .toContain('capped at 1000'); + // has_more without a usable cursor cannot page: fall back to the old text. + expect(historyTruncationMessage({ truncated: true, has_more: true, limit: 5 })).toContain('most recent 5'); + }); +}); diff --git a/frontend/tests-e2e/history-load-more.spec.ts b/frontend/tests-e2e/history-load-more.spec.ts new file mode 100644 index 000000000..17ea9ca39 --- /dev/null +++ b/frontend/tests-e2e/history-load-more.spec.ts @@ -0,0 +1,45 @@ +import { test, expect } from '@playwright/test'; +import { mockApi, seedAuth } from './fixtures/recs'; + +// Issue #676: when GET /api/history reports has_more, Load more fetches the +// next page with the cursor and appends it under the first page's rows. +function row(id: string, status: string, daysAgo: number) { + return { + purchase_id: `${id}-0000-0000-0000-000000000000`, timestamp: new Date(Date.now() - daysAgo * 86400000).toISOString(), + provider: 'aws', service: 'ec2', resource_type: 'm5.large', region: 'us-east-1', count: 1, term: 1, + upfront_cost: 0, monthly_cost: 10, estimated_savings: 2, status, account_id: 'acct-001', + }; +} + +test('Load more appends the next history page and hides once has_more is false', async ({ page }) => { + const errors: string[] = []; + page.on('pageerror', error => errors.push(error.message)); + await seedAuth(page); + await mockApi(page); + const cursors: (string | null)[] = []; + await page.route('**/api/history**', async route => { + const url = new URL(route.request().url()); + if (url.pathname !== '/api/history') return route.fallback(); + const cursor = url.searchParams.get('cursor'); + cursors.push(cursor); + await route.fulfill({ json: cursor === 'page-2' + ? { summary: { total_purchases: 1 }, purchases: [row('cccccccc', 'failed', 3)], truncated: false, has_more: false } + : { summary: { total_purchases: 3 }, purchases: [row('aaaaaaaa', 'pending', 1), row('bbbbbbbb', 'completed', 2)], + truncated: true, limit: 2, executions_limit: 2, has_more: true, next_cursor: 'page-2' } }); + }); + + await page.goto('/purchases/history'); + const rows = page.locator('#history-list tbody tr'); + await expect(rows).toHaveCount(2); + const button = page.locator('#history-load-more-btn'); + await expect(button).toBeVisible(); + await expect(page.locator('#history-truncation-notice')).toBeHidden(); + + await button.click(); + await expect(rows).toHaveCount(3); + await expect(page.locator('#history-list tbody tr[data-execution-id^="cccccccc"]')).toHaveCount(1); + await expect(button).toBeHidden(); + expect(cursors.filter(c => c !== null)).toEqual(['page-2']); + await expect(page.locator('#history-summary')).toContainText('3'); + expect(errors).toEqual([]); +});