diff --git a/frontend/src/__tests__/app-fanout-retry.test.ts b/frontend/src/__tests__/app-fanout-retry.test.ts new file mode 100644 index 00000000..3300e23c --- /dev/null +++ b/frontend/src/__tests__/app-fanout-retry.test.ts @@ -0,0 +1,243 @@ +/** + * Issue #757: a partial bulk (fan-out) purchase failure keeps the modal open + * with per-bucket status and a retry that resubmits only the failed buckets. + */ +const showToast = jest.fn(); +const closePurchaseModal = jest.fn(); +const clearFanOutBuckets = jest.fn(); +const openArcheraOfferModal = jest.fn(); +let current: Array> | null = null; + +jest.mock('../toast', () => ({ showToast: (...a: unknown[]) => showToast(...a) })); +jest.mock('../confirmDialog', () => ({ confirmDialog: jest.fn().mockResolvedValue(true) })); +jest.mock('../api', () => ({ executePurchase: jest.fn() })); +jest.mock('../state', () => ({})); +jest.mock('../auth', () => ({})); +jest.mock('../dashboard', () => ({ loadDashboard: jest.fn(), setupDashboardHandlers: jest.fn() })); +jest.mock('../navigation', () => ({})); +jest.mock('../plans', () => ({ closePurchaseModal: () => closePurchaseModal() })); +jest.mock('../settings', () => ({})); +jest.mock('../users', () => ({})); +jest.mock('../apikeys', () => ({})); +jest.mock('../history', () => ({})); +jest.mock('../modules/savings-history', () => ({})); +jest.mock('../riexchange', () => ({})); +jest.mock('../purchases-deeplink', () => ({})); +jest.mock('../archera', () => ({ handleArcheraDeeplink: jest.fn(), openArcheraOfferModal: (k: string) => openArcheraOfferModal(k) })); +jest.mock('../modal', () => ({ closeModal: jest.fn() })); +jest.mock('../recommendations', () => ({ + setupRecommendationsHandlers: jest.fn(), + getPurchaseModalRecommendations: () => [], + clearPurchaseModalRecommendations: jest.fn(), + getFanOutBuckets: () => (current ? current.map((b) => ({ ...b })) : null), + clearFanOutBuckets: () => { clearFanOutBuckets(); current = null; }, + retainFanOutBuckets: (ids: Set) => { current = (current ?? []).filter((b) => ids.has(b['id'] as string)); }, + getExecuteMode: () => '', + clearExecuteMode: jest.fn(), + fanOutBucketAccountLabel: (b: { id?: string }) => `acct-${b.id}`, + getAccountName: () => 'acct', +})); + +import { handleExecutePurchase } from '../app'; +import * as api from '../api'; + +const bucket = (id: string, service: string): Record => ({ + id, provider: 'aws', service, term: 1, payment: 'no-upfront', capacityPercent: 100, paymentSource: 'toolbar', + recs: [{ service }], +}); + +const section = (id: string): HTMLElement => + document.querySelector(`section.fanout-bucket[data-bucket-id="${id}"]`)!; +const btn = (): HTMLButtonElement => document.getElementById('execute-purchase-btn') as HTMLButtonElement; +const postedServices = (): string[] => + (api.executePurchase as jest.Mock).mock.calls.map((c) => (c[0] as Array<{ service: string }>)[0]!.service); + +beforeEach(() => { + jest.clearAllMocks(); + current = [bucket('bucket-0', 'ec2'), bucket('bucket-1', 'rds'), bucket('bucket-2', 'elasticache')]; + renderModal(current); +}); + +/** Renders the open purchase modal with one section per bucket. */ +function renderModal(buckets: Array>): void { + document.body.innerHTML = ` + `; +} + +/** What Escape/Cancel does: hide the modal and clear its buckets (onClose). */ +function closeModalMidSubmit(): void { + document.getElementById('purchase-modal')!.classList.add('hidden'); + current = null; +} + +/** executePurchase calls that stay pending until the test settles them. */ +function deferredPosts(): Array<{ resolve: (v: unknown) => void; reject: (e: Error) => void }> { + const posts: Array<{ resolve: (v: unknown) => void; reject: (e: Error) => void }> = []; + (api.executePurchase as jest.Mock).mockImplementation(() => new Promise((resolve, reject) => { posts.push({ resolve, reject }); })); + return posts; +} + +const flush = async (): Promise => { for (let i = 0; i < 10; i++) await Promise.resolve(); }; +const toastMessages = (): string[] => showToast.mock.calls.map((c) => (c[0] as { message: string }).message); + +describe('fan-out partial failure retry (issue #757)', () => { + test('keeps the modal open, marks each bucket, and narrows to the failed ones', async () => { + (api.executePurchase as jest.Mock) + .mockResolvedValueOnce({ execution_id: 'aaaa0000-1', status: 'pending', email_sent: true, approval_recipient: 'boss@example.com' }) + .mockRejectedValueOnce(new Error('quota exceeded')) + .mockResolvedValueOnce({ execution_id: 'cccc0000-1', status: 'failed', email_sent: false, email_reason: 'SES throttled' }); + + await handleExecutePurchase(); + + expect(closePurchaseModal).not.toHaveBeenCalled(); + expect(clearFanOutBuckets).not.toHaveBeenCalled(); + expect(openArcheraOfferModal).not.toHaveBeenCalled(); + + expect(section('bucket-0').classList.contains('fanout-bucket-submitted')).toBe(true); + expect(section('bucket-0').querySelector('.fanout-bucket-result')!.textContent) + .toBe('Submitted: awaiting approval from boss@example.com'); + expect(section('bucket-0').querySelector('select')!.disabled).toBe(true); + expect(section('bucket-1').querySelector('.fanout-bucket-result')!.textContent).toBe('Failed: quota exceeded'); + expect(section('bucket-2').querySelector('.fanout-bucket-result')!.textContent).toBe('Failed: SES throttled'); + expect(section('bucket-1').querySelector('select')!.disabled).toBe(false); + + const banner = document.getElementById('fanout-result')!; + expect(banner.getAttribute('role')).toBe('alert'); + expect(banner.textContent).toContain('1 bucket submitted, 2 failed.'); + expect(banner.textContent).toContain('Submitted buckets will not be sent again'); + + expect(current!.map((b) => b['id'])).toEqual(['bucket-1', 'bucket-2']); + expect(btn().textContent).toBe('Retry 2 failed'); + expect(btn().disabled).toBe(false); + // The modal carries the details; no sticky failure toast covers it. + expect(showToast.mock.calls.map((c) => (c[0] as { message: string }).message).join('\n')).not.toMatch(/failed/); + }); + + test('retry resubmits only the failed buckets and closes the modal once all succeed', async () => { + (api.executePurchase as jest.Mock) + .mockResolvedValueOnce({ execution_id: 'aaaa0000-1', status: 'pending', email_sent: true }) + .mockRejectedValueOnce(new Error('quota exceeded')) + .mockRejectedValueOnce(new Error('timeout')); + await handleExecutePurchase(); + expect(postedServices()).toEqual(['ec2', 'rds', 'elasticache']); + + (api.executePurchase as jest.Mock).mockClear(); + (api.executePurchase as jest.Mock) + .mockResolvedValueOnce({ execution_id: 'bbbb0000-1', status: 'pending', email_sent: true }) + .mockResolvedValueOnce({ execution_id: 'cccc0000-1', status: 'pending', email_sent: true }); + await handleExecutePurchase(); + + expect(postedServices()).toEqual(['rds', 'elasticache']); + expect(closePurchaseModal).toHaveBeenCalledTimes(1); + expect(openArcheraOfferModal).toHaveBeenCalledWith('purchase'); + }); + + test('a second partial failure keeps the earlier successes marked and counted', async () => { + (api.executePurchase as jest.Mock) + .mockResolvedValueOnce({ execution_id: 'aaaa0000-1', status: 'pending', email_sent: true }) + .mockRejectedValueOnce(new Error('e1')) + .mockRejectedValueOnce(new Error('e2')); + await handleExecutePurchase(); + (api.executePurchase as jest.Mock) + .mockResolvedValueOnce({ execution_id: 'bbbb0000-1', status: 'completed', duplicate: true }) + .mockRejectedValueOnce(new Error('e3')); + await handleExecutePurchase(); + + expect(section('bucket-0').classList.contains('fanout-bucket-submitted')).toBe(true); + expect(section('bucket-1').querySelector('.fanout-bucket-result')!.textContent) + .toBe('Submitted: joined existing request bbbb0000; it was already executed'); + expect(section('bucket-2').querySelector('.fanout-bucket-result')!.textContent).toBe('Failed: e3'); + expect(document.getElementById('fanout-result')!.textContent).toContain('2 buckets submitted, 1 failed.'); + expect(btn().textContent).toBe('Retry 1 failed'); + }); + + test('all buckets failing also stays open for a retry', async () => { + (api.executePurchase as jest.Mock).mockRejectedValue(new Error('down')); + await handleExecutePurchase(); + expect(closePurchaseModal).not.toHaveBeenCalled(); + expect(document.getElementById('fanout-result')!.textContent).toContain('Nothing was submitted.'); + expect(btn().textContent).toBe('Retry 3 failed'); + }); + + test('buckets without ids (no rendered fan-out modal) keep the old close behavior', async () => { + current = [{ ...bucket('x', 'ec2'), id: undefined }, { ...bucket('y', 'rds'), id: undefined }]; + (api.executePurchase as jest.Mock) + .mockResolvedValueOnce({ execution_id: 'aaaa0000-1', status: 'pending', email_sent: true }) + .mockRejectedValueOnce(new Error('boom')); + await handleExecutePurchase(); + expect(closePurchaseModal).toHaveBeenCalledTimes(1); + expect(document.getElementById('fanout-result')).toBeNull(); + }); + + test('closing the modal mid-submit still reports a failed bucket in a toast, as before', async () => { + const posts = deferredPosts(); + const run = handleExecutePurchase(); + await flush(); + expect(posts).toHaveLength(3); + + closeModalMidSubmit(); + posts[0]!.resolve({ execution_id: 'aaaa0000-1', status: 'pending', email_sent: true }); + posts[1]!.reject(new Error('quota exceeded')); + posts[2]!.resolve({ execution_id: 'cccc0000-1', status: 'pending', email_sent: true }); + await run; + + expect(toastMessages()).toContainEqual(expect.stringMatching(/^2 of 3 submitted · 1 failed: acct-bucket-1: quota exceeded/)); + expect(document.getElementById('fanout-result')).toBeNull(); + expect(document.querySelector('.fanout-bucket-result')).toBeNull(); + expect(openArcheraOfferModal).toHaveBeenCalledWith('purchase'); + }); + + test('results from a closed modal leave a newly opened bulk modal untouched', async () => { + const posts = deferredPosts(); + const run = handleExecutePurchase(); + await flush(); + + closeModalMidSubmit(); + const next = [bucket('next-0', 'ec2'), bucket('next-1', 'rds')]; + current = next; + renderModal(next); + + posts[0]!.resolve({ execution_id: 'aaaa0000-1', status: 'pending', email_sent: true }); + posts[1]!.reject(new Error('quota exceeded')); + posts[2]!.resolve({ execution_id: 'cccc0000-1', status: 'pending', email_sent: true }); + await run; + + expect(current!.map((b) => b['id'])).toEqual(['next-0', 'next-1']); + expect(document.getElementById('purchase-modal')!.classList.contains('hidden')).toBe(false); + expect(document.getElementById('fanout-result')).toBeNull(); + expect(document.querySelector('.fanout-bucket-submitted, .fanout-bucket-failed, .fanout-bucket-result')).toBeNull(); + expect([...document.querySelectorAll('select')].some((s) => (s as HTMLSelectElement).disabled)).toBe(false); + expect(closePurchaseModal).not.toHaveBeenCalled(); + expect(clearFanOutBuckets).not.toHaveBeenCalled(); + expect(toastMessages()).toContainEqual(expect.stringMatching(/^2 of 3 submitted · 1 failed: acct-bucket-1: quota exceeded/)); + }); + + test('the payment-adjustment notice is not sticky while the modal is in retry mode', async () => { + (api.executePurchase as jest.Mock) + .mockResolvedValueOnce({ execution_id: 'aaaa0000-1', status: 'pending', email_sent: true, payment_adjustments: [{ provider: 'aws', service: 'ec2', requested_payment_option: 'no-upfront', applied_payment_option: 'all-upfront' }] }) + .mockRejectedValueOnce(new Error('quota exceeded')) + .mockRejectedValueOnce(new Error('timeout')); + await handleExecutePurchase(); + + expect(document.getElementById('fanout-result')).not.toBeNull(); + const notice = showToast.mock.calls.map((c) => c[0] as { kind: string; timeout?: number | null }).find((o) => o.kind === 'warning'); + expect(notice).toBeDefined(); + expect(notice!.timeout).not.toBeNull(); + }); + + test('bucket failure reason text cannot inject markup', async () => { + (api.executePurchase as jest.Mock) + .mockResolvedValueOnce({ execution_id: 'aaaa0000-1', status: 'pending', email_sent: true }) + .mockRejectedValueOnce(new Error('')) + .mockResolvedValueOnce({ execution_id: 'cccc0000-1', status: 'pending', email_sent: true }); + await handleExecutePurchase(); + expect(section('bucket-1').querySelector('img')).toBeNull(); + expect(section('bucket-1').textContent).toContain(''); + }); +}); diff --git a/frontend/src/__tests__/purchase-modal-submit.test.ts b/frontend/src/__tests__/purchase-modal-submit.test.ts index 07000397..adf36a15 100644 --- a/frontend/src/__tests__/purchase-modal-submit.test.ts +++ b/frontend/src/__tests__/purchase-modal-submit.test.ts @@ -202,6 +202,33 @@ describe('Issue #331: Escape discards purchase state like the explicit close but expect(body[0]!['id']).toBe('single-only'); }); + test('each fan-out modal open gets fresh bucket ids, so results from an earlier open cannot match it (issue #757)', async () => { + const [ec2Rec, rdsRec] = fanOutEligibleRows(); + (api.getConfig as jest.Mock).mockResolvedValue({ global: { default_payment: 'no-upfront' } }); + (api.getRecommendations as jest.Mock).mockResolvedValue({ + summary: {}, recommendations: [ec2Rec, rdsRec], regions: [], + }); + (state.getRecommendations as jest.Mock).mockReturnValue([ec2Rec, rdsRec]); + (state.getVisibleRecommendations as jest.Mock).mockReturnValue([ec2Rec, rdsRec]); + (state.getSelectedRecommendationIDs as jest.Mock).mockReturnValue(new Set(['fo-ec2', 'fo-rds'])); + + await loadRecommendations(); + (document.getElementById('bulk-purchase-btn') as HTMLButtonElement).click(); + await flush(); + const first = getFanOutBuckets()!.map((b) => b.id); + lastRegisteredOnClose()(); + + (document.getElementById('bulk-purchase-btn') as HTMLButtonElement).click(); + await flush(); + const second = getFanOutBuckets()!.map((b) => b.id); + + expect(first).toHaveLength(2); + expect(second).toHaveLength(2); + expect(new Set([...first, ...second]).size).toBe(4); + const rendered = [...document.querySelectorAll('section.fanout-bucket')].map((s) => s.dataset['bucketId']); + expect(rendered).toEqual(second); + }); + test('Escape on a single-bucket modal discards its selection too (reverse direction)', async () => { const staleRec = buildRows()[0]!; // v-3-all await openPurchaseModal([staleRec]); diff --git a/frontend/src/app.ts b/frontend/src/app.ts index f979dcc2..351ced58 100644 --- a/frontend/src/app.ts +++ b/frontend/src/app.ts @@ -23,6 +23,7 @@ import { handlePurchaseDeeplink } from './purchases-deeplink'; import { handleRIExchangeDeeplink } from './riexchange-deeplink'; import { handleArcheraDeeplink, openArcheraOfferModal } from './archera'; import { closeModal } from './modal'; +import { showFanOutRetry, fanOutRetryLabel, purchaseModalReplaced, type FanOutBucketOutcome } from './fanout-retry'; import { wireTablistKeyboard } from './lib/tablist'; import { setOpenAccountsHandler } from './first-run-empty-state'; import { setupMobileNav } from './nav/mobile-nav'; @@ -667,10 +668,36 @@ async function handleFanOutExecute(buckets: FanOutBucket[]): Promise { try { await submitFanOutBuckets(buckets); } finally { - setExecutePurchaseSubmitting(executeBtn, false, executeButtonLabel(false)); + // Issue #757: after a partial failure the modal stays open in retry mode. + setExecutePurchaseSubmitting(executeBtn, false, fanOutRetryLabel() ?? executeButtonLabel(false)); } } +/** + * Per-bucket outcome for the retry view (issue #757). Uses the same + * success rule as the toast: a fulfilled POST whose approval email failed + * is a failure. + */ +function fanOutOutcomes( + buckets: FanOutBucket[], + results: PromiseSettledResult[], +): FanOutBucketOutcome[] { + return results.map((r, i) => { + const bucket = buckets[i]!; + if (r.status === 'rejected') { + return { bucket, ok: false, detail: r.reason instanceof Error ? r.reason.message : String(r.reason) }; + } + if (r.value.email_sent === false || r.value.status === 'failed') { + return { bucket, ok: false, detail: r.value.email_reason || 'approval email did not send' }; + } + if (r.value.duplicate) { + return { bucket, ok: true, detail: `joined existing request ${r.value.execution_id.slice(0, 8)}; ${duplicateState(r.value.status)}` }; + } + const to = r.value.approval_recipient ? ` from ${r.value.approval_recipient}` : ''; + return { bucket, ok: true, detail: `awaiting approval${to}` }; + }); +} + async function submitFanOutBuckets(buckets: FanOutBucket[]): Promise { // Fire all POSTs in parallel via allSettled so one failure doesn't // cascade. Each bucket's recs are already scaled by its capacity %; @@ -709,9 +736,20 @@ async function submitFanOutBuckets(buckets: FanOutBucket[]): Promise { ); const succeeded = fulfilled.length - submissionFailures.length; const failed = results.length - succeeded; - closePurchaseModal(); - clearFanOutBuckets(); - clearPurchaseModalRecommendations(); + // Issue #757: on any failure keep the modal open with per-bucket status + // and a retry limited to the failed buckets, instead of closing it and + // making the user reselect rows (and risk resubmitting the successes). + // If the user closed the modal while the POSTs were in flight, report by + // toast as before. If they already opened another purchase modal, leave + // that one alone: it is not the one these results belong to. + // (Checked before showFanOutRetry, which narrows the modal's buckets.) + const replaced = purchaseModalReplaced(buckets); + const retrying = failed > 0 && showFanOutRetry(fanOutOutcomes(buckets, results)); + if (!retrying && !replaced) { + closePurchaseModal(); + clearFanOutBuckets(); + clearPurchaseModalRecommendations(); + } // Disclose payment-option coercions across every bucket that reached the // backend (#1503). Collected from all fulfilled responses, not just the @@ -722,7 +760,9 @@ async function submitFanOutBuckets(buckets: FanOutBucket[]): Promise { fulfilled.flatMap((r) => r.value.payment_adjustments ?? []), ); if (fanOutNotice) { - showToast({ message: fanOutNotice, kind: 'warning', timeout: null }); + // Sticky as before, except in retry mode, where it would cover the + // modal's Retry button; it then expires on the default timeout. + showToast({ message: fanOutNotice, kind: 'warning', ...(retrying ? {} : { timeout: null }) }); } if (failed === 0) { @@ -766,7 +806,10 @@ async function submitFanOutBuckets(buckets: FanOutBucket[]): Promise { timeout: 15_000, }); } - } else { + } else if (!retrying) { + // Issue #757: skipped while the modal stays open for a retry. The modal + // then shows every bucket's status and reason in full, and a sticky toast + // would sit on top of its Retry button. // Prefix each failure with its bucket's account so the user knows what to // retry when one account's bucket fails and another's succeeds (issue #333). const failureMsgs = results @@ -826,7 +869,10 @@ async function submitFanOutBuckets(buckets: FanOutBucket[]): Promise { // async email-link execution completes. Skipping the all-fail path keeps // the modal from layering on top of an error toast for a user who has // nothing to insure yet. - if (succeeded > 0) { + // Not while a purchase modal is open (issue #757): the retry view, or a + // newer modal the user opened before these POSTs settled. The offer would + // stack on top of it. In retry mode it fires once a retry closes the modal. + if (succeeded > 0 && !retrying && !replaced) { openArcheraOfferModal('purchase'); } diff --git a/frontend/src/fanout-retry.ts b/frontend/src/fanout-retry.ts new file mode 100644 index 00000000..3245d028 --- /dev/null +++ b/frontend/src/fanout-retry.ts @@ -0,0 +1,127 @@ +/** + * Partial bulk-purchase failure recovery (issue #757). + * + * A multi-bucket ("fan-out") purchase is one POST per bucket. When some of + * them fail, the modal used to close and clear the selection, leaving the + * user to find and reselect the failed rows in a large table, with a risk of + * resubmitting the buckets that already went through. Instead the modal now + * stays open: every bucket section shows whether it was submitted or failed + * (with the reason), the submitted ones are locked, and the submit button + * becomes "Retry N failed", which resubmits only the failed buckets. + */ + +import { getFanOutBuckets, retainFanOutBuckets, type FanOutBucket } from './recommendations'; + +export interface FanOutBucketOutcome { + bucket: FanOutBucket; + ok: boolean; + /** Short status for the bucket section, e.g. the failure reason. */ + detail: string; +} + +const RESULT_ID = 'fanout-result'; + +function sectionFor(container: HTMLElement, id: string): HTMLElement | null { + for (const el of container.querySelectorAll('section.fanout-bucket')) { + if (el.dataset['bucketId'] === id) return el; + } + return null; +} + +function markSection(section: HTMLElement, outcome: FanOutBucketOutcome): void { + section.querySelector('.fanout-bucket-result')?.remove(); + section.classList.toggle('fanout-bucket-submitted', outcome.ok); + section.classList.toggle('fanout-bucket-failed', !outcome.ok); + const line = document.createElement('p'); + line.className = `fanout-bucket-result ${outcome.ok ? 'fanout-bucket-result-ok' : 'fanout-bucket-result-failed'}`; + line.textContent = outcome.ok ? `Submitted: ${outcome.detail}` : `Failed: ${outcome.detail}`; + section.querySelector('h4')?.after(line); + if (outcome.ok) { + // Submitted buckets are final: lock their controls so nothing suggests + // they will be sent again. + section.querySelectorAll('select').forEach((s) => { s.disabled = true; }); + } +} + +function purchaseModalOpen(): boolean { + const modal = document.getElementById('purchase-modal'); + return modal !== null && !modal.classList.contains('hidden'); +} + +/** + * Whether the purchase modal is still open on the buckets that were just + * submitted. Bucket ids are unique per modal open, so this is false once the + * user closed the modal (Escape or Cancel) while the POSTs were in flight, + * and also once a new purchase modal has replaced it. + */ +function modalHoldsBuckets(buckets: readonly FanOutBucket[]): boolean { + if (!purchaseModalOpen()) return false; + const current = getFanOutBuckets(); + if (!current) return false; + const ids = new Set(current.map((b) => b.id)); + return buckets.every((b) => b.id !== undefined && ids.has(b.id)); +} + +/** + * True when a different purchase modal is open than the one these buckets + * were submitted from: the user closed it and opened another before the + * POSTs settled. The caller must then leave the modal alone instead of + * closing it or clearing its state. + */ +export function purchaseModalReplaced(buckets: readonly FanOutBucket[]): boolean { + if (!buckets.every((b) => b.id)) return false; + return purchaseModalOpen() && !modalHoldsBuckets(buckets); +} + +/** + * Keeps the fan-out modal open after a partial failure and narrows it to the + * failed buckets. Returns false (caller reports by toast and closes the modal + * as before) when nothing failed, when the buckets carry no ids, or when the + * modal that submitted them is no longer open. + */ +export function showFanOutRetry(outcomes: readonly FanOutBucketOutcome[]): boolean { + const failed = outcomes.filter((o) => !o.ok); + if (failed.length === 0) return false; + if (!modalHoldsBuckets(outcomes.map((o) => o.bucket))) return false; + const container = document.getElementById('purchase-details'); + if (!container || !container.querySelector('section.fanout-bucket')) return false; + + for (const o of outcomes) { + const section = sectionFor(container, o.bucket.id!); + if (section) markSection(section, o); + } + + retainFanOutBuckets(new Set(failed.map((o) => o.bucket.id!))); + + let banner = document.getElementById(RESULT_ID); + if (!banner) { + banner = document.createElement('div'); + banner.id = RESULT_ID; + banner.className = 'warning-message fanout-result'; + banner.setAttribute('role', 'alert'); + const summary = document.getElementById('fanout-summary'); + if (summary) summary.after(banner); + else container.prepend(banner); + } + // Count submitted sections across attempts, so a second partial failure + // still reports everything that already went through. + const submitted = container.querySelectorAll('section.fanout-bucket-submitted').length; + const head = `${submitted} bucket${submitted === 1 ? '' : 's'} submitted, ${failed.length} failed.`; + banner.textContent = submitted > 0 + ? `${head} Submitted buckets will not be sent again, and closing this dialog does not cancel them. Retry sends only the failed bucket${failed.length === 1 ? '' : 's'}.` + : `${head} Nothing was submitted. Check the reasons below, then retry.`; + // jsdom has no scrollIntoView; browsers always do. + if (typeof banner.scrollIntoView === 'function') banner.scrollIntoView({ block: 'nearest' }); + return true; +} + +/** + * Submit-button label while a retry is pending, or null when the modal is + * not in retry mode. The banner only exists in retry mode: opening the modal + * again rebuilds #purchase-details and drops it. + */ +export function fanOutRetryLabel(): string | null { + if (!document.getElementById(RESULT_ID)) return null; + const n = getFanOutBuckets()?.length ?? 0; + return n > 0 ? `Retry ${n} failed` : null; +} diff --git a/frontend/src/recommendations.ts b/frontend/src/recommendations.ts index 5985f838..fbc5dbe7 100644 --- a/frontend/src/recommendations.ts +++ b/frontend/src/recommendations.ts @@ -4355,6 +4355,13 @@ export interface FanOutBucket { capacityPercent: number; recs: LocalRecommendation[]; // scaled by capacityPercent paymentSource: 'override' | 'toolbar'; + /** + * Identity within one modal open (issue #757), unique across opens. + * getFanOutBuckets() hands out copies, so the submit path needs this to + * map a POST result back to its bucket and section after a partial + * failure, and to tell whether that modal is still the open one. + */ + id?: string; } // Fan-out modal state. app.ts's Send-for-Approval click reads these @@ -4384,6 +4391,18 @@ export function clearFanOutBuckets(): void { currentFanOutBuckets = null; } +/** + * Narrows the fan-out modal to the buckets with the given ids (issue #757): + * after a partial failure only the failed buckets stay submittable, so a + * retry cannot resubmit the ones that already went through. Re-renders the + * modal header so the email count and totals describe the retry. + */ +export function retainFanOutBuckets(ids: ReadonlySet): void { + if (!currentFanOutBuckets) return; + currentFanOutBuckets = currentFanOutBuckets.filter((b) => b.id !== undefined && ids.has(b.id)); + refreshFanOutSummary(); +} + // currentExecuteMode holds the mode selected by the execute-mode toggle in // the purchase modal (issue #289). "direct" means the session holder has // execute-any/execute-own and explicitly chose to bypass the approval email. @@ -4521,7 +4540,7 @@ async function openFanOutModal( const buckets: FanOutBucket[] = bucketEntries .filter(([_key, recs]) => recs.length > 0) - .map(([_key, recs]) => { + .map(([_key, recs], index) => { const r = recs[0]!; const seed = resolveBucketPaymentSeed(recs, toolbar, overridesByAccount); // SP buckets carry the canonical bucket key as `service` so the @@ -4566,6 +4585,9 @@ async function openFanOutModal( paymentSource: resolvedPaymentSource, capacityPercent: toolbar.capacity, recs: resolvedRecs, + // Unique per open, so results that settle after this modal closed + // cannot match the sections of a newer one. + id: `bucket-${openToken}-${index}`, }; }); currentFanOutBuckets = buckets; @@ -4689,6 +4711,7 @@ function computeFanOutTotals(buckets: FanOutBucket[]): { totalCount: number; tot function renderFanOutBucketSection(b: FanOutBucket): HTMLElement { const section = document.createElement('section'); section.className = 'fanout-bucket form-section'; + if (b.id) section.dataset['bucketId'] = b.id; // Service label: SP bucket → "Savings Plans (Compute + SageMaker)" // listing only the plan types actually present in this bucket; diff --git a/frontend/src/styles/modals.css b/frontend/src/styles/modals.css index c30e3282..461eda9f 100644 --- a/frontend/src/styles/modals.css +++ b/frontend/src/styles/modals.css @@ -534,3 +534,29 @@ max-width: none; padding: 0.5rem 0 0; } + +/* Issue #757: per-bucket outcome after a partial bulk-purchase failure. The + * fan-out modal stays open; submitted buckets are dimmed and locked, failed + * ones are flagged with their reason and are the only ones a retry sends. */ +.fanout-bucket-result { + font-weight: var(--cudly-fw-semibold); + margin: 0.25rem 0; +} + +.fanout-bucket-result-ok { + color: var(--cudly-success-fg); +} + +.fanout-bucket-result-failed { + color: var(--cudly-error-fg); +} + +/* Dim the locked details but keep the status line at full contrast. */ +.fanout-bucket-submitted > :not(.fanout-bucket-result) { + opacity: 0.7; +} + +.fanout-bucket-failed { + border-left: 3px solid var(--cudly-error); + padding-left: 0.75rem; +} diff --git a/frontend/tests-e2e/fanout-partial-failure.spec.ts b/frontend/tests-e2e/fanout-partial-failure.spec.ts new file mode 100644 index 00000000..df1ae9d4 --- /dev/null +++ b/frontend/tests-e2e/fanout-partial-failure.spec.ts @@ -0,0 +1,60 @@ +import { test, expect, type Page } from '@playwright/test'; +import { mockApi, seedAuth } from './fixtures/recs'; + +// Issue #757: when some buckets of a bulk purchase fail, the modal stays open +// with per-bucket status, and "Retry N failed" resubmits only those buckets. + +async function openFanOut(page: Page): Promise { + await page.route('**/api/accounts/*/service-overrides', (route) => route.fulfill({ json: [] })); + await page.goto('/opportunities'); + for (const id of ['r01', 'r12', 'r16']) { + await page.locator(`tr.recommendation-row[data-rec-id="${id}"]`) + .getByRole('checkbox', { name: /^Select / }).check(); + } + await page.locator('#bulk-purchase-btn').click(); + await expect(page.locator('#fanout-summary')).toBeVisible(); + await expect(page.locator('section.fanout-bucket')).toHaveCount(3); +} + +async function send(page: Page, label: RegExp | string): Promise { + await page.locator('#execute-purchase-btn').filter({ hasText: label }).click(); + // The confirm button names the combined upfront of the buckets being sent (#707). + await page.locator('.modal-confirm-backdrop') + .getByRole('button', { name: /^Send for Approval \(\$[\d,.]+ upfront on approval\)$/ }).click(); +} + +test('partial failure keeps the modal open and retries only the failed bucket', async ({ page }) => { + await seedAuth(page); + await mockApi(page); + const posted: string[] = []; + let azureAttempts = 0; + await page.route('**/api/purchases/execute', async (route) => { + const body = JSON.parse(route.request().postData() ?? '{}') as { recommendations: Array<{ provider: string }> }; + const provider = body.recommendations[0]!.provider; + posted.push(provider); + if (provider === 'azure' && azureAttempts++ === 0) { + await route.fulfill({ status: 502, json: { error: 'Azure reservation API unavailable' } }); + return; + } + await route.fulfill({ json: { execution_id: `exec-${provider}-${posted.length}`, status: 'pending', email_sent: true } }); + }); + + await openFanOut(page); + await send(page, 'Send for Approval'); + + const modal = page.getByRole('dialog', { name: 'Review Purchase' }); + await expect(modal).toBeVisible(); + await expect(page.locator('#fanout-result')).toContainText('2 buckets submitted, 1 failed.'); + const failed = page.locator('section.fanout-bucket.fanout-bucket-failed'); + await expect(failed).toHaveCount(1); + await expect(failed).toContainText('AZURE'); + await expect(failed.locator('.fanout-bucket-result')).toContainText('Failed:'); + await expect(page.locator('section.fanout-bucket.fanout-bucket-submitted')).toHaveCount(2); + await expect(page.locator('section.fanout-bucket-submitted select').first()).toBeDisabled(); + await expect(page.locator('#execute-purchase-btn')).toHaveText('Retry 1 failed'); + expect([...posted].sort()).toEqual(['aws', 'azure', 'gcp']); + + await send(page, 'Retry 1 failed'); + await expect(modal).toBeHidden(); + expect(posted.slice(3)).toEqual(['azure']); +});