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
93 changes: 92 additions & 1 deletion frontend/src/__tests__/recommendations.test.ts
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
/**
* Recommendations module tests
*/
import { loadRecommendations, openPurchaseModal, getPurchaseModalRecommendations, clearPurchaseModalRecommendations, refreshRecommendations, setupRecommendationsHandlers, pickBestVariantPerCell, seedGlobalDefaults, effectiveMonthlySavings, effectiveSavingsPct, onDemandMonthly, groupRecsByCell, cellSummary, pageLevelRange, resetExpandedCells, resetAutoRefreshInFlight, scaleCost, formatCostForPeriod, periodSuffix, loadColumnVisibility, saveColumnVisibility, resetColumnVisibilityState, TOGGLEABLE_COLUMNS, COLUMN_DEFS } from '../recommendations';
import { loadRecommendations, openPurchaseModal, getPurchaseModalRecommendations, clearPurchaseModalRecommendations, refreshRecommendations, setupRecommendationsHandlers, pickBestVariantPerCell, seedGlobalDefaults, effectiveMonthlySavings, effectiveSavingsPct, onDemandMonthly, groupRecsByCell, cellSummary, pageLevelRange, resetExpandedCells, resetAutoRefreshInFlight, scaleCost, formatCostForPeriod, periodSuffix, loadColumnVisibility, saveColumnVisibility, resetColumnVisibilityState, TOGGLEABLE_COLUMNS, COLUMN_DEFS, isHomogeneousSelection } from '../recommendations';
import type { CostPeriod } from '../state';

// Mock the api module
Expand Down Expand Up @@ -2715,6 +2715,25 @@ describe('Bundle B: sticky bottom action box', () => {
expect(planBtn.disabled).toBe(true);
});

test('plan button disabled on heterogeneous selection; purchase button still enabled (#769)', async () => {
// The fixture recs differ in term (1 vs 3), so selecting both makes the
// selection heterogeneous for the plan button but not the purchase button.
(state.getSelectedRecommendationIDs as jest.Mock).mockReturnValue(new Set(['r1', 'r2']));
await loadRecommendations();
const purchaseBtn = document.getElementById('bulk-purchase-btn') as HTMLButtonElement;
const planBtn = document.getElementById('create-plan-btn') as HTMLButtonElement;
const hint = document.getElementById('recommendations-action-disabled-hint') as HTMLSpanElement;
// Purchase button is unaffected by homogeneity.
expect(purchaseBtn.disabled).toBe(false);
expect(purchaseBtn.textContent).toBe('Purchase 2 selected');
// Plan button must be disabled.
expect(planBtn.disabled).toBe(true);
expect(planBtn.textContent).toBe('Plan from 2 selected');
// Hint is visible with the heterogeneous explanation.
expect(hint.hidden).toBe(false);
expect(hint.textContent).toContain('Plans require one provider, service, term, and payment');
});

test('Capacity input value persists across re-render (mount-once-then-update)', async () => {
await loadRecommendations();
const cap = document.getElementById('bulk-purchase-capacity') as HTMLInputElement;
Expand Down Expand Up @@ -6402,3 +6421,75 @@ describe('Issue #484: numeric filter matches the displayed rounded value', () =>
});
});
});

// Helpers shared by the isHomogeneousSelection describe block below.
function makeRec(overrides: Partial<{
id: string;
provider: string;
service: string;
term: number;
payment: string;
}>): { id: string; provider: string; service: string; resource_type: string; region: string; count: number; term: number; payment: string; savings: number; upfront_cost: number } {
return {
id: overrides.id ?? 'r1',
provider: overrides.provider ?? 'aws',
service: overrides.service ?? 'ec2',
resource_type: 't3.medium',
region: 'us-east-1',
count: 1,
term: overrides.term ?? 1,
payment: overrides.payment ?? 'all-upfront',
savings: 100,
Comment thread
coderabbitai[bot] marked this conversation as resolved.
upfront_cost: 500,
};
}

describe('isHomogeneousSelection (#769)', () => {
test('empty slice is homogeneous', () => {
expect(isHomogeneousSelection([])).toBe(true);
});

test('single-item slice is always homogeneous', () => {
expect(isHomogeneousSelection([makeRec({}) as never])).toBe(true);
});

test('two recs with identical provider/service/term/payment are homogeneous', () => {
const recs = [
makeRec({ id: 'r1' }),
makeRec({ id: 'r2' }),
];
expect(isHomogeneousSelection(recs as never[])).toBe(true);
});

test('heterogeneous on provider axis', () => {
const recs = [
makeRec({ id: 'r1', provider: 'aws' }),
makeRec({ id: 'r2', provider: 'azure' }),
];
expect(isHomogeneousSelection(recs as never[])).toBe(false);
});

test('heterogeneous on service axis', () => {
const recs = [
makeRec({ id: 'r1', service: 'ec2' }),
makeRec({ id: 'r2', service: 'rds' }),
];
expect(isHomogeneousSelection(recs as never[])).toBe(false);
});

test('heterogeneous on term axis', () => {
const recs = [
makeRec({ id: 'r1', term: 1 }),
makeRec({ id: 'r2', term: 3 }),
];
expect(isHomogeneousSelection(recs as never[])).toBe(false);
});

test('heterogeneous on payment axis', () => {
const recs = [
makeRec({ id: 'r1', payment: 'all-upfront' }),
makeRec({ id: 'r2', payment: 'no-upfront' }),
];
expect(isHomogeneousSelection(recs as never[])).toBe(false);
});
});
64 changes: 47 additions & 17 deletions frontend/src/recommendations.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3034,6 +3034,23 @@ function resolvePurchaseTarget(): LocalRecommendation[] {
return visible.filter((r) => selected.has(r.id));
}

