diff --git a/frontend/src/__tests__/auth.test.ts b/frontend/src/__tests__/auth.test.ts index d83a0bf0..c9838123 100644 --- a/frontend/src/__tests__/auth.test.ts +++ b/frontend/src/__tests__/auth.test.ts @@ -440,7 +440,7 @@ describe('Auth Module', () => { document.body.innerHTML = ` Purchases diff --git a/frontend/src/__tests__/history-cancel-button.test.ts b/frontend/src/__tests__/history-cancel-button.test.ts index 42df7156..1fd71567 100644 --- a/frontend/src/__tests__/history-cancel-button.test.ts +++ b/frontend/src/__tests__/history-cancel-button.test.ts @@ -1,5 +1,6 @@ /** - * History inline Cancel button tests (issue #46). + * Approval queue Cancel button tests (issue #46). Cancel renders only in + * the Approval queue card (#1331), not in the History table. * * Mirror of the backend RBAC matrix in * internal/api/handler_purchases.go::authorizeSessionCancel — keep both @@ -154,7 +155,7 @@ function makeRow(overrides: Record) { }; } -describe('History inline Cancel button (issue #46)', () => { +describe('Approval queue Cancel button (issue #46)', () => { beforeEach(() => { setupDOM(); jest.clearAllMocks(); @@ -252,6 +253,29 @@ describe('History inline Cancel button (issue #46)', () => { expect(api.cancelPurchase).not.toHaveBeenCalled(); }); + test('confirm names the purchase being cancelled (issue #728)', async () => { + (getCurrentUser as jest.Mock).mockReturnValue(ADMIN_USER); + (confirmDialog as jest.Mock).mockResolvedValue(false); + (api.getHistory as jest.Mock).mockResolvedValue({ + summary: {}, + purchases: [ + makeRow({ purchase_id: 'exec-1', created_by_user_id: ADMIN_USER.id }), + makeRow({ purchase_id: 'exec-2', resource_type: 'm5.xlarge', count: 3, region: 'eu-west-1', upfront_cost: 4200, created_by_user_id: ADMIN_USER.id }), + ], + }); + + await loadHistory(); + document.querySelector('.history-cancel-btn[data-cancel-id="exec-2"]')!.click(); + await new Promise((r) => setTimeout(r, 0)); + + const body = (confirmDialog as jest.Mock).mock.calls[0][0].body as HTMLElement; + expect(body.querySelector('.history-confirm-summary strong')?.textContent).toBe('AWS ec2 m5.xlarge × 3 in eu-west-1'); + // formatCurrency is mocked here; purchases-placeholders-181.test.ts + // pins the real "Upfront: $4,200" output with the real utils. + expect(body.textContent).toContain('Upfront:'); + expect(body.textContent).toContain('The pending email approval link will stop working.'); + }); + test('accepted confirm posts cancel + reloads + toasts', async () => { (getCurrentUser as jest.Mock).mockReturnValue(ADMIN_USER); (confirmDialog as jest.Mock).mockResolvedValue(true); diff --git a/frontend/src/__tests__/history-retry-button.test.ts b/frontend/src/__tests__/history-retry-button.test.ts index b50b702a..4321d59b 100644 --- a/frontend/src/__tests__/history-retry-button.test.ts +++ b/frontend/src/__tests__/history-retry-button.test.ts @@ -397,6 +397,23 @@ describe('History inline Retry button (issue #47)', () => { expect(api.retryPurchase).not.toHaveBeenCalled(); }); + test('confirm names the purchase being retried (issue #728)', async () => { + (getCurrentUser as jest.Mock).mockReturnValue(ADMIN_USER); + (confirmDialog as jest.Mock).mockResolvedValue(false); + (api.getHistory as jest.Mock).mockResolvedValue({ + summary: {}, + purchases: [makeRow({ purchase_id: 'r-1', service: 'rds', resource_type: 'db.r5.large', count: 2, created_by_user_id: ADMIN_USER.id })], + }); + + await loadHistory(); + document.querySelector('.history-retry-btn')!.click(); + await new Promise((r) => setTimeout(r, 0)); + + const body = (confirmDialog as jest.Mock).mock.calls[0][0].body as HTMLElement; + expect(body.querySelector('.history-confirm-summary strong')?.textContent).toBe('AWS rds db.r5.large × 2 in us-east-1'); + expect(body.textContent).toContain('This will create a new purchase execution'); + }); + test('accepted confirm posts retry + reloads + success toast when email_sent is true', async () => { (getCurrentUser as jest.Mock).mockReturnValue(ADMIN_USER); (confirmDialog as jest.Mock).mockResolvedValue(true); diff --git a/frontend/src/__tests__/ladder-timeline.test.ts b/frontend/src/__tests__/ladder-timeline.test.ts index 366458bf..f9fddf63 100644 --- a/frontend/src/__tests__/ladder-timeline.test.ts +++ b/frontend/src/__tests__/ladder-timeline.test.ts @@ -5,7 +5,7 @@ jest.mock('../permissions', () => ({ canAccess: jest.fn(() => true) })); import { listAccountsMinimal } from '../api/accounts'; import { getLadderEvents, getLadderRuns, amendLadderEvent, type LadderEvent, type LadderRun } from '../api/ladder'; -import { initLadderTimeline, disposeLadderTimeline } from '../ladder-timeline'; +import { initLadderTimeline, refreshLadderTimeline, disposeLadderTimeline } from '../ladder-timeline'; import { canAccess } from '../permissions'; import { Chart } from 'chart.js'; @@ -361,6 +361,22 @@ test('editor labels each row by its basis and keeps the native amount', async () expect(open('m3')).toBe('Planning budget (on-demand-equivalent $/hr)|1.000000'); }); +test('refreshLadderTimeline re-evaluates the state after a config save and a kill-switch toggle (issue #728)', async () => { + await initLadderTimeline(document.getElementById('timeline')!, [], false); + const state = (): string => document.querySelector('[data-state]')!.textContent ?? ''; + expect(state()).toContain('No ladder configuration'); + + const cfg = { cloud_account_id: 'account', provider: 'aws', enabled: true } as unknown as Parameters[0][number]; + refreshLadderTimeline([cfg]); + await new Promise(resolve => setTimeout(resolve, 0)); + expect(state()).toContain('Ladder planning is disabled'); + + refreshLadderTimeline([cfg], true); + await new Promise(resolve => setTimeout(resolve, 0)); + expect(state()).toContain('Timeline loaded'); + expect(getLadderEvents).toHaveBeenCalledTimes(3); +}); + describe('planned purchases layout and UTC date filters (issue #753)', () => { const date = (edge: 'from' | 'to'): HTMLInputElement => document.querySelector(`[data-date="${edge}"]`)!; const type = (edge: 'from' | 'to', value: string): void => { date(edge).value = value; date(edge).dispatchEvent(new Event('change')); }; diff --git a/frontend/src/__tests__/ladder.test.ts b/frontend/src/__tests__/ladder.test.ts index 32809282..254cc86b 100644 --- a/frontend/src/__tests__/ladder.test.ts +++ b/frontend/src/__tests__/ladder.test.ts @@ -12,10 +12,11 @@ import type { LadderConfig } from '../api'; import { waitFor } from '@testing-library/dom'; import { listAccountsMinimal, type AccountSummary } from '../api/accounts'; import { canAccess } from '../permissions'; +import { refreshLadderTimeline } from '../ladder-timeline'; import { confirmDialog } from '../confirmDialog'; jest.mock('../api/accounts', () => ({ listAccountsMinimal: jest.fn() })); -jest.mock('../ladder-timeline', () => ({ initLadderTimeline: jest.fn() })); +jest.mock('../ladder-timeline', () => ({ initLadderTimeline: jest.fn(), refreshLadderTimeline: jest.fn() })); jest.mock('../api', () => ({ getLadderConfigs: jest.fn(), @@ -126,6 +127,8 @@ describe('ladder.ts', () => { expect(api.upsertLadderConfig).toHaveBeenCalledTimes(1); const { updated_at: _updatedAt, ...expected } = config; expect(api.upsertLadderConfig).toHaveBeenCalledWith({ ...expected, target_coverage: 75 }); + // Issue #728: the timeline picks up the saved config without a reload. + expect(refreshLadderTimeline).toHaveBeenCalledWith(expect.arrayContaining([expect.objectContaining({ cloud_account_id: config.cloud_account_id })])); }); describe('renderLadderingSection: toggle label accessibility (issue #1412 row 3.1)', () => { @@ -183,6 +186,8 @@ describe('ladder.ts', () => { expect(api.updateConfig).toHaveBeenCalledWith({ laddering_enabled: !before }); expect(toggle.checked).toBe(!before); await waitFor(() => expect(mockShowToast).toHaveBeenCalledWith(expect.objectContaining({ kind: 'success' }))); + // Issue #728: the timeline re-evaluates with the new kill-switch state. + expect(refreshLadderTimeline).toHaveBeenCalledWith(expect.any(Array), !before); }); test('a failed PUT reverts the toggle and shows an error toast', async () => { diff --git a/frontend/src/__tests__/mobile-nav.test.ts b/frontend/src/__tests__/mobile-nav.test.ts index a5405c4c..e625153f 100644 --- a/frontend/src/__tests__/mobile-nav.test.ts +++ b/frontend/src/__tests__/mobile-nav.test.ts @@ -22,7 +22,7 @@ function buildDOM(): void { API Docs - + diff --git a/frontend/src/__tests__/purchases-placeholders-181.test.ts b/frontend/src/__tests__/purchases-placeholders-181.test.ts index f7964f7a..e5082871 100644 --- a/frontend/src/__tests__/purchases-placeholders-181.test.ts +++ b/frontend/src/__tests__/purchases-placeholders-181.test.ts @@ -9,7 +9,9 @@ */ import { loadHistory } from '../history'; -import { hasNoCommitments, isRealTerm, isPastScheduledDate } from '../lib/purchase-row-guards'; +import { hasNoCommitments, isRealTerm, isPastScheduledDate, NO_RECOMMENDATIONS_LABEL } from '../lib/purchase-row-guards'; +import { describeHistoryPurchase } from '../history-confirm-body'; +import type { HistoryPurchase } from '../types'; jest.mock('../api', () => ({ getHistory: jest.fn(), @@ -61,6 +63,7 @@ jest.mock('../recommendations', () => ({ })); import * as api from '../api'; +import { confirmDialog } from '../confirmDialog'; import { getCurrentUser } from '../state'; import { ADMINISTRATORS_GROUP_ID } from '../permissions'; @@ -167,3 +170,64 @@ describe('Purchases tables never render impossible values (#181)', () => { expect(row.join(' ')).not.toContain('0 Years'); }); }); + +// The Cancel and Retry confirms (#728) summarise the row they act on, so +// they must apply the same guards as the row: a no-commitments execution +// must not read "multiple 0 commitment(s) in multiple ... Upfront: $0". +describe('Cancel and Retry confirms apply the no-commitments guards (#181, #728)', () => { + const EMPTY_FAILED = { ...EMPTY_PENDING, purchase_id: 'exec-empty-failed', status: 'failed' }; + const REAL_PENDING = { + ...EMPTY_PENDING, + purchase_id: 'exec-real', + provider: 'aws', + service: 'ec2', + resource_type: 'm5.xlarge', + region: 'eu-west-1', + count: 3, + term: 1, + upfront_cost: 4200, + }; + + beforeEach(() => { + setupDOM(); + jest.clearAllMocks(); + (getCurrentUser as jest.Mock).mockReturnValue(ADMIN_USER); + (confirmDialog as jest.Mock).mockResolvedValue(false); + (api.getHistory as jest.Mock).mockResolvedValue({ summary: {}, purchases: [EMPTY_PENDING, EMPTY_FAILED, REAL_PENDING] }); + }); + + const confirmBodyFor = async (selector: string): Promise => { + await loadHistory(); + document.querySelector(selector)!.click(); + await new Promise((r) => setTimeout(r, 0)); + expect(confirmDialog).toHaveBeenCalledTimes(1); + return (confirmDialog as jest.Mock).mock.calls[0][0].body as HTMLElement; + }; + + const expectNoPlaceholders = (body: HTMLElement): void => { + expect(body.querySelector('.history-confirm-summary strong')?.textContent).toBe(NO_RECOMMENDATIONS_LABEL); + const text = body.textContent || ''; + expect(text).not.toMatch(/multiple|0 commitment\(s\)|Upfront|\$0|0 Years/i); + expect(text).toContain('Created:'); + }; + + test('Cancel on a pending row with no commitments says so instead of printing placeholders', async () => { + expectNoPlaceholders(await confirmBodyFor('#purchases-approval-queue .history-cancel-btn[data-cancel-id="exec-empty"]')); + }); + + test('Retry on a failed row with no commitments says so instead of printing placeholders', async () => { + expectNoPlaceholders(await confirmBodyFor('#history-list .history-retry-btn[data-retry-id="exec-empty-failed"]')); + }); + + test('Cancel on a real row names it and shows upfront in the shipped currency format', async () => { + const body = await confirmBodyFor('#purchases-approval-queue .history-cancel-btn[data-cancel-id="exec-real"]'); + expect(body.querySelector('.history-confirm-summary strong')?.textContent).toBe('AWS ec2 m5.xlarge × 3 in eu-west-1'); + expect(body.textContent).toContain('Upfront: $4,200'); + expect(body.textContent).toContain('Term: 1 Year'); + }); + + test('a "multiple" region is left out like a "multiple" provider', () => { + const p = { ...REAL_PENDING, resource_type: '2 commitment(s)', count: 2, region: 'multiple' } as unknown as HistoryPurchase; + expect(describeHistoryPurchase(p)).toBe('AWS ec2 2 commitment(s) × 2'); + }); +}); diff --git a/frontend/src/__tests__/settings-accounts.test.ts b/frontend/src/__tests__/settings-accounts.test.ts index 02bc8b53..86dc79a8 100644 --- a/frontend/src/__tests__/settings-accounts.test.ts +++ b/frontend/src/__tests__/settings-accounts.test.ts @@ -1524,8 +1524,8 @@ describe('Account overrides modal', () => { 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); + // 10-second TTL (issue #728; was 5 s per issue #113). + expect(undoToast!.timeout).toBe(10_000); // Simulate clicking Undo. undoToast!.actions![0]!.onClick(); @@ -1580,7 +1580,7 @@ describe('Account overrides modal', () => { }>).find(t => t.kind === 'info' && t.message?.includes('aws/rds')); expect(undoToast).toBeDefined(); expect(undoToast!.actions?.[0]?.label).toBe('Undo'); - expect(undoToast!.timeout).toBe(5_000); + expect(undoToast!.timeout).toBe(10_000); // Let the pending refresh resolve so the async chain completes cleanly. resolveRefresh(); diff --git a/frontend/src/__tests__/toast.test.ts b/frontend/src/__tests__/toast.test.ts index 0acee6f2..e4c35b8a 100644 --- a/frontend/src/__tests__/toast.test.ts +++ b/frontend/src/__tests__/toast.test.ts @@ -105,6 +105,59 @@ describe('showToast', () => { showToast({ message: 'ok', kind: 'success' }); expect(document.querySelector('.toast--success')?.getAttribute('role')).toBe('status'); }); + + describe('pause while hovered or focused (issue #728, WCAG 2.2.1)', () => { + const toastEl = (): HTMLElement => document.querySelector('.toast') as HTMLElement; + + it('hover pauses the countdown and leaving resumes it with the time left', () => { + showToast({ message: 'read me', timeout: 5_000 }); + jest.advanceTimersByTime(3_000); + toastEl().dispatchEvent(new MouseEvent('mouseenter')); + jest.advanceTimersByTime(60_000); + expect(toastEl()).not.toBeNull(); + + toastEl().dispatchEvent(new MouseEvent('mouseleave')); + jest.advanceTimersByTime(1_999); + expect(toastEl().classList.contains('toast--leaving')).toBe(false); + jest.advanceTimersByTime(2 + 200); + expect(document.querySelector('.toast')).toBeNull(); + }); + + it('focus inside the toast pauses it until focus leaves the toast', () => { + const handler = jest.fn(); + showToast({ message: 'undo?', timeout: 5_000, actions: [{ label: 'Undo', onClick: handler }] }); + const undo = toastEl().querySelector('button') as HTMLButtonElement; + undo.focus(); + jest.advanceTimersByTime(60_000); + expect(toastEl()).not.toBeNull(); + + // Moving focus within the toast keeps it paused. + (toastEl().querySelector('.toast-close') as HTMLButtonElement).focus(); + jest.advanceTimersByTime(60_000); + expect(toastEl()).not.toBeNull(); + + (document.activeElement as HTMLElement).blur(); + jest.advanceTimersByTime(5_000 + 200); + expect(document.querySelector('.toast')).toBeNull(); + }); + + it('resumes with at least one second so it does not vanish on mouse-out', () => { + showToast({ message: 'almost gone', timeout: 5_000 }); + jest.advanceTimersByTime(4_990); + toastEl().dispatchEvent(new MouseEvent('mouseenter')); + toastEl().dispatchEvent(new MouseEvent('mouseleave')); + jest.advanceTimersByTime(900); + expect(toastEl().classList.contains('toast--leaving')).toBe(false); + }); + + it('sticky toasts stay sticky after hover', () => { + showToast({ message: 'stay', timeout: null }); + toastEl().dispatchEvent(new MouseEvent('mouseenter')); + toastEl().dispatchEvent(new MouseEvent('mouseleave')); + jest.advanceTimersByTime(120_000); + expect(toastEl()).not.toBeNull(); + }); + }); }); // Issue #742: a toast must not sit on top of an open dialog's Save button, diff --git a/frontend/src/history-confirm-body.ts b/frontend/src/history-confirm-body.ts new file mode 100644 index 00000000..e57302c0 --- /dev/null +++ b/frontend/src/history-confirm-body.ts @@ -0,0 +1,58 @@ +/** + * Confirm-dialog body that names the purchase a History row action will + * act on (issue #728, UX-29). Cancel and Retry used to say only "Cancel + * this pending purchase?", so in a long table the user could not tell + * which row they were confirming. The summary mirrors the row: provider, + * service, resource and count, region, account, upfront cost and date. + * A row with no commitments (#181) shows the same "No recommendations + * attached" label as the row instead of its placeholder values. + */ + +import type { HistoryPurchase } from './types'; +import { getAccountName } from './recommendations'; +import { formatCurrency, formatDate, formatTerm } from './utils'; +import { hasNoCommitments, isRealTerm, NO_RECOMMENDATIONS_LABEL } from './lib/purchase-row-guards'; + +/** The API's "multiple" placeholder for a basket spanning providers or regions. */ +const specific = (v: string | undefined): string => (v && v !== 'multiple' ? v : ''); + +/** One-line description of a History row, e.g. "AWS ec2 m5.large × 2 in us-east-1". */ +export function describeHistoryPurchase(p: HistoryPurchase): string { + if (hasNoCommitments(p)) return NO_RECOMMENDATIONS_LABEL; + const provider = specific(p.provider).toUpperCase(); + const what = [provider, p.service, p.resource_type].filter(Boolean).join(' '); + const count = p.count > 1 ? ` × ${p.count}` : ''; + const region = specific(p.region) ? ` in ${p.region}` : ''; + return `${what || 'Purchase'}${count}${region}`; +} + +/** + * Body element for a row-action confirm: a definition-style summary of the + * purchase followed by the action's explanation. + */ +export function historyConfirmBody(p: HistoryPurchase | undefined, explanation: string): HTMLElement { + const wrap = document.createElement('div'); + wrap.className = 'history-confirm-body'; + if (p) { + const summary = document.createElement('p'); + summary.className = 'history-confirm-summary'; + const name = document.createElement('strong'); + name.textContent = describeHistoryPurchase(p); + summary.appendChild(name); + const facts: string[] = []; + if (p.account_id) facts.push(`Account: ${getAccountName(p.account_id)}`); + if (isRealTerm(p.term)) facts.push(`Term: ${formatTerm(p.term)}`); + if (p.upfront_cost != null && !hasNoCommitments(p)) facts.push(`Upfront: ${formatCurrency(p.upfront_cost)}`); + const date = formatDate(p.timestamp); + if (date) facts.push(`Created: ${date}`); + if (facts.length > 0) { + summary.appendChild(document.createElement('br')); + summary.appendChild(document.createTextNode(facts.join(' · '))); + } + wrap.appendChild(summary); + } + const text = document.createElement('p'); + text.textContent = explanation; + wrap.appendChild(text); + return wrap; +} diff --git a/frontend/src/history.ts b/frontend/src/history.ts index 822b9d28..f6716a8d 100644 --- a/frontend/src/history.ts +++ b/frontend/src/history.ts @@ -5,6 +5,7 @@ import * as api from './api'; import * as state from './state'; import { formatCurrency, formatDate, formatTerm, escapeHtml, escapeHtmlAttr, amortizedMonthly, NO_VALUE } from './utils'; +import { historyConfirmBody } from './history-confirm-body'; import type { HistoryResponse, HistorySummary, HistoryPurchase } from './types'; import type { RevokeQuote } from './api'; import { switchTab } from './navigation'; @@ -978,6 +979,11 @@ function sameRowActions(btn: HTMLButtonElement): HTMLButtonElement[] { ); } +/** The loaded History or Approval queue row for an execution ID. */ +function findHistoryRow(id: string): HistoryPurchase | undefined { + return lastPurchases.find(p => p.purchase_id === id) ?? lastPendingForQueue.find(p => p.purchase_id === id); +} + async function runHistoryRowAction( btn: HTMLButtonElement, id: string, @@ -1539,7 +1545,8 @@ function wireRowActionHandlers(container: HTMLElement): void { btn.addEventListener('click', () => runHistoryRowAction(btn, id, async markMutationSucceeded => { const ok = await confirmDialog({ title: 'Cancel this pending purchase?', - body: 'This will permanently abort the approval flow. The pending email approval link will stop working. This action cannot be undone.', + // Issue #728: name the purchase so the right row is confirmed. + body: historyConfirmBody(findHistoryRow(id), 'This will permanently abort the approval flow. The pending email approval link will stop working. This action cannot be undone.'), confirmLabel: 'Cancel purchase', destructive: true, }); @@ -1571,9 +1578,9 @@ function wireRowActionHandlers(container: HTMLElement): void { const overThreshold = btn.classList.contains('history-retry-over-threshold'); const ok = await confirmDialog({ title: overThreshold ? 'Retry past threshold?' : 'Retry this failed purchase?', - body: overThreshold + body: historyConfirmBody(findHistoryRow(id), overThreshold ? 'The same recommendations have already failed multiple times. Are you sure you want to retry again? This may not succeed.' - : 'This will create a new purchase execution from the same recommendations. The original failed row will be linked to the new attempt.', + : 'This will create a new purchase execution from the same recommendations. The original failed row will be linked to the new attempt.'), confirmLabel: overThreshold ? 'Retry anyway' : 'Retry purchase', destructive: false, }); diff --git a/frontend/src/index.html b/frontend/src/index.html index 16399565..7e4baac7 100644 --- a/frontend/src/index.html +++ b/frontend/src/index.html @@ -45,7 +45,7 @@

CUDly

API Docs - +