Skip to content

Commit f43f903

Browse files
committed
fix(dev-portal): address role activator review feedback
1 parent eb28016 commit f43f903

8 files changed

Lines changed: 185 additions & 14 deletions

File tree

.changeset/dev-portal_add-role-activation.md

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -4,4 +4,4 @@
44

55
Add claimable and permanent role tabs to the person side sheet, including activation and deactivation controls for claimable roles.
66

7-
Related isue #5230
7+
Related issue #5230

packages/dev-portal/package.json

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -53,11 +53,13 @@
5353
"@equinor/fusion-react-styles": "^2.1.0",
5454
"@equinor/fusion-wc-chip": "^1.2.2",
5555
"@equinor/fusion-wc-person": "^3.5.5",
56+
"@testing-library/react": "^16.0.0",
5657
"@types/react": "^19.2.7",
5758
"@types/react-dom": "^19.2.3",
5859
"@types/semver": "^7.7.1",
5960
"@vitejs/plugin-react": "^6.0.1",
6061
"dotenv": "^17.3.1",
62+
"happy-dom": "^20.8.4",
6163
"react": "^19.2.1",
6264
"react-dom": "^19.2.1",
6365
"rxjs": "^7.8.1",
Lines changed: 83 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,83 @@
1+
import { cleanup, fireEvent, render, screen, waitFor } from '@testing-library/react';
2+
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
3+
4+
import { ClaimableRole } from './ClaimableRole';
5+
import type { ClaimableRoleAssignment } from './RolesApi';
6+
7+
const mocks = vi.hoisted(() => ({
8+
createClient: vi.fn(),
9+
currentUser: { localAccountId: 'account-id' },
10+
}));
11+
12+
vi.mock('@equinor/fusion-framework-react', () => ({
13+
useFramework: () => ({
14+
modules: { serviceDiscovery: { createClient: mocks.createClient } },
15+
}),
16+
}));
17+
18+
vi.mock('@equinor/fusion-framework-react/hooks', () => ({
19+
useCurrentUser: () => mocks.currentUser,
20+
}));
21+
22+
const assignment: ClaimableRoleAssignment = {
23+
id: 'assignment-id',
24+
claimableRole: {
25+
name: 'Developer',
26+
displayName: 'Developer role',
27+
description: 'Temporary development access',
28+
},
29+
isActive: false,
30+
activeTo: null,
31+
};
32+
33+
describe('ClaimableRole', () => {
34+
beforeEach(() => {
35+
mocks.createClient.mockReset();
36+
});
37+
38+
afterEach(() => {
39+
cleanup();
40+
});
41+
42+
it('requires a reason before activating a role', async () => {
43+
const json = vi.fn();
44+
mocks.createClient.mockResolvedValue({ json });
45+
46+
render(<ClaimableRole assignment={assignment} onChange={vi.fn()} />);
47+
48+
fireEvent.click(screen.getByLabelText('Activate Developer role'));
49+
fireEvent.click(screen.getByRole('button', { name: 'Activate' }));
50+
51+
expect(await screen.findByText('Reason is required.')).toBeTruthy();
52+
expect(json).not.toHaveBeenCalled();
53+
});
54+
55+
it('activates a role and reports its updated state', async () => {
56+
const json = vi.fn().mockResolvedValue({ activeToDate: '2026-08-07T16:00:00Z' });
57+
const onChange = vi.fn();
58+
mocks.createClient.mockResolvedValue({ json });
59+
60+
render(<ClaimableRole assignment={assignment} onChange={onChange} />);
61+
62+
fireEvent.click(screen.getByLabelText('Activate Developer role'));
63+
fireEvent.change(screen.getByLabelText('Reason for activation'), {
64+
target: { value: 'Testing role-dependent behavior' },
65+
});
66+
fireEvent.click(screen.getByRole('button', { name: 'Activate' }));
67+
68+
await waitFor(() =>
69+
expect(onChange).toHaveBeenCalledWith({
70+
...assignment,
71+
isActive: true,
72+
activeTo: '2026-08-07T16:00:00Z',
73+
}),
74+
);
75+
expect(json).toHaveBeenCalledWith(
76+
'/accounts/account-id/claimable-role-assignments/assignment-id/activate',
77+
{
78+
method: 'POST',
79+
body: JSON.stringify({ reason: 'Testing role-dependent behavior', hours: 2 }),
80+
},
81+
);
82+
});
83+
});

