From 4af46331e0be8b36cfbedfd4a49302c58a555030 Mon Sep 17 00:00:00 2001 From: Cristian Magherusan-Stanciu Date: Mon, 19 Oct 2026 18:11:59 +0200 Subject: [PATCH 01/12] fix(frontend): include capacity in recommendation column ids Derive the saved-filter allowlist from COLUMN_DEFS so the capacity filter survives a reload. Closes #278 Co-Authored-By: Claude Opus 5.5 --- frontend/src/__tests__/recommendations.test.ts | 8 ++++++++ frontend/src/recommendations.ts | 6 +----- 2 files changed, 9 insertions(+), 5 deletions(-) diff --git a/frontend/src/__tests__/recommendations.test.ts b/frontend/src/__tests__/recommendations.test.ts index ebf066a7f..b336f2d2a 100644 --- a/frontend/src/__tests__/recommendations.test.ts +++ b/frontend/src/__tests__/recommendations.test.ts @@ -7752,6 +7752,14 @@ describe('Column filters localStorage persistence (issue #163)', () => { expect(Object.keys(result)).toHaveLength(0); }); + test('restores a filter for every column in COLUMN_DEFS, including capacity', () => { + const filters: Record = {}; + for (const c of COLUMN_DEFS) filters[c.key] = { kind: 'set', values: ['x'] }; + localStorageMock.getItem.mockReturnValue(JSON.stringify({ schemaVersion: 1, filters })); + const result = loadColumnFilters(); + expect(Object.keys(result).sort()).toEqual(COLUMN_DEFS.map((c) => c.key).sort()); + }); + test('restores a categorical (set) filter correctly', () => { localStorageMock.getItem.mockReturnValue( JSON.stringify({ diff --git a/frontend/src/recommendations.ts b/frontend/src/recommendations.ts index 6f44b1be9..8b2587f27 100644 --- a/frontend/src/recommendations.ts +++ b/frontend/src/recommendations.ts @@ -3690,11 +3690,7 @@ const COLUMN_FILTERS_SCHEMA_VERSION = 1; // Full set of valid column ids, used as an allowlist when loading from // localStorage so stale or hand-edited keys are silently dropped. -const VALID_COLUMN_IDS = new Set([ - 'provider', 'account', 'service', 'resource_type', 'region', - 'count', 'term', 'payment', 'savings', 'upfront_cost', - 'monthly_cost', 'on_demand_monthly', 'effective_savings_pct', -]); +const VALID_COLUMN_IDS = new Set(COLUMN_DEFS.map((c) => c.key)); interface ColumnFiltersSchema { schemaVersion: number; From bc61ea1976acb5e5c60003331254b504ba571473 Mon Sep 17 00:00:00 2001 From: Cristian Magherusan-Stanciu Date: Mon, 19 Oct 2026 18:12:35 +0200 Subject: [PATCH 02/12] fix(frontend): keep loaded total when sorting or selecting recommendations The sort and selection handlers re-rendered with the already-filtered list, collapsing "Showing X of Y" to "Showing X of X". Pass loadedRecs like the chevron handlers do. Closes #287 Co-Authored-By: Claude Opus 5.5 --- .../src/__tests__/recommendations.test.ts | 23 +++++++++++++++++++ frontend/src/recommendations.ts | 6 ++--- 2 files changed, 26 insertions(+), 3 deletions(-) diff --git a/frontend/src/__tests__/recommendations.test.ts b/frontend/src/__tests__/recommendations.test.ts index b336f2d2a..a6e8e9a01 100644 --- a/frontend/src/__tests__/recommendations.test.ts +++ b/frontend/src/__tests__/recommendations.test.ts @@ -666,6 +666,29 @@ describe('Recommendations Module', () => { (state.getSelectedRecommendationIDs as jest.Mock).mockReturnValue(new Set()); }); + test('re-render after a sort click keeps the loaded total in "Showing X of Y" (closes #287)', async () => { + const allRecs = [ + { id: 'r1', provider: 'aws', cloud_account_id: 'a1', service: 'ec2', resource_type: 't3.medium', region: 'us-east-1', count: 1, term: 1, payment_option: 'no-upfront', savings: 100, upfront_cost: 0 }, + { id: 'r2', provider: 'aws', cloud_account_id: 'a1', service: 'rds', resource_type: 'db.t3', region: 'us-east-1', count: 1, term: 1, payment_option: 'no-upfront', savings: 200, upfront_cost: 0 }, + ]; + (api.getRecommendations as jest.Mock).mockResolvedValue({ summary: {}, recommendations: allRecs, regions: [] }); + (state.getRecommendations as jest.Mock).mockReturnValue(allRecs); + (state.getVisibleRecommendations as jest.Mock).mockReturnValue([allRecs[0]]); + (state.getRecommendationsColumnFilters as jest.Mock).mockReturnValue({ service: { kind: 'set', values: ['ec2'] } }); + (state.getSelectedRecommendationIDs as jest.Mock).mockReturnValue(new Set()); + + await loadRecommendations(); + const liveText = (): string => + document.querySelector('.recommendations-filter-live')?.textContent ?? ''; + expect(liveText()).toMatch(/Showing 1 of 2 recommendations$/); + + document.querySelector('th.sortable')?.click(); + expect(liveText()).toMatch(/Showing 1 of 2 recommendations$/); + + (state.getRecommendationsColumnFilters as jest.Mock).mockReturnValue({}); + (state.getVisibleRecommendations as jest.Mock).mockReturnValue([]); + }); + test('renders recommendations list', async () => { (api.getRecommendations as jest.Mock).mockResolvedValue({ summary: {}, diff --git a/frontend/src/recommendations.ts b/frontend/src/recommendations.ts index 8b2587f27..314ae4b77 100644 --- a/frontend/src/recommendations.ts +++ b/frontend/src/recommendations.ts @@ -4868,7 +4868,7 @@ function renderRecommendationsList(loadedRecs: LocalRecommendation[]): void { // common UX surprise of "I sorted by Account, refreshed, now I'm back // at Savings desc". writeSortToUrl({ column: col as state.RecommendationsSortColumn, direction }); - renderRecommendationsList(recommendations); + renderRecommendationsList(loadedRecs); }; th.addEventListener('click', onActivate); th.addEventListener('keydown', (e) => { @@ -4909,7 +4909,7 @@ function renderRecommendationsList(loadedRecs: LocalRecommendation[]): void { } else { state.clearSelectedRecommendations(); } - renderRecommendationsList(recommendations); + renderRecommendationsList(loadedRecs); }); } @@ -4945,7 +4945,7 @@ function renderRecommendationsList(loadedRecs: LocalRecommendation[]): void { } else { state.removeSelectedRecommendation(id); } - renderRecommendationsList(recommendations); + renderRecommendationsList(loadedRecs); }); }); From 8a745197c1221feaf1c6f495292f2d9f55c1bc36 Mon Sep 17 00:00:00 2001 From: Cristian Magherusan-Stanciu Date: Mon, 19 Oct 2026 18:14:08 +0200 Subject: [PATCH 03/12] fix(frontend): reset execute mode and button label when the purchase modal closes Reset the mode and label on open and on close of both the single and fan-out purchase modals so the button never promises Execute Purchase Now while the radio is back on Send for Approval. Closes #754 Co-Authored-By: Claude Opus 5.5 --- .../src/__tests__/execute-mode-toggle.test.ts | 26 +++++++++++++++++++ frontend/src/recommendations.ts | 15 ++++++++++- 2 files changed, 40 insertions(+), 1 deletion(-) diff --git a/frontend/src/__tests__/execute-mode-toggle.test.ts b/frontend/src/__tests__/execute-mode-toggle.test.ts index dc1a1ab11..13adf1b20 100644 --- a/frontend/src/__tests__/execute-mode-toggle.test.ts +++ b/frontend/src/__tests__/execute-mode-toggle.test.ts @@ -8,6 +8,7 @@ */ import { openPurchaseModal, getExecuteMode } from '../recommendations'; +import { closeModal } from '../modal'; jest.mock('../api', () => ({ getRecommendations: jest.fn().mockResolvedValue({ summary: {}, recommendations: [], regions: [] }), @@ -188,4 +189,29 @@ describe('Execute-mode toggle (issue #289)', () => { const btn = document.getElementById('execute-purchase-btn') as HTMLButtonElement; expect(btn.textContent).toBe('Send for Approval'); }); + test('closing the modal resets execute mode and the button label (closes #754)', async () => { + mockUser('admin'); + const { modal } = setupPurchaseModal(); + await openPurchaseModal([minimalRec as never]); + + const directRadio = document.getElementById('execute-mode-direct') as HTMLInputElement; + directRadio.click(); + directRadio.dispatchEvent(new Event('change', { bubbles: true })); + const btn = document.getElementById('execute-purchase-btn') as HTMLButtonElement; + expect(btn.textContent).toBe('Execute Purchase Now'); + + closeModal(modal); + + expect(getExecuteMode()).toBe(''); + expect(btn.textContent).toBe('Send for Approval'); + }); + + test('reopening resets a stale Execute Purchase Now label (closes #754)', async () => { + mockUser('admin'); + setupPurchaseModal(); + const btn = document.getElementById('execute-purchase-btn') as HTMLButtonElement; + btn.textContent = 'Execute Purchase Now'; + await openPurchaseModal([minimalRec as never]); + expect(btn.textContent).toBe('Send for Approval'); + }); }); diff --git a/frontend/src/recommendations.ts b/frontend/src/recommendations.ts index 314ae4b77..cca365eb7 100644 --- a/frontend/src/recommendations.ts +++ b/frontend/src/recommendations.ts @@ -4280,6 +4280,16 @@ export function clearExecuteMode(): void { currentExecuteMode = ''; } +// Resets the mode and the submit button label together so the label never +// promises a different action than the radio (issue #754). +function resetExecuteModeAndLabel(): void { + currentExecuteMode = ''; + const executeBtn = document.getElementById('execute-purchase-btn') as HTMLButtonElement | null; + if (executeBtn && executeBtn.dataset['submitting'] !== 'true') { + executeBtn.textContent = 'Send for Approval'; + } +} + // resolveBucketPaymentSeed picks the default Payment value for a // bucket per issue #111 sub-option (ii): // - When all recs in the bucket share one non-empty cloud_account_id @@ -4374,6 +4384,7 @@ async function openFanOutModal( toolbar: BulkPurchaseToolbarState, ): Promise { const openToken = ++purchaseModalOpenToken; + resetExecuteModeAndLabel(); // Pre-fetch service-overrides for every distinct account referenced by // any rec in any bucket; overridesByAccount seeds the bucket-level // payment (issue #111). One fetch per distinct accountID; cached for the lifetime of this @@ -4465,6 +4476,7 @@ async function openFanOutModal( onClose: () => { clearPurchaseModalRecommendations(); clearFanOutBuckets(); + resetExecuteModeAndLabel(); }, }); } @@ -5171,7 +5183,7 @@ export async function openPurchaseModal(recommendations: LocalRecommendation[], // Reset the execute mode for this modal session so a prior direct-execute // choice does not carry over to a freshly opened modal (issue #289). - currentExecuteMode = ''; + resetExecuteModeAndLabel(); // Execute-mode toggle (issue #289): shown only to sessions with // execute-any:purchases or execute-own:purchases. Everyone else sees only @@ -5336,6 +5348,7 @@ export async function openPurchaseModal(recommendations: LocalRecommendation[], onClose: () => { clearPurchaseModalRecommendations(); clearFanOutBuckets(); + resetExecuteModeAndLabel(); }, }); } From a2c9142d0b9721be2536b4f6a7e36672345b475d Mon Sep 17 00:00:00 2001 From: Cristian Magherusan-Stanciu Date: Mon, 19 Oct 2026 18:14:49 +0200 Subject: [PATCH 04/12] fix(frontend): print marketplace discount as an integer percent Derive the 0.95 multiplier from an integer percent constant so the consent copy no longer shows 5.000000000000004%. Closes #283 Co-Authored-By: Claude Opus 5.5 --- .../history-marketplace-sell-button.test.ts | 27 +++++++++++++++++++ frontend/src/history.ts | 7 ++--- 2 files changed, 31 insertions(+), 3 deletions(-) diff --git a/frontend/src/__tests__/history-marketplace-sell-button.test.ts b/frontend/src/__tests__/history-marketplace-sell-button.test.ts index 53cf1c1c8..6730be160 100644 --- a/frontend/src/__tests__/history-marketplace-sell-button.test.ts +++ b/frontend/src/__tests__/history-marketplace-sell-button.test.ts @@ -320,6 +320,33 @@ describe('Marketplace consent modal residual proration (issue #808 follow-up)', expect(text).not.toContain('$0'); }); + test('consent note prints the discount as an integer percent (closes #283)', async () => { + (api.getHistory as jest.Mock).mockResolvedValue({ + summary: {}, + purchases: [ + makeRow({ + purchase_id: 'ri-note', + offering_class: 'standard', + term: 3, + timestamp: SIX_MONTHS_AGO, + upfront_cost: 3600, + monthly_cost: 0, + }), + ], + }); + await loadHistory(); + document + .getElementById('history-list')! + .querySelector('.history-marketplace-sell-btn[data-marketplace-sell-id="ri-note"]')! + .click(); + await Promise.resolve(); + await Promise.resolve(); + + const arg = (confirmDialog as jest.Mock).mock.calls[0][0] as { body: HTMLElement }; + const note = arg.body.querySelector('.marketplace-pricing-note')?.textContent ?? ''; + expect(note).toContain('prices the listing at 5% below remaining value'); + }); + // Regression for the consent-modal math bug (PR follow-up to #808): the // modal used to compute totalValue = upfrontRemaining + monthly * // remainingMonths on the ROW TOTAL, which both (a) includes recurring cost diff --git a/frontend/src/history.ts b/frontend/src/history.ts index a573d82fe..ed1d697cf 100644 --- a/frontend/src/history.ts +++ b/frontend/src/history.ts @@ -33,8 +33,9 @@ const VALID_PROVIDERS: api.Provider[] = ['aws', 'azure', 'gcp']; const AWS_MARKETPLACE_FEE_PERCENT = 12; // awsMarketplaceNetFactor: fraction of list price the seller receives after the fee. const AWS_MARKETPLACE_NET_FACTOR = 1 - AWS_MARKETPLACE_FEE_PERCENT / 100; -// awsMarketplaceBuyerDiscountFactor: applied to residual RI value to compute the default list price. -const AWS_MARKETPLACE_BUYER_DISCOUNT = 0.95; +// awsMarketplaceBuyerDiscountPercent: discount off residual RI value for the default list price. +const AWS_MARKETPLACE_BUYER_DISCOUNT_PERCENT = 5; +const AWS_MARKETPLACE_BUYER_DISCOUNT = 1 - AWS_MARKETPLACE_BUYER_DISCOUNT_PERCENT / 100; type StatusFilter = 'all' | 'pending' | 'completed' | 'failed' | 'expired' | 'cancelled'; @@ -1595,7 +1596,7 @@ function wireRowActionHandlers(container: HTMLElement): void { const noteEl = document.createElement('p'); noteEl.className = 'marketplace-pricing-note'; - noteEl.textContent = `AWS charges a ${AWS_MARKETPLACE_FEE_PERCENT}% transaction fee on proceeds. The default schedule prices the listing at ${(1 - AWS_MARKETPLACE_BUYER_DISCOUNT) * 100}% below remaining value. You can adjust pricing by contacting your administrator or modifying the schedule via the API. This action cannot be undone without cancelling the listing.`; + noteEl.textContent = `AWS charges a ${AWS_MARKETPLACE_FEE_PERCENT}% transaction fee on proceeds. The default schedule prices the listing at ${AWS_MARKETPLACE_BUYER_DISCOUNT_PERCENT}% below remaining value. You can adjust pricing by contacting your administrator or modifying the schedule via the API. This action cannot be undone without cancelling the listing.`; bodyEl.appendChild(noteEl); const ok = await confirmDialog({ From ef7e81af8365d011cf87622661d04041616c29e5 Mon Sep 17 00:00:00 2001 From: Cristian Magherusan-Stanciu Date: Mon, 19 Oct 2026 18:15:32 +0200 Subject: [PATCH 05/12] fix(frontend): run amortize toggle and deep link on an empty history window The empty-list early return skipped mountAmortizeCheckbox and applyExecutionDeepLink, so a stale execution link never got its toast or hash cleanup and the amortize toggle disappeared. Closes #282 Co-Authored-By: Claude Opus 5.5 --- .../history-truncation-notice.test.ts | 28 +++++++++++++++++++ frontend/src/history.ts | 2 ++ 2 files changed, 30 insertions(+) diff --git a/frontend/src/__tests__/history-truncation-notice.test.ts b/frontend/src/__tests__/history-truncation-notice.test.ts index 55eb3a0ce..c892d75a6 100644 --- a/frontend/src/__tests__/history-truncation-notice.test.ts +++ b/frontend/src/__tests__/history-truncation-notice.test.ts @@ -43,6 +43,7 @@ jest.mock('../state', () => ({ jest.mock('../recommendations', () => ({ getAccountName: jest.fn((id: string) => id) })); import * as api from '../api'; +import { showToast } from '../toast'; function setupDOM(): void { while (document.body.firstChild) document.body.removeChild(document.body.firstChild); @@ -110,3 +111,30 @@ describe('History truncation banner (issue #248)', () => { } }); }); + +describe('History empty window (issue #282)', () => { + beforeEach(() => { + setupDOM(); + const controls = document.createElement('div'); + controls.id = 'history-controls'; + document.body.appendChild(controls); + jest.clearAllMocks(); + }); + afterEach(() => { + window.history.replaceState({}, '', window.location.pathname); + }); + + test('mounts the amortize checkbox even when the list is empty', async () => { + (api.getHistory as jest.Mock).mockResolvedValue({ summary: {}, purchases: [] }); + await loadHistory(); + expect(document.getElementById('history-amortize-checkbox')).not.toBeNull(); + }); + + test('toasts and clears the deep link when the execution is outside an empty window', async () => { + window.history.replaceState({}, '', '/purchases#history?execution=exec-123'); + (api.getHistory as jest.Mock).mockResolvedValue({ summary: {}, purchases: [] }); + await loadHistory(); + expect(showToast).toHaveBeenCalledWith(expect.objectContaining({ message: expect.stringContaining("isn't in the current view") })); + expect(window.location.hash).not.toContain('execution='); + }); +}); diff --git a/frontend/src/history.ts b/frontend/src/history.ts index ed1d697cf..43140bb57 100644 --- a/frontend/src/history.ts +++ b/frontend/src/history.ts @@ -1191,6 +1191,8 @@ function renderHistoryList(purchases: HistoryPurchase[]): void { if (!purchases || purchases.length === 0) { container.innerHTML = '

