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
90 changes: 90 additions & 0 deletions frontend/src/__tests__/settings-accounts.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
38 changes: 38 additions & 0 deletions frontend/src/settings.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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' });
}
Expand Down
Loading