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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
8 changes: 8 additions & 0 deletions frontend/src/__tests__/api-history.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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([]);

Expand Down
187 changes: 187 additions & 0 deletions frontend/src/__tests__/history-load-more.test.ts
Original file line number Diff line number Diff line change
@@ -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> = {}): HistoryResponse {
return { summary: { total_purchases: 99 }, purchases, ...extra };
}

function setupDOM(): void {
document.body.innerHTML = `
<input type="date" id="history-start" value="2026-09-01"><input type="date" id="history-end" value="2026-10-23">
<p id="purchases-truncation-notice" hidden></p><p id="history-truncation-notice" hidden></p>
<div id="purchases-approval-queue"></div><div id="history-summary"></div><div id="history-list"></div>
<div id="history-load-more" hidden><button type="button" id="history-load-more-btn">Load more</button></div>`;
}

const loadMoreWrap = (): HTMLElement => document.getElementById('history-load-more')!;
const historyIds = (): string[] => Array.from(document.querySelectorAll<HTMLElement>('#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<HistoryResponse>(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');
});
});
1 change: 1 addition & 0 deletions frontend/src/api/history.ts
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,7 @@ export async function getHistory(filters: HistoryFilters = {}): Promise<Purchase
if (filters.provider) params.set('provider', filters.provider);
if (filters.planId) params.set('plan_id', filters.planId);
if (filters.account_ids && filters.account_ids.length > 0) params.set('account_ids', filters.account_ids.join(','));
if (filters.cursor) params.set('cursor', filters.cursor);

const queryString = params.toString();
return apiRequest<PurchaseHistory[]>(`/history${queryString ? '?' + queryString : ''}`);
Expand Down
2 changes: 2 additions & 0 deletions frontend/src/api/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
77 changes: 77 additions & 0 deletions frontend/src/history-paging.ts
Original file line number Diff line number Diff line change
@@ -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=<next_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<string>();
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');
}
Loading
Loading