diff --git a/frontend/src/__tests__/plans.test.ts b/frontend/src/__tests__/plans.test.ts index 4fe55a242..af6fe2f68 100644 --- a/frontend/src/__tests__/plans.test.ts +++ b/frontend/src/__tests__/plans.test.ts @@ -2053,4 +2053,173 @@ describe('Plans Module', () => { expect(service.value).not.toBe('ec2'); }); }); + + describe('Target Accounts provider filter (issue #703)', () => { + // These tests need the account-search DOM elements in addition to the + // standard plan-modal markup provided by the outer beforeEach. + beforeEach(() => { + // Inject account-search elements into the existing form using safe DOM API calls. + const form = document.getElementById('plan-form'); + if (form) { + const accountSection = document.createElement('div'); + accountSection.className = 'plan-accounts-search'; + + const searchInput = document.createElement('input'); + searchInput.type = 'text'; + searchInput.id = 'plan-account-search'; + searchInput.placeholder = 'Search accounts'; + accountSection.appendChild(searchInput); + + const suggestions = document.createElement('div'); + suggestions.id = 'plan-account-suggestions'; + suggestions.className = 'account-suggestions hidden'; + accountSection.appendChild(suggestions); + + const selectedContainer = document.createElement('div'); + selectedContainer.id = 'plan-accounts-selected'; + selectedContainer.className = 'selected-accounts'; + accountSection.appendChild(selectedContainer); + + const hiddenIds = document.createElement('input'); + hiddenIds.type = 'hidden'; + hiddenIds.id = 'plan-account-ids'; + hiddenIds.value = ''; + accountSection.appendChild(hiddenIds); + + form.appendChild(accountSection); + } + + // Use fake timers so setTimeout in handlePlanAccountSearch can be + // controlled synchronously. + jest.useFakeTimers(); + }); + + afterEach(() => { + jest.useRealTimers(); + }); + + test('account search passes the current plan provider to listAccounts', async () => { + (api.listAccounts as jest.Mock).mockResolvedValue([]); + + // openNewPlanModal calls form.reset() synchronously, resetting the provider + // select back to its first option ('aws'). Wire up the modal first, then + // change provider, then trigger the search so the handler reads the new value. + openNewPlanModal(); + // Let setupPlanAccountsSection's async portion settle (it clones the input node). + await Promise.resolve(); + + // Now change provider AFTER the form reset so setupPlanAccountsSection already ran. + const providerSelect = document.getElementById('plan-provider') as HTMLSelectElement; + providerSelect.value = 'azure'; + + // The search input node was replaced (cloneNode) inside setupPlanAccountsSection; + // re-query by id to get the live node with the registered listener. + const searchInput = document.getElementById('plan-account-search') as HTMLInputElement; + searchInput.value = 'my-azure'; + searchInput.dispatchEvent(new Event('input')); + + // Advance past the 300 ms debounce timer and flush async api call. + jest.runAllTimers(); + await Promise.resolve(); + await Promise.resolve(); + + expect(api.listAccounts).toHaveBeenCalledWith( + expect.objectContaining({ search: 'my-azure', provider: 'azure' }) + ); + }); + + test('account search passes aws provider when plan provider is aws', async () => { + (api.listAccounts as jest.Mock).mockResolvedValue([]); + + openNewPlanModal(); + await Promise.resolve(); + + // form.reset() resets to first option 'aws', so provider is already 'aws'. + const searchInput = document.getElementById('plan-account-search') as HTMLInputElement; + searchInput.value = 'prod'; + searchInput.dispatchEvent(new Event('input')); + + jest.runAllTimers(); + await Promise.resolve(); + await Promise.resolve(); + + expect(api.listAccounts).toHaveBeenCalledWith( + expect.objectContaining({ provider: 'aws' }) + ); + }); + + test('switching provider clears existing account chips and hidden field', async () => { + // openNewPlanModal wires up setupRampScheduleHandlers, which attaches the + // provider-change listener that clears accounts. Must call it before the test. + openNewPlanModal(); + await Promise.resolve(); + + // Pre-populate the hidden field to simulate an earlier account selection. + // renderPlanAccountChips() will clear the DOM container from planSelectedAccounts, + // so populating it directly in the DOM is sufficient to assert the clear. + const selectedContainer = document.getElementById('plan-accounts-selected') as HTMLElement; + const chip = document.createElement('span'); + chip.className = 'account-chip'; + chip.textContent = 'old-acct'; + selectedContainer.appendChild(chip); + (document.getElementById('plan-account-ids') as HTMLInputElement).value = 'acct-old-id'; + + // Switch provider — should clear chips and hidden field via the handler added in + // setupRampScheduleHandlers (called by openNewPlanModal). + const providerSelect = document.getElementById('plan-provider') as HTMLSelectElement; + providerSelect.value = 'gcp'; + providerSelect.dispatchEvent(new Event('change')); + + expect(selectedContainer.textContent).toBe(''); + expect((document.getElementById('plan-account-ids') as HTMLInputElement).value).toBe(''); + }); + + test('switching provider clears and hides open account suggestion dropdown', async () => { + openNewPlanModal(); + await Promise.resolve(); + + // Simulate an open suggestion dropdown with stale results from a previous search. + const suggestions = document.getElementById('plan-account-suggestions') as HTMLElement; + suggestions.textContent = 'stale-item'; + suggestions.classList.remove('hidden'); + + // Also put text in the search input to verify it is cleared. + const searchInput = document.getElementById('plan-account-search') as HTMLInputElement; + searchInput.value = 'old query'; + + // Switch provider — the handler must clear + hide the dropdown and clear the input. + const providerSelect = document.getElementById('plan-provider') as HTMLSelectElement; + providerSelect.value = 'gcp'; + providerSelect.dispatchEvent(new Event('change')); + + expect(suggestions.textContent).toBe(''); + expect(suggestions.classList.contains('hidden')).toBe(true); + expect(searchInput.value).toBe(''); + }); + + test('account search input is disabled when provider is cleared after modal open', async () => { + // Open the modal with default provider (aws from form reset). + openNewPlanModal(); + await Promise.resolve(); + + // Simulate user clearing the provider via the change listener, which should disable search. + const providerSelect = document.getElementById('plan-provider') as HTMLSelectElement; + providerSelect.value = ''; + providerSelect.dispatchEvent(new Event('change')); + + const searchInput = document.getElementById('plan-account-search') as HTMLInputElement; + expect(searchInput.disabled).toBe(true); + }); + + test('account search input is enabled when plan-provider has a value', async () => { + // Default after form.reset() is 'aws' (first option), so disabled should be false. + openNewPlanModal(); + await Promise.resolve(); + + // The cloned input replaces the original in setupPlanAccountsSection; + // query by id to get the current node. + const searchInput = document.getElementById('plan-account-search') as HTMLInputElement; + expect(searchInput.disabled).toBe(false); + }); + }); }); diff --git a/frontend/src/plans.ts b/frontend/src/plans.ts index e8c40e9c1..eb8875f29 100644 --- a/frontend/src/plans.ts +++ b/frontend/src/plans.ts @@ -704,7 +704,9 @@ async function handlePlanAccountSearch(value: string): Promise { } try { - const accounts = await api.listAccounts({ search: value }); + const providerSelect = document.getElementById('plan-provider') as HTMLSelectElement | null; + const provider = providerSelect?.value as api.Provider | undefined; + const accounts = await api.listAccounts({ search: value, ...(provider ? { provider } : {}) }); suggestions.textContent = ''; if (accounts.length === 0) { suggestions.classList.add('hidden'); @@ -736,10 +738,17 @@ async function handlePlanAccountSearch(value: string): Promise { async function setupPlanAccountsSection(planId?: string): Promise { planSelectedAccounts = []; + const planProvider = (document.getElementById('plan-provider') as HTMLSelectElement | null)?.value; + if (planId) { try { const existingAccounts = await api.listPlanAccounts(planId); - planSelectedAccounts = existingAccounts.map(a => ({ id: a.id, name: a.name, external_id: a.external_id })); + // Filter out any account whose provider does not match the current plan + // provider. This prevents stale cross-provider assignments from silently + // surviving a provider switch on an existing plan. + planSelectedAccounts = existingAccounts + .filter(a => !planProvider || a.provider === planProvider) + .map(a => ({ id: a.id, name: a.name, external_id: a.external_id })); } catch { // Non-critical — section just starts empty } @@ -750,6 +759,9 @@ async function setupPlanAccountsSection(planId?: string): Promise { const searchInput = document.getElementById('plan-account-search') as HTMLInputElement | null; if (searchInput) { + // Disable the search input until a provider is selected. + searchInput.disabled = !planProvider; + // Remove previous listeners by replacing node const newInput = searchInput.cloneNode(true) as HTMLInputElement; searchInput.parentNode?.replaceChild(newInput, searchInput); @@ -920,6 +932,28 @@ function setupRampScheduleHandlers(): void { providerSelect?.addEventListener('change', () => { updateCommitmentOptions(); updatePlanNameFromSchedule(); + // Clear all selected accounts when the provider changes. Selected account + // entries only carry id/name/external_id (no provider field), so we cannot + // filter by provider directly. Clearing on change is the safe default: + // an account valid for the old provider is almost never valid for the new + // one, and it prevents a cross-provider assignment from silently reaching + // the backend validator. + planSelectedAccounts = []; + renderPlanAccountChips(); + updatePlanAccountIdsField(); + // Clear and hide any open account suggestion dropdown so stale suggestions + // from the previous provider cannot be clicked and add a mismatched account. + const suggestions = document.getElementById('plan-account-suggestions') as HTMLElement | null; + if (suggestions) { + suggestions.textContent = ''; + suggestions.classList.add('hidden'); + } + // Re-enable/disable the account search input to match the new provider state. + const accountSearchInput = document.getElementById('plan-account-search') as HTMLInputElement | null; + if (accountSearchInput) { + accountSearchInput.value = ''; + accountSearchInput.disabled = !providerSelect.value; + } }); serviceSelect?.addEventListener('change', () => {