diff --git a/frontend/src/__tests__/settings-accounts.test.ts b/frontend/src/__tests__/settings-accounts.test.ts index f50145a01..25d199e57 100644 --- a/frontend/src/__tests__/settings-accounts.test.ts +++ b/frontend/src/__tests__/settings-accounts.test.ts @@ -165,18 +165,29 @@ function buildAccountsDOM(): void { modal.appendChild(btn('close-account-modal-btn')); document.body.appendChild(modal); - // ── Override modal (issue #104) ──────────────────────────── + // ── Override modal (issue #104, bulk multi-service mode issue #119) ─── const overrideModal = div('override-modal', 'modal hidden'); const overrideForm = el('form', {}, 'override-form'); overrideForm.appendChild(input('override-account-id', 'hidden')); overrideForm.appendChild(input('override-provider', 'hidden')); - overrideForm.appendChild(select('override-service', [])); + // Single-service row (default mode). + const singleRow = div('override-single-service-row'); + singleRow.appendChild(select('override-service', [])); + overrideForm.appendChild(singleRow); + // Bulk-mode toggle (issue #119). + const bulkToggle = input('override-bulk-toggle', 'checkbox'); + overrideForm.appendChild(bulkToggle); + // Bulk services container (hidden by default). + const bulkServicesDiv = div('override-bulk-services', 'hidden'); + const bulkServicesList = div('override-bulk-services-list'); + bulkServicesDiv.appendChild(bulkServicesList); + overrideForm.appendChild(bulkServicesDiv); overrideForm.appendChild(select('override-term', ['', '1', '3'])); overrideForm.appendChild(select('override-payment', ['', 'no-upfront', 'partial-upfront', 'all-upfront'])); overrideForm.appendChild(input('override-coverage', 'number')); const overrideErr = el('p', {}, 'override-form-error'); overrideForm.appendChild(overrideErr); - const overrideSubmit = el('button', { type: 'submit' }) as HTMLButtonElement; + const overrideSubmit = el('button', { type: 'submit' }, 'override-submit-btn') as HTMLButtonElement; overrideSubmit.textContent = 'Save override'; overrideForm.appendChild(overrideSubmit); overrideModal.appendChild(overrideForm); @@ -1817,3 +1828,310 @@ describe('Override modal Inherit labels (issue #112)', () => { expect(covInput.placeholder).toBe('Inherit (currently: 70%)'); }); }); + +// --------------------------------------------------------------------------- +// Bulk multi-service override modal — issue #119 +// --------------------------------------------------------------------------- + +describe('Bulk override modal (issue #119)', () => { + /** + * Open the override modal in bulk mode with no pre-existing overrides, + * toggle the bulk switch, and return the DOM elements needed by tests. + */ + async function openBulkModal(accountId = 'acc-1'): Promise<{ + modal: HTMLElement; + bulkToggle: HTMLInputElement; + bulkList: HTMLElement; + submitBtn: HTMLButtonElement; + form: HTMLFormElement; + }> { + (api.listAccounts as jest.Mock).mockResolvedValue([ + { id: accountId, name: 'Prod', provider: 'aws', external_id: '111', enabled: true }, + ]); + (api.listAccountServiceOverrides as jest.Mock).mockResolvedValue([]); + await loadAccountsForProvider('aws'); + + const overridesBtn = document.querySelector( + `button[aria-label="Service overrides for Prod (111)"]`, + ) as HTMLButtonElement; + expect(overridesBtn).not.toBeNull(); + overridesBtn.click(); + await new Promise(r => setTimeout(r, 0)); + + const modal = document.getElementById('override-modal') as HTMLElement; + const bulkToggle = document.getElementById('override-bulk-toggle') as HTMLInputElement; + const bulkList = document.getElementById('override-bulk-services-list') as HTMLElement; + const submitBtn = document.getElementById('override-submit-btn') as HTMLButtonElement; + const form = document.getElementById('override-form') as HTMLFormElement; + + // Flip to bulk mode. + bulkToggle.checked = true; + bulkToggle.dispatchEvent(new Event('change')); + + return { modal, bulkToggle, bulkList, submitBtn, form }; + } + + beforeEach(() => { + buildAccountsDOM(); + setupSettingsHandlers(); + jest.clearAllMocks(); + }); + + test('bulk toggle hides single-service row and shows checkbox list', async () => { + const { bulkList } = await openBulkModal(); + + const singleRow = document.getElementById('override-single-service-row') as HTMLElement; + const bulkDiv = document.getElementById('override-bulk-services') as HTMLElement; + + expect(singleRow.classList.contains('hidden')).toBe(true); + expect(bulkDiv.classList.contains('hidden')).toBe(false); + + // All 9 AWS services should be rendered as checkboxes. + const checkboxes = bulkList.querySelectorAll('input[type="checkbox"]'); + expect(checkboxes).toHaveLength(9); + const values = Array.from(checkboxes).map(cb => (cb as HTMLInputElement).value); + expect(values).toContain('ec2'); + expect(values).toContain('rds'); + expect(values).toContain('elasticache'); + }); + + test('submit is disabled until at least one checkbox is checked', async () => { + const { submitBtn, bulkList } = await openBulkModal(); + + // No boxes checked yet. + expect(submitBtn.disabled).toBe(true); + + // Check one box. + const firstCb = bulkList.querySelector('input[type="checkbox"]') as HTMLInputElement; + firstCb.checked = true; + firstCb.dispatchEvent(new Event('change')); + expect(submitBtn.disabled).toBe(false); + }); + + test('all 3 saves succeed: aggregated success toast, modal closes, panel reloads', async () => { + (api.listAccountServiceOverrides as jest.Mock) + .mockResolvedValueOnce([]) // initial: empty -> auto-opens modal + .mockResolvedValueOnce([ // reload after save: returns new rows so modal stays closed + { id: 'o1', account_id: 'acc-1', provider: 'aws', service: 'rds', term: 1 }, + { id: 'o2', account_id: 'acc-1', provider: 'aws', service: 'elasticache', term: 1 }, + { id: 'o3', account_id: 'acc-1', provider: 'aws', service: 'opensearch', term: 1 }, + ]); + (api.saveAccountServiceOverride as jest.Mock).mockResolvedValue({}); + + const { form, bulkList } = await openBulkModal(); + + // Select rds, elasticache, opensearch. + const targets = ['rds', 'elasticache', 'opensearch']; + for (const svc of targets) { + const cb = bulkList.querySelector(`input[value="${svc}"]`) as HTMLInputElement; + cb.checked = true; + cb.dispatchEvent(new Event('change')); + } + (document.getElementById('override-payment') as HTMLSelectElement).value = 'all-upfront'; + + form.dispatchEvent(new Event('submit', { cancelable: true })); + // Flush all async chains: allSettled + loadOverridesPanel + recommendations. + for (let i = 0; i < 10; i++) await new Promise(r => setTimeout(r, 0)); + + expect(api.saveAccountServiceOverride).toHaveBeenCalledTimes(3); + const toastArg = mockShowToast.mock.calls[0]?.[0] as { message: string; kind: string }; + expect(toastArg.kind).toBe('success'); + expect(toastArg.message).toMatch(/Created 3 overrides/); + + // Modal should be hidden after success. + const modal = document.getElementById('override-modal') as HTMLElement; + expect(modal.classList.contains('hidden')).toBe(true); + }); + + test('2 successes / 1 failure: warning toast names the failed service', async () => { + (api.listAccountServiceOverrides as jest.Mock) + .mockResolvedValueOnce([]) // initial: empty -> auto-opens modal + .mockResolvedValueOnce([ // reload after partial save: non-empty keeps modal closed + { id: 'o1', account_id: 'acc-1', provider: 'aws', service: 'rds', term: 1 }, + { id: 'o2', account_id: 'acc-1', provider: 'aws', service: 'opensearch', term: 1 }, + ]); + (api.saveAccountServiceOverride as jest.Mock) + .mockResolvedValueOnce({}) // rds OK + .mockRejectedValueOnce(new Error('server error')) // elasticache fails + .mockResolvedValueOnce({}); // opensearch OK + + const { form, bulkList } = await openBulkModal(); + + const targets = ['rds', 'elasticache', 'opensearch']; + for (const svc of targets) { + const cb = bulkList.querySelector(`input[value="${svc}"]`) as HTMLInputElement; + cb.checked = true; + cb.dispatchEvent(new Event('change')); + } + (document.getElementById('override-payment') as HTMLSelectElement).value = 'no-upfront'; + + form.dispatchEvent(new Event('submit', { cancelable: true })); + // Flush all async chains: allSettled + loadOverridesPanel + recommendations. + for (let i = 0; i < 10; i++) await new Promise(r => setTimeout(r, 0)); + + expect(api.saveAccountServiceOverride).toHaveBeenCalledTimes(3); + const toastArg = mockShowToast.mock.calls[0]?.[0] as { message: string; kind: string }; + expect(toastArg.kind).toBe('warning'); + expect(toastArg.message).toMatch(/Created 2 overrides/); + expect(toastArg.message).toContain('elasticache'); + + // Modal closes even on partial success. + const modal = document.getElementById('override-modal') as HTMLElement; + expect(modal.classList.contains('hidden')).toBe(true); + }); + + test('all saves fail: error shown in form, modal stays open', async () => { + (api.listAccountServiceOverrides as jest.Mock).mockResolvedValue([]); + (api.saveAccountServiceOverride as jest.Mock).mockRejectedValue(new Error('timeout')); + + const { form, bulkList } = await openBulkModal(); + + const targets = ['rds', 'elasticache']; + for (const svc of targets) { + const cb = bulkList.querySelector(`input[value="${svc}"]`) as HTMLInputElement; + cb.checked = true; + cb.dispatchEvent(new Event('change')); + } + (document.getElementById('override-payment') as HTMLSelectElement).value = 'no-upfront'; + + form.dispatchEvent(new Event('submit', { cancelable: true })); + await new Promise(r => setTimeout(r, 0)); + + // No toast on total failure. + expect(mockShowToast).not.toHaveBeenCalled(); + + const errEl = document.getElementById('override-form-error') as HTMLElement; + expect(errEl.textContent).toMatch(/failed/i); + + // Modal stays open so user can retry. + const modal = document.getElementById('override-modal') as HTMLElement; + expect(modal.classList.contains('hidden')).toBe(false); + }); + + test('submit without any box checked shows inline error, never calls API', async () => { + (api.listAccountServiceOverrides as jest.Mock).mockResolvedValue([]); + (api.saveAccountServiceOverride as jest.Mock).mockResolvedValue({}); + + const { form } = await openBulkModal(); + + (document.getElementById('override-payment') as HTMLSelectElement).value = 'no-upfront'; + // No checkboxes checked. + form.dispatchEvent(new Event('submit', { cancelable: true })); + await new Promise(r => setTimeout(r, 0)); + + expect(api.saveAccountServiceOverride).not.toHaveBeenCalled(); + const errEl = document.getElementById('override-form-error') as HTMLElement; + expect(errEl.textContent).toMatch(/Select at least one/i); + }); + + test('already-overridden services are excluded from the checkbox list', async () => { + (api.listAccounts as jest.Mock).mockResolvedValue([ + { id: 'acc-1', name: 'Prod', external_id: '111', enabled: true, provider: 'aws' }, + ]); + (api.listAccountServiceOverrides as jest.Mock).mockResolvedValue([ + { id: 'o1', account_id: 'acc-1', provider: 'aws', service: 'ec2', term: 1 }, + { id: 'o2', account_id: 'acc-1', provider: 'aws', service: 'rds', term: 1 }, + ]); + await loadAccountsForProvider('aws'); + + const overridesBtn = document.querySelector( + `button[aria-label="Service overrides for Prod (111)"]`, + ) as HTMLButtonElement; + // Panel has existing overrides so the modal is not auto-opened; open manually. + const panel = document.getElementById('account-overrides-modal-body') as HTMLElement; + overridesBtn.click(); + await new Promise(r => setTimeout(r, 0)); + + const addBtn = Array.from(panel.querySelectorAll('button')).find(b => b.textContent === 'Add override'); + expect(addBtn).toBeDefined(); + addBtn!.click(); + + const bulkToggle = document.getElementById('override-bulk-toggle') as HTMLInputElement; + bulkToggle.checked = true; + bulkToggle.dispatchEvent(new Event('change')); + + const bulkList = document.getElementById('override-bulk-services-list') as HTMLElement; + const values = Array.from(bulkList.querySelectorAll('input[type="checkbox"]')).map( + cb => (cb as HTMLInputElement).value, + ); + expect(values).not.toContain('ec2'); + expect(values).not.toContain('rds'); + expect(values).toContain('elasticache'); + }); + + test('closing the modal resets bulk toggle to off', async () => { + const { bulkToggle } = await openBulkModal(); + expect(bulkToggle.checked).toBe(true); + + (document.getElementById('close-override-modal-btn') as HTMLButtonElement).click(); + + expect(bulkToggle.checked).toBe(false); + const singleRow = document.getElementById('override-single-service-row') as HTMLElement; + expect(singleRow.classList.contains('hidden')).toBe(false); + }); + + // Preventive conflict gating (issue #119): selecting an incompatible + // term/payment combo must disable the conflicting service checkbox BEFORE + // the user hits Save, so the all-or-nothing submit block is unreachable in + // normal use. The only AWS hard restriction is rds + 3yr + no-upfront. + test('selecting 3yr/no-upfront disables rds checkbox and API is never called', async () => { + (api.listAccountServiceOverrides as jest.Mock).mockResolvedValue([]); + (api.saveAccountServiceOverride as jest.Mock).mockResolvedValue({}); + + const { bulkList, form } = await openBulkModal(); + + // Check rds first so it is "selected" before the conflict appears. + const rdsCb = bulkList.querySelector('input[value="rds"]'); + expect(rdsCb).not.toBeNull(); + rdsCb!.checked = true; + rdsCb!.dispatchEvent(new Event('change')); + expect(rdsCb!.disabled).toBe(false); + + // Set term = 3, then payment = no-upfront (the invalid combo for rds). + const termSel = document.getElementById('override-term') as HTMLSelectElement; + termSel.value = '3'; + termSel.dispatchEvent(new Event('change')); + + const paymentSel = document.getElementById('override-payment') as HTMLSelectElement; + paymentSel.value = 'no-upfront'; + paymentSel.dispatchEvent(new Event('change')); + + // rds must now be disabled and unchecked. + expect(rdsCb!.disabled).toBe(true); + expect(rdsCb!.checked).toBe(false); + + // A service with no restriction (e.g. ec2) stays enabled. + const ec2Cb = bulkList.querySelector('input[value="ec2"]'); + expect(ec2Cb!.disabled).toBe(false); + + // Attempting to submit with rds disabled (nothing checked) must not call + // the API and must show an inline error. + form.dispatchEvent(new Event('submit', { cancelable: true })); + await new Promise(r => setTimeout(r, 0)); + expect(api.saveAccountServiceOverride).not.toHaveBeenCalled(); + }); + + test('switching back to a compatible combo re-enables the rds checkbox', async () => { + (api.listAccountServiceOverrides as jest.Mock).mockResolvedValue([]); + + const { bulkList } = await openBulkModal(); + + // Trigger the invalid combo. + const termSel = document.getElementById('override-term') as HTMLSelectElement; + termSel.value = '3'; + termSel.dispatchEvent(new Event('change')); + const paymentSel = document.getElementById('override-payment') as HTMLSelectElement; + paymentSel.value = 'no-upfront'; + paymentSel.dispatchEvent(new Event('change')); + + const rdsCb = bulkList.querySelector('input[value="rds"]'); + expect(rdsCb!.disabled).toBe(true); + + // Switch payment to a valid combo (partial-upfront). + paymentSel.value = 'partial-upfront'; + paymentSel.dispatchEvent(new Event('change')); + + // rds should be re-enabled. + expect(rdsCb!.disabled).toBe(false); + }); +}); diff --git a/frontend/src/index.html b/frontend/src/index.html index 36b825be9..77544b6b5 100644 --- a/frontend/src/index.html +++ b/frontend/src/index.html @@ -1085,9 +1085,28 @@

Add service override

- + +
+ +
+ + +
+ Apply to multiple services: + +
+ + + + - +
+ Enabled: + +
@@ -1193,10 +1212,10 @@

AWS Authentication

Path to the OIDC token file (Azure/GCP projected token). Leave blank to use AWS_WEB_IDENTITY_TOKEN_FILE env var. - +
+ AWS Org Root: + +
diff --git a/frontend/src/settings.ts b/frontend/src/settings.ts index 87b96dd43..3615af42c 100644 --- a/frontend/src/settings.ts +++ b/frontend/src/settings.ts @@ -1436,7 +1436,13 @@ async function testAccount(accountId: string, accountLabel: string, btn: HTMLBut // State carried from openOverrideModal → submitOverrideForm so the form // handler knows which account/provider/panel it belongs to without // inspecting the DOM. Cleared on close so it can't leak across opens. -let overrideModalContext: { accountId: string; provider: string; panel: HTMLElement } | null = null; +let overrideModalContext: { + accountId: string; + provider: string; + panel: HTMLElement; + /** Services available for bulk selection (excludes already-overridden). */ + availableServices: ReadonlyArray<{ value: string; label: string }>; +} | null = null; let overridePaymentOptionsController: AbortController | null = null; /** @@ -1459,7 +1465,16 @@ export function openOverrideModal( const modal = document.getElementById('override-modal'); if (!modal) return; - overrideModalContext = { accountId, provider, panel }; + // Populate available-services list before writing context so bulk mode can + // reference it from the start. + const used = new Set( + existingOverrides + .filter(o => o.provider === provider) + .map(o => o.service), + ); + const available = getOverrideServicesForProvider(provider).filter(s => !used.has(s.value)); + + overrideModalContext = { accountId, provider, panel, availableServices: available }; setInputValue('override-account-id', accountId); setInputValue('override-provider', provider); @@ -1486,25 +1501,25 @@ export function openOverrideModal( coverageInput.placeholder = inheritCurrentlyLabel(`${cachedGlobalDefaults.coverage}%`); } - // Populate the service dropdown with provider-specific services, excluding - // any that already have an override for this account. Issue #109 extends - // this to Azure and GCP (previously AWS-only per issue #104 follow-up). - const used = new Set( - existingOverrides - .filter(o => o.provider === provider) - .map(o => o.service), - ); + // Reset bulk-mode toggle to off so the modal always opens in single-service + // mode (issue #119). + const bulkToggle = document.getElementById('override-bulk-toggle') as HTMLInputElement | null; + if (bulkToggle) bulkToggle.checked = false; + applyOverrideBulkMode(false, available); + + // Populate the single-service dropdown with provider-specific services, + // excluding any that already have an override for this account. Issue #109 + // extends this to Azure and GCP (previously AWS-only per issue #104 follow-up). const select = document.getElementById('override-service') as HTMLSelectElement | null; if (select) { select.replaceChildren(); - const available = getOverrideServicesForProvider(provider).filter(s => !used.has(s.value)); if (available.length === 0) { const opt = document.createElement('option'); opt.value = ''; opt.textContent = 'All services already have overrides'; opt.disabled = true; select.appendChild(opt); - const submitBtn = modal.querySelector('button[type="submit"]'); + const submitBtn = document.getElementById('override-submit-btn') as HTMLButtonElement | null; if (submitBtn) submitBtn.disabled = true; } else { for (const { value, label } of available) { @@ -1513,7 +1528,7 @@ export function openOverrideModal( opt.textContent = label; select.appendChild(opt); } - const submitBtn = modal.querySelector('button[type="submit"]'); + const submitBtn = document.getElementById('override-submit-btn') as HTMLButtonElement | null; if (submitBtn) submitBtn.disabled = false; } } @@ -1526,14 +1541,149 @@ export function openOverrideModal( overridePaymentOptionsController?.abort(); overridePaymentOptionsController = new AbortController(); syncOverridePaymentOptions(provider); - const onChange = () => syncOverridePaymentOptions(provider); + // After payment options update, re-evaluate bulk conflict gating (issue #119). + const onChange = () => { + syncOverridePaymentOptions(provider); + syncBulkServiceConflicts(provider); + }; select?.addEventListener('change', onChange, { signal: overridePaymentOptionsController.signal }); const termSel = document.getElementById('override-term') as HTMLSelectElement | null; termSel?.addEventListener('change', onChange, { signal: overridePaymentOptionsController.signal }); + const paymentSel = document.getElementById('override-payment') as HTMLSelectElement | null; + paymentSel?.addEventListener('change', onChange, { signal: overridePaymentOptionsController.signal }); + + // Wire the bulk-mode toggle (issue #119). AbortController cleans it up on close. + bulkToggle?.addEventListener( + 'change', + () => { + const ctx = overrideModalContext; + applyOverrideBulkMode(bulkToggle.checked, ctx?.availableServices ?? []); + }, + { signal: overridePaymentOptionsController.signal }, + ); modal.classList.remove('hidden'); } +/** + * Switch the override modal between single-service and bulk-service mode. + * + * In single mode the normal so the button is not left stuck disabled after + // toggling back from bulk mode. + if (submitBtn) { + const singleSelect = singleRow?.querySelector('select') as HTMLSelectElement | null; + submitBtn.disabled = (singleSelect?.value ?? '') === ''; + } + return; + } + + // Rebuild the checkbox list using DOM APIs (no innerHTML). + if (listDiv) { + listDiv.replaceChildren(); + for (const { value, label } of available) { + const checkLabel = document.createElement('label'); + checkLabel.className = 'bulk-service-checkbox-label'; + + const cb = document.createElement('input'); + cb.type = 'checkbox'; + cb.name = 'bulk-service'; + cb.value = value; + // Re-evaluate submit-button state whenever a checkbox changes. + cb.addEventListener('change', () => { + if (submitBtn) { + const anyChecked = listDiv.querySelectorAll('input[type="checkbox"]:checked').length > 0; + submitBtn.disabled = !anyChecked; + } + }); + + const span = document.createElement('span'); + span.textContent = label; + + checkLabel.appendChild(cb); + checkLabel.appendChild(span); + listDiv.appendChild(checkLabel); + } + } + + // In bulk mode start with submit disabled until at least one box is checked. + if (submitBtn) submitBtn.disabled = true; + + // Apply conflict gating for the current term/payment selection immediately + // after the list is built, so checkboxes are already disabled if the chosen + // combo is invalid for a service before the user interacts (issue #119). + syncBulkServiceConflicts(overrideModalContext?.provider ?? ''); +} + +/** + * Disable (and annotate) bulk-service checkboxes whose service does not + * support the currently selected (term, payment) combo. Re-enables them + * if the combo becomes valid again. Mirrors the single-select path that + * uses syncOverridePaymentOptions / isValidCombination. Issue #119. + * + * Only acts when both term and payment are explicitly set; when either is + * blank ("Inherit") every checkbox stays enabled because no hard restriction + * can be evaluated. + */ +function syncBulkServiceConflicts(provider: string): void { + const termRaw = (document.getElementById('override-term') as HTMLSelectElement | null)?.value ?? ''; + const paymentRaw = (document.getElementById('override-payment') as HTMLSelectElement | null)?.value ?? ''; + + const listDiv = document.getElementById('override-bulk-services-list'); + if (!listDiv) return; + + const checkboxes = listDiv.querySelectorAll('input[name="bulk-service"]'); + if (checkboxes.length === 0) return; + + // When term or payment is unset ("Inherit"), no restriction can be evaluated. + const term = termRaw !== '' ? parseInt(termRaw, 10) : NaN; + const payment = paymentRaw; + const canEvaluate = payment !== '' && Number.isFinite(term) && term > 0; + + for (const cb of checkboxes) { + const service = cb.value; + const label = cb.closest('label'); + if (canEvaluate && !isValidCombination(provider, service, term, payment)) { + cb.disabled = true; + cb.checked = false; + if (label) { + label.title = `${provider}/${service} does not support ${term}-year ${payment}`; + label.classList.add('bulk-service-checkbox-label--conflict'); + } + } else { + cb.disabled = false; + if (label) { + label.title = ''; + label.classList.remove('bulk-service-checkbox-label--conflict'); + } + } + } + + // Re-evaluate submit-button state after disabling/enabling checkboxes. + const submitBtn = document.getElementById('override-submit-btn') as HTMLButtonElement | null; + if (submitBtn) { + const anyChecked = listDiv.querySelectorAll('input[type="checkbox"]:checked').length > 0; + submitBtn.disabled = !anyChecked; + } +} + // syncOverridePaymentOptions filters the override-payment