packages/dev-portal/src/PersonSideSheet/sheets/roles/ClaimableRole.tsx

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -10,7 +10,7 @@ import {
1010
} from '@equinor/eds-core-react';
1111
import { useFramework } from '@equinor/fusion-framework-react';
1212
import { useCurrentUser } from '@equinor/fusion-framework-react/hooks';
13-
import type { ChangeEvent } from 'react';
13+
import type { ChangeEvent, ReactElement } from 'react';
1414
import { useState } from 'react';
1515
import styled from 'styled-components';
1616

@@ -63,7 +63,7 @@ const Styled = {
6363
* @param props.onChange - Reports successful activation state changes to the parent tab.
6464
* @returns A role row with activation controls.
6565
*/
66-
export const ClaimableRole = ({ assignment, onChange }: ClaimableRoleProps) => {
66+
export const ClaimableRole = ({ assignment, onChange }: ClaimableRoleProps): ReactElement => {
6767
const framework = useFramework();
6868
const user = useCurrentUser();
6969
const [isClaiming, setIsClaiming] = useState(false);
Lines changed: 64 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,64 @@
1+
import { cleanup, fireEvent, render, screen, waitFor } from '@testing-library/react';
2+
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
3+
4+
import { RolesSheetContent } from './RolesSheetContent';
5+
6+
const mocks = vi.hoisted(() => ({
7+
createClient: vi.fn(),
8+
currentUser: { localAccountId: 'account-id' },
9+
framework: {
10+
modules: { serviceDiscovery: { createClient: vi.fn() } },
11+
},
12+
}));
13+
14+
mocks.framework.modules.serviceDiscovery.createClient = mocks.createClient;
15+
16+
vi.mock('@equinor/fusion-framework-react', () => ({
17+
useFramework: () => mocks.framework,
18+
}));
19+
20+
vi.mock('@equinor/fusion-framework-react/hooks', () => ({
21+
useCurrentUser: () => mocks.currentUser,
22+
}));
23+
24+
vi.mock('./ClaimableRole', () => ({
25+
ClaimableRole: () => <div>Claimable role</div>,
26+
}));
27+
28+
describe('RolesSheetContent', () => {
29+
beforeEach(() => {
30+
mocks.createClient.mockReset();
31+
});
32+
33+
afterEach(() => {
34+
cleanup();
35+
});
36+
37+
it('renders loading and empty states', async () => {
38+
const json = vi.fn().mockResolvedValue([]);
39+
mocks.createClient.mockResolvedValue({ json });
40+
41+
render(<RolesSheetContent navigate={vi.fn()} />);
42+
43+
expect(screen.getByLabelText('Loading roles')).toBeTruthy();
44+
expect(await screen.findByText('You have no available roles')).toBeTruthy();
45+
expect(json).toHaveBeenCalledTimes(2);
46+
});
47+
48+
it('retries role retrieval after a failure', async () => {
49+
const json = vi
50+
.fn()
51+
.mockRejectedValueOnce(new Error('Roles service unavailable'))
52+
.mockResolvedValue([]);
53+
mocks.createClient.mockResolvedValue({ json });
54+
55+
render(<RolesSheetContent navigate={vi.fn()} />);
56+
57+
expect(await screen.findByText('Roles service unavailable')).toBeTruthy();
58+
fireEvent.click(screen.getByRole('button', { name: 'Retry' }));
59+
60+
expect(screen.getByLabelText('Loading roles')).toBeTruthy();
61+
await waitFor(() => expect(mocks.createClient).toHaveBeenCalledTimes(2));
62+
expect(await screen.findByText('You have no available roles')).toBeTruthy();
63+
});
64+
});

packages/dev-portal/src/PersonSideSheet/sheets/roles/RolesSheetContent.tsx

Lines changed: 25 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -10,7 +10,7 @@ import {
1010
import { arrow_back, verified_user } from '@equinor/eds-icons';
1111
import { useFramework } from '@equinor/fusion-framework-react';
1212
import { useCurrentUser } from '@equinor/fusion-framework-react/hooks';
13-
import { useEffect, useState } from 'react';
13+
import { type ReactElement, useEffect, useRef, useState } from 'react';
1414
import styled from 'styled-components';
1515

1616
import type { SheetContentProps } from '../types';
@@ -47,14 +47,24 @@ const Styled = {
4747
* @param props.navigate - Navigates back to the person side sheet landing page.
4848
* @returns A tabbed role overview with loading, error, and empty states.
4949
*/
50-
export const RolesSheetContent = ({ navigate }: SheetContentProps) => {
50+
export const RolesSheetContent = ({ navigate }: SheetContentProps): ReactElement => {
5151
const framework = useFramework();
5252
const user = useCurrentUser();
5353
const [tab, setTab] = useState(0);
5454
const [claimableRoles, setClaimableRoles] = useState<ClaimableRoleAssignment[]>([]);
5555
const [permanentRoles, setPermanentRoles] = useState<PermanentRoleAssignment[]>([]);
5656
const [isLoading, setIsLoading] = useState(true);
5757
const [error, setError] = useState<string>();
58+
const [loadAttempt, setLoadAttempt] = useState(0);
59+
const latestLoadAttempt = useRef(loadAttempt);
60+
latestLoadAttempt.current = loadAttempt;
61+
62+
/** Starts a fresh role request after a retrieval failure. */
63+
const handleRetry = (): void => {
64+
setError(undefined);
65+
setIsLoading(true);
66+
setLoadAttempt((attempt) => attempt + 1);
67+
};
5868

5969
/**
6070
* Replaces a changed assignment after activation or deactivation succeeds.
@@ -71,6 +81,7 @@ export const RolesSheetContent = ({ navigate }: SheetContentProps) => {
7181

7282
useEffect(() => {
7383
let isActive = true;
84+
const currentLoadAttempt = loadAttempt;
7485

7586
/** Loads both role collections together so the tabs represent one consistent snapshot. */
7687
const loadRoles = async (): Promise<void> => {
@@ -90,19 +101,19 @@ export const RolesSheetContent = ({ navigate }: SheetContentProps) => {
90101
]);
91102

92103
// Ignore a completed request after the side sheet content has unmounted.
93-
if (isActive) {
104+
if (isActive && latestLoadAttempt.current === currentLoadAttempt) {
94105
setClaimableRoles(claimable);
95106
setPermanentRoles(permanent);
96107
setError(undefined);
97108
}
98109
} catch (cause) {
99110
// Keep transport details out of the side sheet while preserving a useful retry direction.
100-
if (isActive) {
111+
if (isActive && latestLoadAttempt.current === currentLoadAttempt) {
101112
setError(cause instanceof Error ? cause.message : 'Failed to load roles.');
102113
}
103114
} finally {
104115
// Avoid updating state when navigation unmounts this sheet during a request.
105-
if (isActive) {
116+
if (isActive && latestLoadAttempt.current === currentLoadAttempt) {
106117
setIsLoading(false);
107118
}
108119
}
@@ -113,7 +124,7 @@ export const RolesSheetContent = ({ navigate }: SheetContentProps) => {
113124
return () => {
114125
isActive = false;
115126
};
116-
}, [framework, user?.localAccountId]);
127+
}, [framework, user?.localAccountId, loadAttempt]);
117128

118129
// Prepare role rows before markup so the tab panels only render presentation state.
119130
const claimableItems = claimableRoles.map((assignment) => (
@@ -154,9 +165,14 @@ export const RolesSheetContent = ({ navigate }: SheetContentProps) => {
154165
{isLoading ? (
155166
<CircularProgress aria-label="Loading roles" />
156167
) : error ? (
157-
<Banner>
158-
<Banner.Message>{error}</Banner.Message>
159-
</Banner>
168+
<>
169+
<Banner>
170+
<Banner.Message>{error}</Banner.Message>
171+
</Banner>
172+
<Button variant="outlined" onClick={handleRetry}>
173+
Retry
174+
</Button>
175+
</>
160176
) : (
161177
<Tabs activeTab={tab} onChange={(index) => setTab(Number(index))}>
162178
<Tabs.List>

packages/dev-portal/vitest.config.ts

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -4,9 +4,9 @@ import { name, version } from './package.json';
44

55
export default defineProject({
66
test: {
7-
include: ['src/**/*.test.ts'],
7+
include: ['src/**/*.test.{ts,tsx}'],
88
name: `${name}@${version}`,
9-
environment: 'node',
9+
environment: 'happy-dom',
1010
globals: true,
1111
},
1212
});

pnpm-lock.yaml

Lines changed: 6 additions & 0 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

0 commit comments

Comments
 (0)