From a6f2374f9b79d14e6b53394e4bd8e5a724ea260c Mon Sep 17 00:00:00 2001 From: skypank Date: Tue, 4 Aug 2026 15:36:39 +0530 Subject: [PATCH 1/4] feat(frontend): resource-selection UI in MyOpenCRE (Part of #586) --- .../ResourceSelector.test.tsx | 134 ++++++++++++++++++ .../ResourceSelector/ResourceSelector.tsx | 118 +++++++++++++++ application/frontend/src/hooks/index.ts | 1 + .../src/hooks/useResourceSelection.test.ts | 84 +++++++++++ .../src/hooks/useResourceSelection.ts | 81 +++++++++++ .../src/pages/MyOpenCRE/MyOpenCRE.tsx | 6 + application/frontend/src/setupTests.ts | 5 + jest.component.config.js | 23 +++ package.json | 1 + 9 files changed, 453 insertions(+) create mode 100644 application/frontend/src/components/ResourceSelector/ResourceSelector.test.tsx create mode 100644 application/frontend/src/components/ResourceSelector/ResourceSelector.tsx create mode 100644 application/frontend/src/hooks/useResourceSelection.test.ts create mode 100644 application/frontend/src/hooks/useResourceSelection.ts create mode 100644 application/frontend/src/setupTests.ts create mode 100644 jest.component.config.js diff --git a/application/frontend/src/components/ResourceSelector/ResourceSelector.test.tsx b/application/frontend/src/components/ResourceSelector/ResourceSelector.test.tsx new file mode 100644 index 000000000..2187199f0 --- /dev/null +++ b/application/frontend/src/components/ResourceSelector/ResourceSelector.test.tsx @@ -0,0 +1,134 @@ +import { fireEvent, render, waitFor } from '@testing-library/react'; +import React from 'react'; + +import { useCapabilities } from '../../hooks/useCapabilities'; +import { useResourceSelection } from '../../hooks/useResourceSelection'; +import { useUser } from '../../hooks/useUser'; +import { ResourceSelector } from './ResourceSelector'; + +jest.mock('../../hooks/useEnvironment', () => ({ + useEnvironment: () => ({ name: 'test', apiUrl: '/rest/v1' }), +})); +jest.mock('../../hooks/useCapabilities'); +jest.mock('../../hooks/useUser'); +jest.mock('../../hooks/useResourceSelection'); + +const mockCaps = useCapabilities as jest.Mock; +const mockUser = useUser as jest.Mock; +const mockSel = useResourceSelection as jest.Mock; + +function standardsFetch(list: string[]): jest.Mock { + return jest.fn().mockResolvedValue({ + status: 200, + ok: true, + json: () => Promise.resolve(list), + }); +} + +describe('ResourceSelector', () => { + beforeEach(() => { + mockCaps.mockReturnValue({ + capabilities: { myopencre: true, login: true }, + loading: false, + }); + mockUser.mockReturnValue({ + user: 'u', + isLoggedIn: true, + loading: false, + login: jest.fn(), + logout: jest.fn(), + }); + mockSel.mockReturnValue({ + selected: ['ASVS'], + loading: false, + saving: false, + error: null, + save: jest.fn().mockResolvedValue(['ASVS']), + }); + (global as any).fetch = standardsFetch(['ASVS', 'CWE', 'SAMM']); + }); + + // clearAllMocks (not resetAllMocks): keep the mock implementations across the + // async boundary so a fetch that resolves after a test can't crash a leaked + // render by making a mocked hook return undefined. + afterEach(() => jest.clearAllMocks()); + + it('fetches the full universe of standards with ?all=true', async () => { + render(); + await waitFor(() => expect((global as any).fetch).toHaveBeenCalled()); + const url = (global as any).fetch.mock.calls[0][0] as string; + expect(url).toContain('/standards?all=true'); + }); + + it('pre-checks the standards in the current selection', async () => { + const { container, findByText } = render(); + await findByText('Save'); + const inputs = container.querySelectorAll('input[type="checkbox"]'); + expect(inputs.length).toBe(3); + expect((inputs[0] as HTMLInputElement).checked).toBe(true); // ASVS (selected) + expect((inputs[1] as HTMLInputElement).checked).toBe(false); // CWE + expect((inputs[2] as HTMLInputElement).checked).toBe(false); // SAMM + }); + + it('toggling a checkbox and clicking Save persists the updated list', async () => { + const saveMock = jest.fn().mockResolvedValue(['ASVS', 'CWE']); + mockSel.mockReturnValue({ + selected: ['ASVS'], + loading: false, + saving: false, + error: null, + save: saveMock, + }); + const { container, findByText, getByText } = render(); + await findByText('Save'); + // semantic-ui-react toggles when the event target is the input: + // click the CWE input directly (index 1 in the fixed standards order). + const inputs = container.querySelectorAll('input[type="checkbox"]'); + fireEvent.click(inputs[1] as HTMLElement); // add CWE + fireEvent.click(getByText('Save')); + await waitFor(() => expect(saveMock).toHaveBeenCalled()); + const arg = (saveMock.mock.calls[0][0] as string[]).slice().sort(); + expect(arg).toEqual(['ASVS', 'CWE']); + }); + + it('shows a success message after a successful save', async () => { + const { findByText, getByText } = render(); + await findByText('Save'); + fireEvent.click(getByText('Save')); + expect(await findByText(/saved/i)).toBeTruthy(); + }); + + it('shows an error message when the hook reports an error', async () => { + mockSel.mockReturnValue({ + selected: ['ASVS'], + loading: false, + saving: false, + error: 'Could not save your standards. Please try again.', + save: jest.fn(), + }); + const { findByText } = render(); + expect(await findByText(/could not save/i)).toBeTruthy(); + }); + + it('renders a login prompt (not the checklist) when logged out', async () => { + mockUser.mockReturnValue({ + user: null, + isLoggedIn: false, + loading: false, + login: jest.fn(), + logout: jest.fn(), + }); + const { findByText, queryByText } = render(); + expect(await findByText(/log in/i)).toBeTruthy(); + expect(queryByText('Save')).toBeNull(); + }); + + it('renders nothing when the MyOpenCRE capability is off', () => { + mockCaps.mockReturnValue({ + capabilities: { myopencre: false, login: true }, + loading: false, + }); + const { container } = render(); + expect(container.firstChild).toBeNull(); + }); +}); diff --git a/application/frontend/src/components/ResourceSelector/ResourceSelector.tsx b/application/frontend/src/components/ResourceSelector/ResourceSelector.tsx new file mode 100644 index 000000000..304170e5e --- /dev/null +++ b/application/frontend/src/components/ResourceSelector/ResourceSelector.tsx @@ -0,0 +1,118 @@ +import React, { useEffect, useState } from 'react'; +import { Button, Checkbox, List, Loader, Message } from 'semantic-ui-react'; + +import { useCapabilities } from '../../hooks/useCapabilities'; +import { useEnvironment } from '../../hooks/useEnvironment'; +import { useResourceSelection } from '../../hooks/useResourceSelection'; +import { useUser } from '../../hooks/useUser'; + +// Lets a logged-in user pick which standards appear in their account view, and +// persist it (Part of #586). Reuses useUser / useCapabilities / useEnvironment +// and the useResourceSelection hook; renders with semantic-ui-react. +export const ResourceSelector = () => { + const { apiUrl } = useEnvironment(); + const { capabilities } = useCapabilities(); + const { isLoggedIn, loading: userLoading, login } = useUser(); + const { selected, loading: selectionLoading, saving, error, save } = useResourceSelection(); + + const [standards, setStandards] = useState([]); + const [standardsLoading, setStandardsLoading] = useState(true); + const [standardsError, setStandardsError] = useState(null); + const [checked, setChecked] = useState([]); + const [saved, setSaved] = useState(false); + + // Fetch the FULL universe of standards. ?all=true bypasses the per-user + // server-side filter (PR3); without it the picker could only ever show the + // user's already-selected standards and never offer new ones to add. + useEffect(() => { + let active = true; + fetch(`${apiUrl}/standards?all=true`, { method: 'GET' }) + .then((res) => { + if (res.status === 200) { + return res.json(); + } + throw new Error(`Unexpected /standards status: ${res.status}`); + }) + .then((data) => { + if (active && Array.isArray(data)) { + setStandards(data); + } + }) + .catch((err) => { + if (active) { + setStandardsError('Could not load the list of standards.'); + } + console.error('ResourceSelector: could not load standards', err); + }) + .finally(() => { + if (active) { + setStandardsLoading(false); + } + }); + return () => { + active = false; + }; + }, [apiUrl]); + + // Initialise the local checkbox state from the saved selection once it loads. + useEffect(() => { + setChecked(selected); + }, [selected]); + + const toggle = (name: string) => { + setSaved(false); + setChecked((prev) => (prev.includes(name) ? prev.filter((n) => n !== name) : [...prev, name])); + }; + + const onSave = async () => { + setSaved(false); + const stored = await save(checked); + if (stored) { + setSaved(true); + } + }; + + // Feature gate: this belongs to MyOpenCRE. + if (capabilities && !capabilities.myopencre) { + return null; + } + + // Logged-out: prompt to sign in rather than showing a blank/broken picker. + if (capabilities && capabilities.login && !userLoading && !isLoggedIn) { + return ( + + Log in to choose your standards +

Sign in to pick which standards appear in your OpenCRE view.

+