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
115 changes: 115 additions & 0 deletions frontend/src/__tests__/riexchange.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,29 @@ jest.mock('../navigation', () => ({
switchSettingsSubTab: jest.fn(),
}));

// Capture subscription callbacks so tests can fire them directly.
// These arrays are populated by the mock factory below. Declared with
// `let` so the reference is stable across the hoisted jest.mock call.
let _providerListeners: Array<() => void> = [];
let _accountListeners: Array<() => void> = [];
jest.mock('../state', () => ({
subscribeProvider: jest.fn((cb: () => void) => {
// _providerListeners may not be initialised yet at hoist time —
// access it lazily via the closure over the outer `let`.
_providerListeners = _providerListeners ?? [];
_providerListeners.push(cb);
return () => undefined;
}),
subscribeAccount: jest.fn((cb: () => void) => {
_accountListeners = _accountListeners ?? [];
_accountListeners.push(cb);
return () => undefined;
}),
getCurrentProvider: jest.fn(() => 'aws'),
getCurrentAccountIDs: jest.fn(() => []),
getCurrentUser: jest.fn(() => ({ id: 'u', email: 'u@example.com', role: 'admin' })),
}));

import {
fillQuoteFromRI,
loadReshapeRecommendations,
Expand Down Expand Up @@ -450,3 +473,95 @@ describe('⚙︎ Exchange settings deep-link', () => {
expect(navigation.switchSettingsSubTab).toHaveBeenCalledWith('purchasing');
});
});

// issue #186: provider/account subscriptions on the RI Exchange tab
describe('RI Exchange filter subscriptions (issue #186)', () => {
let instancesEl: HTMLDivElement;
let recsEl: HTMLDivElement;
let historyEl: HTMLDivElement;
let riExchangePanel: HTMLDivElement;

beforeEach(() => {
instancesEl = document.createElement('div');
instancesEl.id = 'ri-exchange-instances-list';
recsEl = document.createElement('div');
recsEl.id = 'ri-exchange-recommendations-list';
historyEl = document.createElement('div');
historyEl.id = 'ri-exchange-history-list';
// The sub-tab panel must exist and be visible for the guard to pass.
riExchangePanel = document.createElement('div');
riExchangePanel.id = 'inventory-ri-exchange';
document.body.append(instancesEl, recsEl, historyEl, riExchangePanel);

(api.listConvertibleRIs as jest.Mock).mockResolvedValue([]);
(api.getRIUtilization as jest.Mock).mockResolvedValue([]);
(api.getReshapeRecommendations as jest.Mock).mockResolvedValue({ recommendations: [], recs_staleness: '', recs_collected_at: null });
(api.getRIExchangeHistory as jest.Mock).mockResolvedValue([]);
_providerListeners.length = 0;
_accountListeners.length = 0;
// Re-apply the implementation after jest.resetAllMocks() from a prior
// describe block may have cleared it.
const stateMod = jest.requireMock('../state') as {
subscribeProvider: jest.Mock;
subscribeAccount: jest.Mock;
};
stateMod.subscribeProvider.mockImplementation((cb: () => void) => {
_providerListeners.push(cb);
return () => undefined;
});
stateMod.subscribeAccount.mockImplementation((cb: () => void) => {
_accountListeners.push(cb);
return () => undefined;
});
});

afterEach(() => {
document.body.innerHTML = '';
// Use clearAllMocks rather than resetAllMocks so the subscribeProvider/
// subscribeAccount mock implementations (which push to _providerListeners)
// are preserved across tests in this block.
jest.clearAllMocks();
});

it('setupRIExchangeHandlers registers subscribeProvider and subscribeAccount', () => {
const stateMod = jest.requireMock('../state');
setupRIExchangeHandlers();
expect(stateMod.subscribeProvider).toHaveBeenCalled();
expect(stateMod.subscribeAccount).toHaveBeenCalled();
});

it('a provider change triggers loadRIExchange when the sub-tab is active', async () => {
setupRIExchangeHandlers();
// Fire the provider listener (simulates topbar provider change).
_providerListeners.forEach(cb => cb());
// Flush the microtask queue: queueMicrotask fires after all pending
// micro-ticks; wrapping in a resolved promise ensures we drain it.
await new Promise(r => setTimeout(r, 0));
expect(api.listConvertibleRIs).toHaveBeenCalled();
});

it('a provider change does NOT trigger loadRIExchange when the sub-tab is hidden', async () => {
riExchangePanel.classList.add('hidden');
setupRIExchangeHandlers();
_providerListeners.forEach(cb => cb());
await new Promise(r => setTimeout(r, 0));
expect(api.listConvertibleRIs).not.toHaveBeenCalled();
});

it('an account change triggers loadRIExchange when the sub-tab is active', async () => {
setupRIExchangeHandlers();
_accountListeners.forEach(cb => cb());
await new Promise(r => setTimeout(r, 0));
expect(api.listConvertibleRIs).toHaveBeenCalled();
});

it('coalesces provider and account changes into a single reload', async () => {
setupRIExchangeHandlers();
// Simulate topbar filter cascade: provider change triggers account reset
_providerListeners.forEach(cb => cb());
_accountListeners.forEach(cb => cb());
await new Promise(r => setTimeout(r, 0));
// Should be called once, not twice
expect(api.listConvertibleRIs).toHaveBeenCalledTimes(1);
});
});
37 changes: 36 additions & 1 deletion frontend/src/riexchange.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@
*/

import * as api from './api';
import * as state from './state';
import { formatDate, formatDateTime, escapeHtml, formatCurrency } from './utils';
import { switchTab, switchSettingsSubTab } from './navigation';
import { confirmDialog } from './confirmDialog';
Expand Down Expand Up @@ -49,7 +50,25 @@ export async function loadRIExchange(): Promise<void> {
}

/**
* Setup RI Exchange event handlers
* True when the RI Exchange sub-tab is the currently visible panel.
* The sub-tab panel id is "inventory-ri-exchange" (see index.html).
* Used by the provider/account change subscriptions below to avoid
* unnecessary fetches while the user is on a different tab.
*/
function isRIExchangeSubtabActive(): boolean {
const panel = document.getElementById('inventory-ri-exchange');
return panel !== null && !panel.classList.contains('hidden');
}

/**
* Setup RI Exchange event handlers.
*
* Wires the refresh button, the settings deep-link, and
* provider/account state subscriptions so the convertible-RI list
* and reshape recommendations reload when the operator switches the
* global account filter (issue #186). An active-subtab guard
* mirrors the Recommendations tab pattern to avoid redundant fetches
* while the panel is off-screen.
*/
export function setupRIExchangeHandlers(): void {
// Refresh button. Quote + execute flow lives in the per-row "Exchange"
Expand All @@ -72,6 +91,22 @@ export function setupRIExchangeHandlers(): void {
}
});
}

// issue #186: reload when the global provider/account filter changes
// so the RI Exchange tables stay consistent with the rest of the UI.
// Coalesce the two events into a single reload (provider change also
// fires an account change via the topbar-filters.ts clearing logic).
let reloadQueued = false;
const scheduleReload = (): void => {
if (!isRIExchangeSubtabActive() || reloadQueued) return;
reloadQueued = true;
queueMicrotask(() => {
reloadQueued = false;
if (isRIExchangeSubtabActive()) void loadRIExchange();
});
};
state.subscribeProvider(scheduleReload);
state.subscribeAccount(scheduleReload);
}

// ──────────────────────────────────────────────
Expand Down
Loading