No purchase history found for the selected period.

'; + mountAmortizeCheckbox('history-controls', 'history-amortize-checkbox'); + applyExecutionDeepLink(); return; } From 2d460e594e030f46e73575892b58b3b730184b4c Mon Sep 17 00:00:00 2001 From: Cristian Magherusan-Stanciu Date: Mon, 19 Oct 2026 18:17:06 +0200 Subject: [PATCH 06/12] fix(frontend): refresh all RI exchange sections after a successful exchange The post-exchange refresh called loadConvertibleRIs() unscoped, dropping the account chip scope and leaving stale reshape recommendations. loadRIExchange() re-resolves the scope and reloads all three sections. Closes #285 Co-Authored-By: Claude Opus 5.5 --- frontend/src/__tests__/riexchange.test.ts | 26 +++++++++++++++++++++++ frontend/src/riexchange.ts | 3 +-- 2 files changed, 27 insertions(+), 2 deletions(-) diff --git a/frontend/src/__tests__/riexchange.test.ts b/frontend/src/__tests__/riexchange.test.ts index fc42efb2d..bb06b30eb 100644 --- a/frontend/src/__tests__/riexchange.test.ts +++ b/frontend/src/__tests__/riexchange.test.ts @@ -1058,6 +1058,32 @@ describe('RI Exchange global filter scoping (issue #871)', () => { expect(api.listConvertibleRIs).toHaveBeenCalledWith('123456789012'); }); + it('keeps the account scope and refreshes reshape recs after a successful exchange (closes #285)', async () => { + stateMod().getCurrentAccountIDs.mockReturnValue(['uuid-1']); + (api.listTargetOfferings as jest.Mock).mockResolvedValue([]); + const modal = createModal(); + (api.getExchangeQuote as jest.Mock).mockResolvedValueOnce({ + IsValidExchange: true, ValidationFailureReason: '', CurrencyCode: 'USD', PaymentDueRaw: '12.50', + SourceHourlyPriceRaw: '', SourceRemainingUpfrontRaw: '', SourceRemainingTotalRaw: '', + TargetHourlyPriceRaw: '', TargetRemainingUpfrontRaw: '', TargetRemainingTotalRaw: '', Region: 'us-west-2', + }); + (api.executeExchange as jest.Mock).mockResolvedValueOnce({ exchange_id: 'exch-1', quote: {} }); + openExchangeModal('ri-abc', 3, 'm5.large', [ + { instance_type: 'm5.large', offering_id: '4b2293b4-5fbc-4017-9c75-d5a9d3aa8c91', effective_monthly_cost: 42.5 }, + ]); + Array.from(modal.querySelectorAll('button')).find((b) => b.textContent === 'Get Quote')?.click(); + await new Promise((resolve) => setTimeout(resolve, 0)); + Array.from(modal.querySelectorAll('button')).find((b) => b.textContent === 'Execute Exchange')?.click(); + document.querySelector('.modal-confirm-actions .btn-destructive')?.click(); + await new Promise((resolve) => setTimeout(resolve, 0)); + await new Promise((resolve) => setTimeout(resolve, 0)); + + expect(api.executeExchange).toHaveBeenCalledTimes(1); + expect(api.listConvertibleRIs).toHaveBeenCalledWith('123456789012'); + expect(api.listConvertibleRIs).not.toHaveBeenCalledWith(undefined); + expect(api.getReshapeRecommendations).toHaveBeenCalled(); + }); + it('forwards the Azure subscription GUID (external_id), not the account UUID', async () => { stateMod().getCurrentProvider.mockReturnValue('azure'); stateMod().getCurrentAccountIDs.mockReturnValue(['uuid-az']); diff --git a/frontend/src/riexchange.ts b/frontend/src/riexchange.ts index c4ad1f52f..e6b12a1cb 100644 --- a/frontend/src/riexchange.ts +++ b/frontend/src/riexchange.ts @@ -1913,8 +1913,7 @@ export function openExchangeModal(riId: string, count: number, suggestedTargetTy max_payment_due_usd: quote.PaymentDueRaw, region: quote.Region, }); - void loadConvertibleRIs(); - void loadExchangeHistory(); + void loadRIExchange(); if (!sessionOpen) return; setResultText(resultContainer, 'Exchange completed. ID: ' + result.exchange_id, 'success-message'); executeBtn.classList.add('hidden'); From 52a6f3ffc3a677ac8fcaec9c50d4f073b37617db Mon Sep 17 00:00:00 2001 From: Cristian Magherusan-Stanciu Date: Mon, 19 Oct 2026 18:17:38 +0200 Subject: [PATCH 07/12] fix(frontend): drop the duplicate Count column from exchange history The Count cell repeated source_count, which the Source Type cell already shows as "Nx type". Remove the column instead of picking one of two counts that both appear in the type cells. Closes #286 Co-Authored-By: Claude Opus 5.5 --- frontend/src/__tests__/riexchange.test.ts | 41 +++++++++++++++++++++++ frontend/src/riexchange.ts | 3 +- 2 files changed, 42 insertions(+), 2 deletions(-) diff --git a/frontend/src/__tests__/riexchange.test.ts b/frontend/src/__tests__/riexchange.test.ts index bb06b30eb..07a6f5ab8 100644 --- a/frontend/src/__tests__/riexchange.test.ts +++ b/frontend/src/__tests__/riexchange.test.ts @@ -1272,3 +1272,44 @@ describe('RI Exchange global filter scoping (issue #871)', () => { expect(modal.classList.contains('hidden')).toBe(true); }); }); + +describe('exchange history table counts (issue #286)', () => { + beforeEach(() => { + for (const id of ['ri-exchange-instances-list', 'ri-exchange-recommendations-list', 'ri-exchange-history-list']) { + const el = document.createElement('div'); + el.id = id; + document.body.appendChild(el); + } + const s = jest.requireMock('../state') as { getCurrentProvider: jest.Mock; getCurrentAccountIDs: jest.Mock; getRiExchangeColumnFilters: jest.Mock; getActiveRiColumnFilters: jest.Mock }; + s.getCurrentProvider.mockReturnValue('aws'); + s.getCurrentAccountIDs.mockReturnValue([]); + s.getRiExchangeColumnFilters.mockReturnValue({}); + s.getActiveRiColumnFilters.mockReturnValue({}); + (api.listConvertibleRIs as jest.Mock).mockResolvedValue([]); + (api.getRIUtilization as jest.Mock).mockResolvedValue([]); + (api.getReshapeRecommendations as jest.Mock).mockResolvedValue({ recommendations: [], recs_staleness: '', recs_collected_at: null }); + }); + + afterEach(() => { + document.body.innerHTML = ''; + jest.clearAllMocks(); + }); + + it('shows each count once, inside the type cells, with no duplicate Count column', async () => { + (api.getRIExchangeHistory as jest.Mock).mockResolvedValue([{ + id: 'h1', account_id: 'a', exchange_id: 'exch-1', region: 'us-east-1', source_ri_ids: ['ri-1'], + source_instance_type: 'm5.large', source_count: 4, target_offering_id: 'o', target_instance_type: 'm5.4xlarge', + target_count: 1, payment_due: '1.00', status: 'completed', mode: 'manual', + created_at: '2026-01-01T00:00:00Z', updated_at: '2026-01-01T00:00:00Z', + }]); + await loadRIExchange(); + const table = document.querySelector('#ri-exchange-history-list table')!; + const headers = Array.from(table.querySelectorAll('th')).map((h) => h.textContent); + expect(headers).not.toContain('Count'); + const cells = Array.from(table.querySelectorAll('tbody tr td')).map((c) => c.textContent); + expect(cells).toHaveLength(headers.length); + expect(cells).toContain('4x m5.large'); + expect(cells).toContain('1x m5.4xlarge'); + expect(cells).not.toContain('4'); + }); +}); diff --git a/frontend/src/riexchange.ts b/frontend/src/riexchange.ts index e6b12a1cb..540b69e0c 100644 --- a/frontend/src/riexchange.ts +++ b/frontend/src/riexchange.ts @@ -2356,7 +2356,6 @@ function renderExchangeHistory(container: HTMLElement, records: RIExchangeHistor + '' + escapeHtml(formatDateTime(rec.created_at)) + '' + '' + escapeHtml(String(rec.source_count)) + 'x ' + escapeHtml(rec.source_instance_type) + '' + '' + escapeHtml(String(rec.target_count)) + 'x ' + escapeHtml(rec.target_instance_type) + '' - + '' + escapeHtml(String(rec.source_count)) + '' + '$' + escapeHtml(rec.payment_due) + '' + '' + escapeHtml(rec.status) + '' + '' + exchangeIdCell + '' @@ -2366,7 +2365,7 @@ function renderExchangeHistory(container: HTMLElement, records: RIExchangeHistor const tableHTML = '' + '' - + '' + + '' + '' + '' + rowsHTML + '' + '
DateSource TypeTarget TypeCountPaymentStatusExchange IDActionsDateSource TypeTarget TypePaymentStatusExchange IDActions
'; From 734c9254a212c455ffe5fa808f756aa2bacc214a Mon Sep 17 00:00:00 2001 From: Cristian Magherusan-Stanciu Date: Mon, 19 Oct 2026 18:28:32 +0200 Subject: [PATCH 08/12] fix(frontend): restore the execute button label from the mode at settle time A modal closed and reopened while a submit was in flight no longer gets the label captured at submit time written back over the reset one. Refs #754 Co-Authored-By: Claude Opus 5.5 --- frontend/src/__tests__/app-submit-toast.test.ts | 14 ++++++++++++++ frontend/src/app.ts | 6 ++---- 2 files changed, 16 insertions(+), 4 deletions(-) diff --git a/frontend/src/__tests__/app-submit-toast.test.ts b/frontend/src/__tests__/app-submit-toast.test.ts index 6265041c0..2d4aec884 100644 --- a/frontend/src/__tests__/app-submit-toast.test.ts +++ b/frontend/src/__tests__/app-submit-toast.test.ts @@ -86,6 +86,20 @@ describe('submit toast', () => { expect(all).not.toMatch(/Approval request sent/); }); + it('restores the button label from the execute mode at settle time, not submit time (#754)', async () => { + executeMode = 'direct'; + let resolvePost: (v: unknown) => void = () => undefined; + (api.executePurchase as jest.Mock).mockReturnValue(new Promise((r) => { resolvePost = r; })); + const pending = handleExecutePurchase(); + await Promise.resolve(); + await Promise.resolve(); + // Modal closed (mode reset) and reopened while the POST is in flight. + executeMode = ''; + resolvePost({ execution_id: execId, status: 'pending', email_sent: true }); + await pending; + expect(document.getElementById('execute-purchase-btn')?.textContent).toBe('Send for Approval'); + }); + it('non-duplicate direct keeps the executed-immediately message', async () => { executeMode = 'direct'; submit({ status: 'completed' }); diff --git a/frontend/src/app.ts b/frontend/src/app.ts index 0c8a39ce8..2235cdaee 100644 --- a/frontend/src/app.ts +++ b/frontend/src/app.ts @@ -424,8 +424,6 @@ export async function handleExecutePurchase(): Promise { const executeBtn = document.getElementById('execute-purchase-btn') as HTMLButtonElement | null; setExecutePurchaseSubmitting(executeBtn, true, 'Sending...'); - const defaultBtnLabel = isDirect ? 'Execute Purchase Now' : 'Send for Approval'; - // Confirmation dialog varies by mode: // - Approval path: low-friction, non-destructive. // - Direct-execute path: red destructive dialog with cost callout and @@ -460,7 +458,7 @@ export async function handleExecutePurchase(): Promise { }); if (!ok) { - setExecutePurchaseSubmitting(executeBtn, false, defaultBtnLabel); + setExecutePurchaseSubmitting(executeBtn, false, getExecuteMode() === 'direct' ? 'Execute Purchase Now' : 'Send for Approval'); return; } @@ -558,7 +556,7 @@ export async function handleExecutePurchase(): Promise { const verb = isDirect ? 'execute' : 'send for approval'; showToast({ message: `Failed to ${verb} purchase: ${err.message}`, kind: 'error' }); } finally { - setExecutePurchaseSubmitting(executeBtn, false, defaultBtnLabel); + setExecutePurchaseSubmitting(executeBtn, false, getExecuteMode() === 'direct' ? 'Execute Purchase Now' : 'Send for Approval'); } } From 1a2c9ccf0a120ba6b60011c5ad39fa4c9cd8ac6f Mon Sep 17 00:00:00 2001 From: Cristian Magherusan-Stanciu Date: Mon, 19 Oct 2026 18:29:10 +0200 Subject: [PATCH 09/12] test(frontend): cover select-all and row selection re-render totals Refs #287 Co-Authored-By: Claude Opus 5.5 --- .../src/__tests__/recommendations.test.ts | 28 +++++++++++++++++++ 1 file changed, 28 insertions(+) diff --git a/frontend/src/__tests__/recommendations.test.ts b/frontend/src/__tests__/recommendations.test.ts index a6e8e9a01..912560583 100644 --- a/frontend/src/__tests__/recommendations.test.ts +++ b/frontend/src/__tests__/recommendations.test.ts @@ -689,6 +689,34 @@ describe('Recommendations Module', () => { (state.getVisibleRecommendations as jest.Mock).mockReturnValue([]); }); + test.each([ + ['select-all', '#select-all-recs'], + ['row selection', 'input[data-rec-id]'], + ])('%s re-render keeps the loaded total in "Showing X of Y" (closes #287)', async (_name, selector) => { + const allRecs = [ + { id: 'r1', provider: 'aws', cloud_account_id: 'a1', service: 'ec2', resource_type: 't3.medium', region: 'us-east-1', count: 1, term: 1, payment_option: 'no-upfront', savings: 100, upfront_cost: 0 }, + { id: 'r2', provider: 'aws', cloud_account_id: 'a1', service: 'rds', resource_type: 'db.t3', region: 'us-east-1', count: 1, term: 1, payment_option: 'no-upfront', savings: 200, upfront_cost: 0 }, + ]; + (api.getRecommendations as jest.Mock).mockResolvedValue({ summary: {}, recommendations: allRecs, regions: [] }); + (state.getRecommendations as jest.Mock).mockReturnValue(allRecs); + (state.getVisibleRecommendations as jest.Mock).mockReturnValue([allRecs[0]]); + (state.getRecommendationsColumnFilters as jest.Mock).mockReturnValue({ service: { kind: 'set', values: ['ec2'] } }); + (state.getSelectedRecommendationIDs as jest.Mock).mockReturnValue(new Set()); + + await loadRecommendations(); + const liveText = (): string => + document.querySelector('.recommendations-filter-live')?.textContent ?? ''; + expect(liveText()).toMatch(/Showing 1 of 2 recommendations$/); + + const box = document.querySelector(selector)!; + box.checked = true; + box.dispatchEvent(new Event('change', { bubbles: true })); + expect(liveText()).toMatch(/Showing 1 of 2 recommendations$/); + + (state.getRecommendationsColumnFilters as jest.Mock).mockReturnValue({}); + (state.getVisibleRecommendations as jest.Mock).mockReturnValue([]); + }); + test('renders recommendations list', async () => { (api.getRecommendations as jest.Mock).mockResolvedValue({ summary: {}, From 3e279804cdf4235dfcb072c2f665d1fffd7ca534 Mon Sep 17 00:00:00 2001 From: Cristian Magherusan-Stanciu Date: Mon, 19 Oct 2026 18:30:33 +0200 Subject: [PATCH 10/12] test(frontend): cover fan-out modal execute mode and label reset Refs #754 Co-Authored-By: Claude Opus 5.5 --- .../__tests__/purchase-modal-submit.test.ts | 24 +++++++++++++++++++ 1 file changed, 24 insertions(+) diff --git a/frontend/src/__tests__/purchase-modal-submit.test.ts b/frontend/src/__tests__/purchase-modal-submit.test.ts index 57725b119..f8b91b880 100644 --- a/frontend/src/__tests__/purchase-modal-submit.test.ts +++ b/frontend/src/__tests__/purchase-modal-submit.test.ts @@ -154,6 +154,7 @@ import { clearPurchaseModalRecommendations, getFanOutBuckets, clearFanOutBuckets, + getExecuteMode, loadRecommendations, seedGlobalDefaults, } from '../recommendations'; @@ -1113,6 +1114,29 @@ describe('Issue #1904: fan-out modal skips incompatible buckets', () => { ); }); + test('fan-out open and close reset the execute button label and mode (#754)', async () => { + const rows = buildFanOutRows(); + (api.getConfig as jest.Mock).mockResolvedValue({ global: { default_payment: 'no-upfront' } }); + (api.getRecommendations as jest.Mock).mockResolvedValue({ summary: {}, recommendations: rows, regions: [] }); + (state.getRecommendations as jest.Mock).mockReturnValue(rows); + (state.getVisibleRecommendations as jest.Mock).mockReturnValue(rows); + (state.getSelectedRecommendationIDs as jest.Mock).mockReturnValue(new Set(['ec2-1', 'rds-3'])); + await loadRecommendations(); + + const btn = document.getElementById('execute-purchase-btn') as HTMLButtonElement; + btn.textContent = 'Execute Purchase Now'; + (document.getElementById('bulk-purchase-btn') as HTMLButtonElement).click(); + await flush(); + expect(btn.textContent).toBe('Send for Approval'); + expect(getExecuteMode()).toBe(''); + + btn.textContent = 'Execute Purchase Now'; + const onClose = (openModal as jest.Mock).mock.calls.slice(-1)[0][1].onClose as () => void; + onClose(); + expect(btn.textContent).toBe('Send for Approval'); + expect(getExecuteMode()).toBe(''); + }); + test('busy fan-out stays disabled while its requests are pending', async () => { const requests = [ deferred>>(), From 897e0df642e68f2f690fd7f8506532117a8709d6 Mon Sep 17 00:00:00 2001 From: Cristian Magherusan-Stanciu Date: Mon, 19 Oct 2026 18:31:07 +0200 Subject: [PATCH 11/12] fix(frontend): admit only filterable columns in saved recommendation filters Visual-only columns such as usage_history have no filter UI, so a saved filter on one would hide every row. Derive the allowlist from non-visual columns. Refs #278 Co-Authored-By: Claude Opus 5.5 --- frontend/src/__tests__/recommendations.test.ts | 9 ++++++--- frontend/src/recommendations.ts | 4 +++- 2 files changed, 9 insertions(+), 4 deletions(-) diff --git a/frontend/src/__tests__/recommendations.test.ts b/frontend/src/__tests__/recommendations.test.ts index 912560583..f48d5dbbc 100644 --- a/frontend/src/__tests__/recommendations.test.ts +++ b/frontend/src/__tests__/recommendations.test.ts @@ -7803,12 +7803,15 @@ describe('Column filters localStorage persistence (issue #163)', () => { expect(Object.keys(result)).toHaveLength(0); }); - test('restores a filter for every column in COLUMN_DEFS, including capacity', () => { + test('restores a filter for every filterable column, including capacity, and drops visual columns', () => { + const filterable = COLUMN_DEFS.filter((c) => c.kind !== 'visual').map((c) => c.key); + const visual = COLUMN_DEFS.find((c) => c.kind === 'visual')!.key; const filters: Record = {}; - for (const c of COLUMN_DEFS) filters[c.key] = { kind: 'set', values: ['x'] }; + for (const key of [...filterable, visual]) filters[key] = { kind: 'set', values: ['x'] }; localStorageMock.getItem.mockReturnValue(JSON.stringify({ schemaVersion: 1, filters })); const result = loadColumnFilters(); - expect(Object.keys(result).sort()).toEqual(COLUMN_DEFS.map((c) => c.key).sort()); + expect(Object.keys(result).sort()).toEqual([...filterable].sort()); + expect(result).not.toHaveProperty(visual); }); test('restores a categorical (set) filter correctly', () => { diff --git a/frontend/src/recommendations.ts b/frontend/src/recommendations.ts index cca365eb7..058b8bc85 100644 --- a/frontend/src/recommendations.ts +++ b/frontend/src/recommendations.ts @@ -3690,7 +3690,9 @@ const COLUMN_FILTERS_SCHEMA_VERSION = 1; // Full set of valid column ids, used as an allowlist when loading from // localStorage so stale or hand-edited keys are silently dropped. -const VALID_COLUMN_IDS = new Set(COLUMN_DEFS.map((c) => c.key)); +const VALID_COLUMN_IDS = new Set( + COLUMN_DEFS.filter((c) => c.kind !== 'visual').map((c) => c.key), +); interface ColumnFiltersSchema { schemaVersion: number; From 4459fd7ad0405e3be4d6710189fef5350aec5fec Mon Sep 17 00:00:00 2001 From: Cristian Magherusan-Stanciu Date: Mon, 19 Oct 2026 18:31:13 +0200 Subject: [PATCH 12/12] docs(frontend): point the marketplace discount comment at the real Go constant Refs #283 Co-Authored-By: Claude Opus 5.5 --- frontend/src/history.ts | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/frontend/src/history.ts b/frontend/src/history.ts index 43140bb57..b4c183d44 100644 --- a/frontend/src/history.ts +++ b/frontend/src/history.ts @@ -33,7 +33,8 @@ const VALID_PROVIDERS: api.Provider[] = ['aws', 'azure', 'gcp']; const AWS_MARKETPLACE_FEE_PERCENT = 12; // awsMarketplaceNetFactor: fraction of list price the seller receives after the fee. const AWS_MARKETPLACE_NET_FACTOR = 1 - AWS_MARKETPLACE_FEE_PERCENT / 100; -// awsMarketplaceBuyerDiscountPercent: discount off residual RI value for the default list price. +// Discount off residual RI value for the default list price; 1 - 5/100 mirrors +// awsMarketplaceBuyerDiscountFactor (0.95) in internal/api/handler_marketplace.go. const AWS_MARKETPLACE_BUYER_DISCOUNT_PERCENT = 5; const AWS_MARKETPLACE_BUYER_DISCOUNT = 1 - AWS_MARKETPLACE_BUYER_DISCOUNT_PERCENT / 100;