diff --git a/frontend/src/__tests__/settings-accounts.test.ts b/frontend/src/__tests__/settings-accounts.test.ts index d482a4b11..25467bd5d 100644 --- a/frontend/src/__tests__/settings-accounts.test.ts +++ b/frontend/src/__tests__/settings-accounts.test.ts @@ -1356,6 +1356,96 @@ describe('Account overrides modal', () => { expect(opts.body).not.toContain('Reset'); }); + describe('toast-undo for Delete override (issue #113)', () => { + const overrideFixture = { + account_id: 'acc-1', + provider: 'aws', + service: 'rds', + term: 1, + payment: 'all-upfront', + coverage: 75, + enabled: true, + }; + + test('Delete shows info toast with Undo action and clears row (click-Undo path)', async () => { + (api.listAccountServiceOverrides as jest.Mock) + .mockResolvedValueOnce([overrideFixture]) // initial render + .mockResolvedValue([]); // reload after delete + reload after undo + (api.deleteAccountServiceOverride as jest.Mock).mockResolvedValue(undefined); + (api.saveAccountServiceOverride as jest.Mock).mockResolvedValue(overrideFixture); + mockConfirmDialog.mockResolvedValue(true); + + const panel = document.createElement('div'); + document.body.appendChild(panel); + await loadOverridesPanel('acc-1', panel, 'aws'); + + const deleteBtn = Array.from(panel.querySelectorAll('button')) + .find(b => b.textContent === 'Delete') as HTMLButtonElement; + expect(deleteBtn).toBeDefined(); + + deleteBtn.click(); + // Flush the confirm + delete + reload chain. + for (let i = 0; i < 5; i++) { await new Promise(r => setTimeout(r, 0)); } + + expect(api.deleteAccountServiceOverride).toHaveBeenCalledWith('acc-1', 'aws', 'rds'); + + // An info toast with an Undo action must have been shown. + const toastCalls = mockShowToast.mock.calls.map(c => c[0]) as Array<{ + kind?: string; + message?: string; + actions?: Array<{ label: string; onClick: () => void }>; + timeout?: number | null; + }>; + const undoToast = toastCalls.find(t => t.kind === 'info' && t.message?.includes('aws/rds')); + expect(undoToast).toBeDefined(); + expect(undoToast!.actions).toBeDefined(); + expect(undoToast!.actions!.length).toBe(1); + expect(undoToast!.actions![0]!.label).toBe('Undo'); + // 5-second TTL per issue spec. + expect(undoToast!.timeout).toBe(5_000); + + // Simulate clicking Undo. + undoToast!.actions![0]!.onClick(); + for (let i = 0; i < 5; i++) { await new Promise(r => setTimeout(r, 0)); } + + // saveAccountServiceOverride must have been called with the original snapshot. + expect(api.saveAccountServiceOverride).toHaveBeenCalledWith( + 'acc-1', 'aws', 'rds', + expect.objectContaining({ + term: 1, + payment: 'all-upfront', + coverage: 75, + enabled: true, + }), + ); + }); + + test('let-it-expire path: override is permanently gone after toast timeout', async () => { + (api.listAccountServiceOverrides as jest.Mock) + .mockResolvedValueOnce([overrideFixture]) + .mockResolvedValue([]); + (api.deleteAccountServiceOverride as jest.Mock).mockResolvedValue(undefined); + mockConfirmDialog.mockResolvedValue(true); + + const panel = document.createElement('div'); + document.body.appendChild(panel); + await loadOverridesPanel('acc-1', panel, 'aws'); + + const deleteBtn = Array.from(panel.querySelectorAll('button')) + .find(b => b.textContent === 'Delete') as HTMLButtonElement; + deleteBtn.click(); + for (let i = 0; i < 5; i++) { await new Promise(r => setTimeout(r, 0)); } + + // Undo was NOT clicked; saveAccountServiceOverride must not have been called. + expect(api.saveAccountServiceOverride).not.toHaveBeenCalled(); + // The delete did fire. + expect(api.deleteAccountServiceOverride).toHaveBeenCalledTimes(1); + // The panel reloaded to show the empty state (no more override rows). + const table = panel.querySelector('table.overrides-table'); + expect(table).toBeNull(); + }); + }); + describe('override commitmentOptions parity (issue #107)', () => { test('inline payment selector hides invalid options for RDS term=3 row', async () => { // RDS rejects 3yr no-upfront per commitmentOptions invalidCombinations. diff --git a/frontend/src/settings.ts b/frontend/src/settings.ts index 8f76f8e8a..9e785e2ca 100644 --- a/frontend/src/settings.ts +++ b/frontend/src/settings.ts @@ -1195,10 +1195,48 @@ export async function loadOverridesPanel(accountId: string, panel: HTMLElement, destructive: true, }); if (!ok) return; + // Snapshot all fields before deleting so the Undo action can re-PUT + // them if the user clicks the toast button within the 5-second window. + const snapshot: api.AccountServiceOverrideRequest = { + enabled: o.enabled, + term: o.term, + payment: o.payment, + coverage: o.coverage, + ramp_schedule: o.ramp_schedule, + include_engines: o.include_engines, + exclude_engines: o.exclude_engines, + include_regions: o.include_regions, + exclude_regions: o.exclude_regions, + include_types: o.include_types, + exclude_types: o.exclude_types, + }; try { await api.deleteAccountServiceOverride(accountId, o.provider, o.service); await loadOverridesPanel(accountId, panel, provider); await refreshRecommendationsAfterOverrideChange(); + // Show a 5-second undo toast. The action closure captures `snapshot` + // and re-PUT it if the user clicks Undo before the toast expires. + // Each Reset click replaces any prior toast handle so a rapid + // double-click cannot stack multiple Undo buttons. + showToast({ + message: `Override for ${o.provider}/${o.service} deleted.`, + kind: 'info', + timeout: 5_000, + actions: [{ + label: 'Undo', + onClick: () => { + void (async () => { + try { + await api.saveAccountServiceOverride(accountId, o.provider, o.service, snapshot); + await loadOverridesPanel(accountId, panel, provider); + await refreshRecommendationsAfterOverrideChange(); + } catch (undoErr) { + showToast({ message: `Failed to restore override: ${(undoErr as Error).message}`, kind: 'error' }); + } + })(); + }, + }], + }); } catch (err) { showToast({ message: `Failed to delete override: ${(err as Error).message}`, kind: 'error' }); }