// isHomogeneousSelection returns true iff every recommendation in the slice
// shares the same (provider, service, term, payment). A single-item slice
// always passes. An empty slice returns true (vacuously homogeneous).
// Plans require a homogeneous selection because the plan's scheduling
// parameters (provider, service, term, payment) must be unambiguous.
// Exported so unit tests can cover it directly without a full DOM setup.
export function isHomogeneousSelection(recs: readonly LocalRecommendation[]): boolean {
if (recs.length <= 1) return true;
// recs is non-empty here; the non-null assertion is safe.
// eslint-disable-next-line @typescript-eslint/no-non-null-assertion
const first = recs[0]!;
const { provider, service, term, payment } = first;
return recs.every(
(r) => r.provider === provider && r.service === service && r.term === term && r.payment === payment,
);
}

// updateBottomActionBox refreshes labels and disabled state on every
// renderRecommendationsList call without rebuilding the input/select DOM,
// preserving any in-progress typing in the Capacity input.
Expand Down Expand Up @@ -3095,21 +3112,11 @@ function updateBottomActionBox(visibleCount: number, loadedCount: number): void
? 'No rows visible — adjust filters'
: 'Select at least one cell to enable';

// a11y: the disabled-state explanation lives on a sibling hint span,
// not on the buttons' `title` attribute. Disabled <button> elements are
// non-focusable per HTML spec and don't reliably surface `title`
// tooltips across browsers, so keyboard users would never see the
// hint and mouse users only sometimes would. The sibling element +
// aria-describedby pattern works for both. See #273 CR follow-up.
if (disabledHint) {
if (hasSelection) {
disabledHint.hidden = true;
disabledHint.textContent = '';
} else {
disabledHint.hidden = false;
disabledHint.textContent = disabledMessage;
}
}
// Compute the selected-visible slice once; both the plan-button gating and
// the hint span need it.
const selectedVisible = visible.filter((r) => selected.has(r.id));
const planHomogeneous = isHomogeneousSelection(selectedVisible);
const planEnabled = hasSelection && planHomogeneous;

if (purchaseBtn) {
purchaseBtn.disabled = !hasSelection;
Expand All @@ -3127,18 +3134,41 @@ function updateBottomActionBox(visibleCount: number, loadedCount: number): void
}
}
if (planBtn) {
planBtn.disabled = !hasSelection;
planBtn.disabled = !planEnabled;
planBtn.textContent = hasSelection
? `Plan from ${selectedVisibleCount} selected`
: 'Create Plan';
if (hasSelection) {
if (planEnabled) {
planBtn.title = 'Schedule a recurring plan that will purchase these recommendations on a defined cadence';
planBtn.removeAttribute('aria-describedby');
} else {
planBtn.removeAttribute('title');
planBtn.setAttribute('aria-describedby', 'recommendations-action-disabled-hint');
}
}

// a11y: the disabled-state explanation lives on a sibling hint span, not on
// the buttons' `title` attribute. Disabled <button> elements are non-focusable
// per HTML spec and don't reliably surface `title` tooltips across browsers, so
// keyboard users would never see the hint and mouse users only sometimes would.
// The sibling element + aria-describedby pattern works for both (#273 CR follow-up).
// The hint also carries the heterogeneous-selection explanation for the plan
// button (#769): when a selection spans multiple providers/services/terms/payment
// options the plan button is disabled and the hint explains why.
if (disabledHint) {
const heterogeneousPlanBlock = hasSelection && planBtn != null && !planBtn.hidden && !planHomogeneous;
if (!hasSelection) {
disabledHint.hidden = false;
disabledHint.textContent = disabledMessage;
} else if (heterogeneousPlanBlock) {
disabledHint.hidden = false;
disabledHint.textContent =
'Plans require one provider, service, term, and payment. Refine your selection.';
} else {
disabledHint.hidden = true;
disabledHint.textContent = '';
}
}
}

// openCreatePlanFromBottomBox opens the plan-creation modal. plans.ts'
Expand Down
Loading