Skip to content
Open
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
88 changes: 88 additions & 0 deletions frontend/src/__tests__/recommendations-permissions.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,10 @@
* Viewer has no purchase/plan actions so the Select-all checkbox,
* per-row checkboxes, and row-click selection are all hidden/inert.
* Admin and user (operator) roles are unchanged.
*
* Issue #120: per-row "Plan" button deep-links into the Create Purchase
* Plan modal with the row's rec pre-seeded. The button follows the
* same create:plans permission gate.
*/
import { loadRecommendations } from '../recommendations';
import * as api from '../api';
Expand Down Expand Up @@ -62,7 +66,21 @@ jest.mock('../toast', () => ({
showToast: jest.fn().mockReturnValue({ dismiss: jest.fn() }),
}));

// openCreatePlanFromBottomBox calls `await import('./plans')` at runtime.
// Mock the module so the per-row Plan button click test can assert on
// the call without rendering the full modal DOM.
const mockOpenCreatePlanModal = jest.fn();
jest.mock('../plans', () => ({
openCreatePlanModal: (...args: unknown[]) => mockOpenCreatePlanModal(...args),
setupPlanHandlers: jest.fn(),
loadPlans: jest.fn().mockResolvedValue(undefined),
closePlanModal: jest.fn(),
closePurchaseModal: jest.fn(),
openNewPlanModal: jest.fn(),
}));

import * as state from '../state';
import * as api from '../api';

const mockUser = (role: string | null) => {
(state.getCurrentUser as jest.Mock).mockReturnValue(
Expand Down Expand Up @@ -293,3 +311,73 @@ describe('Recommendations checkbox + row-click gating for viewer role (issue #86
expect(summaryEffectiveCols).toBe(headerColCount);
});
});

// Shared rec fixture used by the per-row Plan button tests below.
const sampleRec120 = {
id: 'rec-120',
provider: 'aws',
cloud_account_id: 'acc-1',
service: 'ec2',
resource_type: 't3.small',
region: 'us-east-1',
count: 2,
term: 1,
payment: 'all-upfront',
savings: 150,
upfront_cost: 500,
};

// Helper: seed the api + state mocks with one rec so the table renders a row.
const seedOneRec = () => {
(api.getRecommendations as jest.Mock).mockResolvedValue({
summary: {},
recommendations: [sampleRec120],
regions: [],
});
(state.getRecommendations as jest.Mock).mockReturnValue([sampleRec120]);
(state.getVisibleRecommendations as jest.Mock).mockReturnValue([sampleRec120]);
(state.getSelectedRecommendationIDs as jest.Mock).mockReturnValue(new Set());
};

describe('Per-row Plan button deep-link (issue #120)', () => {
beforeEach(() => {
jest.clearAllMocks();
mockOpenCreatePlanModal.mockReset();
setupDom();
});

test('admin sees a per-row Plan button when rows are rendered', async () => {
mockUser('admin');
seedOneRec();
await loadRecommendations();
const planBtns = document.querySelectorAll<HTMLButtonElement>('button.rec-plan-btn');
expect(planBtns.length).toBe(1);
expect(planBtns[0]!.dataset['recId']).toBe('rec-120');
});

test('readonly role has no per-row Plan buttons (create:plans gated)', async () => {
mockUser('readonly');
seedOneRec();
await loadRecommendations();
const planBtns = document.querySelectorAll<HTMLButtonElement>('button.rec-plan-btn');
expect(planBtns.length).toBe(0);
});

test('clicking per-row Plan button calls openCreatePlanModal with the rec pre-seeded', async () => {
mockUser('admin');
seedOneRec();
await loadRecommendations();
const planBtn = document.querySelector<HTMLButtonElement>('button.rec-plan-btn[data-rec-id="rec-120"]');
expect(planBtn).not.toBeNull();
planBtn!.click();
// openCreatePlanFromBottomBox does `await import('./plans')` which in
// CommonJS (ts-jest) compiles to Promise.resolve().then(() => require(...)).
// Flush the microtask queue with a few awaits so the dynamic import
// resolves and openCreatePlanModal is called before we assert.
for (let i = 0; i < 5; i++) await Promise.resolve();
expect(mockOpenCreatePlanModal).toHaveBeenCalledTimes(1);
const calledWith = mockOpenCreatePlanModal.mock.calls[0]![0] as unknown[];
expect(Array.isArray(calledWith)).toBe(true);
expect((calledWith as { id: string }[])[0]!.id).toBe('rec-120');
});
});
22 changes: 21 additions & 1 deletion frontend/src/recommendations.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2465,8 +2465,14 @@ function buildVariantRowMarkup(
// The column header also omits the select-all checkbox, so the column is
// visually absent rather than present-but-empty, matching the no-actions
// experience on Plans and Purchases for the same role.
// Issue #120: inline "Plan" button deep-links into the Create Purchase Plan
// modal pre-seeded with this rec. Only rendered when the session has
// create:plans permission (mirrors the bulk Create Plan button gate).
const planBtnHtml = canAccess('create', 'plans')
? `<button type="button" class="btn btn-small rec-plan-btn" data-rec-id="${recId}" data-action="plan" aria-label="Create plan for this recommendation" title="Create a purchase plan from this recommendation">Plan</button>`
: '';
const checkboxCell = showCheckboxes
? `<td class="checkbox-col"><input type="checkbox" data-rec-id="${recId}" ${isSelected ? 'checked' : ''} aria-label="Select recommendation"></td>`
? `<td class="checkbox-col"><input type="checkbox" data-rec-id="${recId}" ${isSelected ? 'checked' : ''} aria-label="Select recommendation">${planBtnHtml}</td>`
: '';
return `
<tr class="recommendation-row${nestedClass} ${savingsClass} ${isSelected ? 'selected' : ''}" data-rec-id="${recId}">
Expand Down Expand Up @@ -3820,6 +3826,20 @@ function renderRecommendationsList(loadedRecs: LocalRecommendation[]): void {
renderRecommendationsList(loadedRecs);
});
});

// Issue #120: per-row Plan button deep-links into Create Purchase Plan modal.
// Fires openCreatePlanFromBottomBox with the single rec so the modal is
// pre-seeded -- same path as the bulk Create Plan button but scoped to one row.
container.querySelectorAll<HTMLButtonElement>('button.rec-plan-btn').forEach((btn) => {
btn.addEventListener('click', (e) => {
e.stopPropagation();
const recId = btn.dataset['recId'] ?? '';
if (!recId) return;
const rec = recommendations.find((r) => r.id === recId);
if (!rec) return;
void openCreatePlanFromBottomBox([rec]);
});
});
}

// resolvePerRecPaymentSeed picks the default Payment value for one rec
Expand Down
Loading