From 22e7ae15151a57a6dcbb0c5df5b556ef4cba9515 Mon Sep 17 00:00:00 2001 From: Cristian Magherusan-Stanciu Date: Mon, 9 Nov 2026 18:47:12 +0100 Subject: [PATCH] fix(frontend): offer a bound plan's providers in the plans provider filter A bound plan whose groups span providers is shown with provider "multiple" and lists its real providers in `providers`. The Provider column filter already matched a row on either, but its options came from `provider` alone: "multiple" was offered, and a provider that appears only inside a bound plan (say gcp) was not, so that plan could not be filtered to from the dropdown. The options are now the union of each row's `providers`, or its single `provider` when it lists none. Closes #1851 Co-Authored-By: Claude Sonnet 5.5 --- .../src/__tests__/plans-column-filters.test.ts | 17 +++++++++++++++++ frontend/src/plans.ts | 13 ++++++++++++- 2 files changed, 29 insertions(+), 1 deletion(-) diff --git a/frontend/src/__tests__/plans-column-filters.test.ts b/frontend/src/__tests__/plans-column-filters.test.ts index 1bc85e937..6d99fd1d2 100644 --- a/frontend/src/__tests__/plans-column-filters.test.ts +++ b/frontend/src/__tests__/plans-column-filters.test.ts @@ -189,6 +189,23 @@ describe('Planned Purchases column filters (issue #166 follow-up)', () => { expect(labels).toEqual(['Jan 2, 2026', 'Jan 10, 2026', 'Jun 2, 2026', 'Dec 1, 2026']); }); + // #1851: a bound plan spanning providers is shown as provider "multiple" and lists + // its real providers; the provider options are those providers, never "multiple". + test('provider options list a bound plan\'s providers, not "multiple"', async () => { + (api.getPlannedPurchases as jest.Mock).mockResolvedValue({ purchases: [ + { ...seedPurchases[0], id: 'b1', provider: 'multiple', providers: ['aws', 'gcp'] }, + { ...seedPurchases[1], id: 'u1', provider: 'azure' }, + { ...seedPurchases[0], id: 'e1', provider: 'oci', providers: [] }, + ] }); + await loadPlans(); + document.querySelector( + '#planned-purchases-list th .column-filter-btn[data-column="provider"]', + )?.click(); + const values = Array.from(document.querySelectorAll('.column-filter-popover .column-filter-item input[type="checkbox"]')) + .map((i) => i.dataset['value']); + expect(values).toEqual(['aws', 'azure', 'gcp', 'oci']); + }); + test('clicking a trigger opens a popover detached to document.body', async () => { await loadPlans(); const providerBtn = document.querySelector( diff --git a/frontend/src/plans.ts b/frontend/src/plans.ts index d562ca4ab..c11251af0 100644 --- a/frontend/src/plans.ts +++ b/frontend/src/plans.ts @@ -338,6 +338,15 @@ const PLANS_NUMERIC_COLUMNS: ReadonlySet = new Set (p.providers && p.providers.length > 0 ? p.providers : [p.provider ?? ''])), + (value) => value, + ); +} + function plansColumnLabel(column: state.PlansColumnId): string { switch (column) { case 'plan_name': return 'Plan'; @@ -382,7 +391,9 @@ function openPlansColumnPopover(column: state.PlansColumnId, anchor: HTMLElement column, label: plansColumnLabel(column), kind: PLANS_NUMERIC_COLUMNS.has(column) ? 'numeric' : 'categorical', - distinctValues: sortedDistinctValues(lastLoadedPurchases, (p) => categoricalCellValueForPlan(p, column)), + distinctValues: column === 'provider' + ? plansProviderOptions(lastLoadedPurchases) + : sortedDistinctValues(lastLoadedPurchases, (p) => categoricalCellValueForPlan(p, column)), displayLabel: (value) => plansCategoricalDisplayLabel(column, value), getFilter: () => state.getPlansColumnFilters()[column], onCommit: (filter) => {