From cfd3b4a8f4f70e9916ec62f0bb0c3e2531d5af83 Mon Sep 17 00:00:00 2001 From: Cristian Magherusan-Stanciu Date: Thu, 28 May 2026 22:04:49 +0200 Subject: [PATCH] ux(recommendations): inline Plan button deep-links into Create Plan modal (closes #120) Add a per-row "Plan" button in the checkbox column of each recommendation row. Clicking it calls openCreatePlanFromBottomBox([rec]) so the Create Purchase Plan modal opens pre-seeded with that single recommendation. Permission gate mirrors the bulk Create Plan button (create:plans). The button carries data-action="plan" so the row-click selection handler skips it. Tests: button visible for admin, absent for readonly, click pre-seeds the modal. --- .../recommendations-permissions.test.ts | 87 +++++++++++++++++++ frontend/src/recommendations.ts | 22 ++++- 2 files changed, 108 insertions(+), 1 deletion(-) diff --git a/frontend/src/__tests__/recommendations-permissions.test.ts b/frontend/src/__tests__/recommendations-permissions.test.ts index 2d0f49ca..f1f962fa 100644 --- a/frontend/src/__tests__/recommendations-permissions.test.ts +++ b/frontend/src/__tests__/recommendations-permissions.test.ts @@ -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'; @@ -62,6 +66,19 @@ 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 { ADMINISTRATORS_GROUP_ID } from '../permissions'; @@ -300,3 +317,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('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('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('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'); + }); +}); diff --git a/frontend/src/recommendations.ts b/frontend/src/recommendations.ts index 83f412b7..97cf6e7e 100644 --- a/frontend/src/recommendations.ts +++ b/frontend/src/recommendations.ts @@ -2743,8 +2743,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') + ? `` + : ''; const checkboxCell = showCheckboxes - ? `` + ? `${planBtnHtml}` : ''; return ` @@ -4103,6 +4109,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('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