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.
+
+
+ );
+ }
+
+ if (!capabilities || userLoading || selectionLoading || standardsLoading) {
+ return ;
+ }
+
+ if (standardsError) {
+ return {standardsError};
+ }
+
+ return (
+
+
Choose which standards appear in your view. OpenCRE is always included.
+ {standards.length === 0 ? (
+
No standards are available yet.
+ ) : (
+
+ {standards.map((name) => (
+
+ toggle(name)} />
+
+ ))}
+
+ )}
+
+ {saved && !error &&
Your standards were saved.}
+ {error &&
{error}}
+
+ );
+};
diff --git a/application/frontend/src/hooks/index.ts b/application/frontend/src/hooks/index.ts
index bc26e9052..d8ffe020c 100644
--- a/application/frontend/src/hooks/index.ts
+++ b/application/frontend/src/hooks/index.ts
@@ -2,3 +2,4 @@ export { useEnvironment } from './useEnvironment';
export { useLocationFromOutsideRoute } from './useLocationFromOutsideRoute';
export { useCapabilities } from './useCapabilities';
export { useUser } from './useUser';
+export { useResourceSelection } from './useResourceSelection';
diff --git a/application/frontend/src/hooks/useResourceSelection.test.ts b/application/frontend/src/hooks/useResourceSelection.test.ts
new file mode 100644
index 000000000..8e58bc16c
--- /dev/null
+++ b/application/frontend/src/hooks/useResourceSelection.test.ts
@@ -0,0 +1,84 @@
+import { act, render, waitFor } from '@testing-library/react';
+import React from 'react';
+
+import { useResourceSelection } from './useResourceSelection';
+
+jest.mock('./useEnvironment', () => ({
+ useEnvironment: () => ({ name: 'test', apiUrl: '/rest/v1' }),
+}));
+
+// Render the hook through a tiny probe component (react-testing-library v11 has
+// no renderHook) that exposes state to the DOM and a button to trigger save.
+type Captured = ReturnType;
+let captured: Captured;
+
+function Probe(): React.ReactElement {
+ captured = useResourceSelection();
+ return React.createElement(
+ 'div',
+ null,
+ React.createElement('span', { 'data-testid': 'loading' }, String(captured.loading)),
+ React.createElement('span', { 'data-testid': 'selected' }, captured.selected.join(',')),
+ React.createElement('span', { 'data-testid': 'error' }, captured.error ?? '')
+ );
+}
+
+function jsonResponse(body: unknown, status = 200): Response {
+ return {
+ status,
+ ok: status >= 200 && status < 300,
+ json: () => Promise.resolve(body),
+ text: () => Promise.resolve(JSON.stringify(body)),
+ } as unknown as Response;
+}
+
+describe('useResourceSelection', () => {
+ afterEach(() => {
+ jest.resetAllMocks();
+ });
+
+ it('loads the current selection on mount', async () => {
+ const fetchMock = jest.fn().mockResolvedValueOnce(jsonResponse({ selected: ['ASVS', 'CWE'] }));
+ (global as any).fetch = fetchMock;
+
+ const { getByTestId } = render(React.createElement(Probe));
+
+ await waitFor(() => expect(getByTestId('loading').textContent).toBe('false'));
+ expect(fetchMock).toHaveBeenCalledWith('/rest/v1/user/resources', { method: 'GET' });
+ expect(getByTestId('selected').textContent).toBe('ASVS,CWE');
+ });
+
+ it('treats 401 as anonymous / not-available (empty selection, no error)', async () => {
+ (global as any).fetch = jest.fn().mockResolvedValueOnce(jsonResponse(null, 401));
+
+ const { getByTestId } = render(React.createElement(Probe));
+
+ await waitFor(() => expect(getByTestId('loading').textContent).toBe('false'));
+ expect(getByTestId('selected').textContent).toBe('');
+ expect(getByTestId('error').textContent).toBe('');
+ });
+
+ it('save() PUTs the selection with the right headers/body and reflects the stored list', async () => {
+ const fetchMock = jest
+ .fn()
+ .mockResolvedValueOnce(jsonResponse({ selected: [] })) // initial GET
+ .mockResolvedValueOnce(jsonResponse({ selected: ['ASVS', 'CWE'] })); // PUT echo
+ (global as any).fetch = fetchMock;
+
+ const { getByTestId } = render(React.createElement(Probe));
+ await waitFor(() => expect(getByTestId('loading').textContent).toBe('false'));
+
+ let returned: string[] | null = null;
+ await act(async () => {
+ returned = await captured.save(['ASVS', 'CWE']);
+ });
+
+ expect(fetchMock).toHaveBeenLastCalledWith('/rest/v1/user/resources', {
+ method: 'PUT',
+ headers: { 'Content-Type': 'application/json', Accept: 'application/json' },
+ body: JSON.stringify({ selected: ['ASVS', 'CWE'] }),
+ });
+ expect(returned).toEqual(['ASVS', 'CWE']);
+ expect(getByTestId('selected').textContent).toBe('ASVS,CWE');
+ });
+});
diff --git a/application/frontend/src/hooks/useResourceSelection.ts b/application/frontend/src/hooks/useResourceSelection.ts
new file mode 100644
index 000000000..46a12a247
--- /dev/null
+++ b/application/frontend/src/hooks/useResourceSelection.ts
@@ -0,0 +1,81 @@
+import { useEffect, useState } from 'react';
+
+import { useEnvironment } from './useEnvironment';
+
+export type ResourceSelectionState = {
+ selected: string[];
+ loading: boolean;
+ saving: boolean;
+ error: string | null;
+ save: (next: string[]) => Promise;
+};
+
+// Per-user resource selection (Part of #586). Mirrors useUser's raw-fetch status
+// handling: 200 ok, 401 -> anonymous / not available, anything else -> error.
+export const useResourceSelection = (): ResourceSelectionState => {
+ const { apiUrl } = useEnvironment();
+ const [selected, setSelected] = useState([]);
+ const [loading, setLoading] = useState(true);
+ const [saving, setSaving] = useState(false);
+ const [error, setError] = useState(null);
+
+ useEffect(() => {
+ let active = true;
+ fetch(`${apiUrl}/user/resources`, { method: 'GET' })
+ .then((res) => {
+ if (res.status === 200) {
+ return res.json();
+ }
+ if (res.status === 401) {
+ return null; // anonymous / feature not available — not an error
+ }
+ throw new Error(`Unexpected /user/resources status: ${res.status}`);
+ })
+ .then((data) => {
+ if (active && data && Array.isArray(data.selected)) {
+ setSelected(data.selected);
+ }
+ })
+ .catch((err) => {
+ if (active) {
+ setError('Could not load your saved standards.');
+ }
+ console.error('useResourceSelection: could not load selection', err);
+ })
+ .finally(() => {
+ if (active) {
+ setLoading(false);
+ }
+ });
+ return () => {
+ active = false;
+ };
+ }, [apiUrl]);
+
+ const save = async (next: string[]): Promise => {
+ setSaving(true);
+ setError(null);
+ try {
+ const res = await fetch(`${apiUrl}/user/resources`, {
+ method: 'PUT',
+ headers: { 'Content-Type': 'application/json', Accept: 'application/json' },
+ body: JSON.stringify({ selected: next }),
+ });
+ if (!res.ok) {
+ throw new Error(`Unexpected PUT /user/resources status: ${res.status}`);
+ }
+ const data = await res.json();
+ const stored = data && Array.isArray(data.selected) ? data.selected : next;
+ setSelected(stored);
+ return stored;
+ } catch (err) {
+ setError('Could not save your standards. Please try again.');
+ console.error('useResourceSelection: could not save selection', err);
+ return null;
+ } finally {
+ setSaving(false);
+ }
+ };
+
+ return { selected, loading, saving, error, save };
+};
diff --git a/application/frontend/src/pages/MyOpenCRE/MyOpenCRE.tsx b/application/frontend/src/pages/MyOpenCRE/MyOpenCRE.tsx
index 9348522c9..a7b7667b0 100644
--- a/application/frontend/src/pages/MyOpenCRE/MyOpenCRE.tsx
+++ b/application/frontend/src/pages/MyOpenCRE/MyOpenCRE.tsx
@@ -3,6 +3,7 @@ import './MyOpenCRE.scss';
import React, { useRef, useState } from 'react';
import { Button, Container, Form, Header, Message } from 'semantic-ui-react';
+import { ResourceSelector } from '../../components/ResourceSelector/ResourceSelector';
import { useEnvironment } from '../../hooks';
type RowValidationError = {
@@ -248,6 +249,11 @@ export const MyOpenCRE = () => {
in the spreadsheet.
+
+
+
+
+