Skip to content

Commit 1867e05

Browse files
committed
feat: enhance Assign Role Wizard with multi-step functionality and scope management
1 parent 39d69ab commit 1867e05

11 files changed

Lines changed: 162 additions & 192 deletions

src/authz-module/data/types.ts

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -38,14 +38,14 @@ export type PermissionsByRole = {
3838
};
3939

4040
export interface PutAssignTeamMembersRoleResponse {
41-
completed: { userIdentifier: string; status: string }[];
42-
errors: { userIdentifier: string; error: string }[];
41+
completed: { userIdentifier: string; scope: string; status: string }[];
42+
errors: { userIdentifier: string; scope: string; error: string }[];
4343
}
4444

4545
export interface AssignTeamMembersRoleRequest {
4646
users: string[];
4747
role: string;
48-
scope: string;
48+
scopes: string[];
4949
}
5050

5151
export type ValidateUsersRequest = {

src/authz-module/index.scss

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -54,6 +54,11 @@
5454
}
5555
}
5656

57+
.scope-list {
58+
max-height: 500px;
59+
overflow-y: auto;
60+
}
61+
5762
.toast-container {
5863
// Ensure toast appears above modal
5964
z-index: 1000;

src/authz-module/wizard/AssignRoleWizard.test.tsx

Lines changed: 65 additions & 117 deletions
Original file line numberDiff line numberDiff line change
@@ -2,37 +2,51 @@ import { screen, waitFor } from '@testing-library/react';
22
import userEvent from '@testing-library/user-event';
33
import { renderWrapper } from '@src/setupTest';
44
import { getAuthenticatedUser } from '@edx/frontend-platform/auth';
5-
import { useValidateUsers } from '../data/hooks';
5+
import { useValidateUsers, useAssignTeamMembersRole } from '../data/hooks';
66
import { ToastManagerProvider } from '../libraries-manager/ToastManagerContext';
77
import AssignRoleWizard from './AssignRoleWizard';
88

99
jest.mock('@edx/frontend-platform/auth', () => ({
1010
getAuthenticatedUser: jest.fn(),
1111
}));
1212

13+
jest.mock('../data/hooks', () => ({
14+
useValidateUsers: jest.fn(),
15+
useAssignTeamMembersRole: jest.fn(),
16+
}));
17+
18+
jest.mock('./DefineApplicationScopeStep', () => ({
19+
__esModule: true,
20+
default: ({ onScopeToggle }: { onScopeToggle: (id: string) => void }) => (
21+
<div>
22+
<button type="button" data-testid="toggle-scope" onClick={() => onScopeToggle('lib:test:123')}>
23+
Toggle scope
24+
</button>
25+
</div>
26+
),
27+
}));
28+
1329
const mockUseValidateUsers = useValidateUsers as jest.Mock;
30+
const mockUseAssignTeamMembersRole = useAssignTeamMembersRole as jest.Mock;
1431
const mockValidateMutateAsync = jest.fn();
32+
const mockAssignMutateAsync = jest.fn();
1533

1634
const renderWizard = (props = {}) => renderWrapper(
1735
<ToastManagerProvider>
1836
<AssignRoleWizard onClose={jest.fn()} {...props} />
1937
</ToastManagerProvider>,
2038
);
2139

22-
// selectors
2340
const getUsersInput = () => screen.getByLabelText(/Add users by username or email/i);
2441
const getRoleRadio = (name: RegExp) => screen.getByRole('radio', { name });
2542
const getNextButton = () => screen.getByRole('button', { name: /^Next$/i });
2643
const getCancelButton = () => screen.getByRole('button', { name: /^Cancel$/i });
27-
const getStep2Heading = () => screen.queryByRole('heading', { name: 'Step 2' });
2844

2945
describe('AssignRoleWizard — Step 1', () => {
3046
beforeEach(() => {
3147
jest.clearAllMocks();
32-
mockUseValidateUsers.mockReturnValue({
33-
mutateAsync: mockValidateMutateAsync,
34-
isPending: false,
35-
});
48+
mockUseValidateUsers.mockReturnValue({ mutateAsync: mockValidateMutateAsync, isPending: false });
49+
mockUseAssignTeamMembersRole.mockReturnValue({ mutateAsync: mockAssignMutateAsync, isPending: false });
3650
(getAuthenticatedUser as jest.Mock).mockReturnValue({ administrator: true });
3751
});
3852

@@ -49,6 +63,11 @@ describe('AssignRoleWizard — Step 1', () => {
4963
expect(getUsersInput()).toHaveValue('alice');
5064
});
5165

66+
it('Next button is disabled without both users and a role', () => {
67+
renderWizard();
68+
expect(getNextButton()).toBeDisabled();
69+
});
70+
5271
it('selecting a different role replaces the previous selection', async () => {
5372
const user = userEvent.setup();
5473
renderWizard();
@@ -67,7 +86,7 @@ describe('AssignRoleWizard — Step 1', () => {
6786
await user.click(getNextButton());
6887
await waitFor(() => {
6988
expect(screen.getByText(/not associated with an account/i)).toBeInTheDocument();
70-
expect(getStep2Heading()).not.toBeInTheDocument();
89+
expect(screen.queryByTestId('toggle-scope')).not.toBeInTheDocument();
7190
});
7291
});
7392

@@ -79,7 +98,7 @@ describe('AssignRoleWizard — Step 1', () => {
7998
await user.click(getRoleRadio(/Library Admin/i));
8099
await user.click(getNextButton());
81100
await waitFor(() => {
82-
expect(screen.getByRole('heading', { name: 'Step 2' })).toBeInTheDocument();
101+
expect(screen.getByTestId('toggle-scope')).toBeInTheDocument();
83102
});
84103
});
85104

@@ -91,9 +110,8 @@ describe('AssignRoleWizard — Step 1', () => {
91110
await user.click(getRoleRadio(/Library Admin/i));
92111
await user.click(getNextButton());
93112
await waitFor(() => {
94-
// Toast should appear with retry option
95113
expect(screen.getByRole('alert')).toBeInTheDocument();
96-
expect(getStep2Heading()).not.toBeInTheDocument();
114+
expect(screen.queryByTestId('toggle-scope')).not.toBeInTheDocument();
97115
});
98116
});
99117

@@ -117,18 +135,11 @@ describe('AssignRoleWizard — Step 1', () => {
117135
renderWizard();
118136
await user.type(getUsersInput(), 'alice');
119137
await user.click(getRoleRadio(/Library Admin/i));
120-
121-
// First attempt fails - toast appears with retry
122138
await user.click(getNextButton());
123139
await waitFor(() => expect(screen.getByRole('alert')).toBeInTheDocument());
124-
125-
// Click retry button
126-
const retryButton = screen.getByRole('button', { name: /retry/i });
127-
await user.click(retryButton);
128-
129-
// Should advance to step 2 on retry
140+
await user.click(screen.getByRole('button', { name: /retry/i }));
130141
await waitFor(() => {
131-
expect(getStep2Heading()).toBeInTheDocument();
142+
expect(screen.getByTestId('toggle-scope')).toBeInTheDocument();
132143
});
133144
});
134145
});
@@ -140,16 +151,15 @@ describe('AssignRoleWizard — Step 2', () => {
140151
await user.click(getRoleRadio(/Library Admin/i));
141152
await user.click(getNextButton());
142153
await waitFor(() => {
143-
expect(screen.getByRole('heading', { name: 'Step 2' })).toBeInTheDocument();
154+
expect(screen.getByTestId('toggle-scope')).toBeInTheDocument();
144155
});
145156
};
146157

147158
beforeEach(() => {
148159
jest.clearAllMocks();
149-
mockUseValidateUsers.mockReturnValue({
150-
mutateAsync: mockValidateMutateAsync,
151-
isPending: false,
152-
});
160+
mockUseValidateUsers.mockReturnValue({ mutateAsync: mockValidateMutateAsync, isPending: false });
161+
mockUseAssignTeamMembersRole.mockReturnValue({ mutateAsync: mockAssignMutateAsync, isPending: false });
162+
(getAuthenticatedUser as jest.Mock).mockReturnValue({ administrator: true });
153163
});
154164

155165
it('Back button returns to Step 1', async () => {
@@ -161,119 +171,57 @@ describe('AssignRoleWizard — Step 2', () => {
161171
expect(getUsersInput()).toBeInTheDocument();
162172
});
163173
});
164-
});
165-
166-
describe('AssignRoleWizard — Step 2', () => {
167-
const mockOnScopeToggle = jest.fn();
168174

169-
beforeEach(() => {
170-
jest.clearAllMocks();
171-
mockOnScopeToggle.mockClear();
172-
mockUseValidateUsers.mockReturnValue({
173-
mutateAsync: mockValidateMutateAsync,
174-
isPending: false,
175-
});
176-
(getAuthenticatedUser as jest.Mock).mockReturnValue({ administrator: true });
177-
});
178-
179-
it('advances to Step 2 with valid users', async () => {
180-
const user = userEvent.setup();
181-
mockValidateMutateAsync.mockResolvedValue({ invalidUsers: [], validUsers: ['alice'] });
182-
renderWizard();
183-
await user.type(screen.getByTestId('users-input'), 'alice');
184-
await user.click(screen.getByTestId('role-option-library_admin'));
185-
await user.click(screen.getByRole('button', { name: /Next/i }));
186-
await waitFor(() => {
187-
expect(screen.getByTestId('stepper')).toHaveAttribute('data-active-step', 'define-application-scope');
188-
});
189-
});
190-
191-
it('calls onScopeToggle when scope is selected', async () => {
175+
it('Save button is disabled when no scopes are selected', async () => {
192176
const user = userEvent.setup();
193-
mockValidateMutateAsync.mockResolvedValue({ invalidUsers: [], validUsers: ['alice'] });
194177
renderWizard();
195-
await user.type(screen.getByTestId('users-input'), 'alice');
196-
await user.click(screen.getByTestId('role-option-library_admin'));
197-
await user.click(screen.getByRole('button', { name: /Next/i }));
198-
await waitFor(() => {
199-
expect(screen.getByTestId('stepper')).toHaveAttribute('data-active-step', 'define-application-scope');
200-
});
201-
await user.click(screen.getByTestId('toggle-scope'));
202-
// Scope toggle is called from the mocked component
178+
await advanceToStep2(user);
179+
expect(screen.getByRole('button', { name: /^Save$/i })).toBeDisabled();
203180
});
204181

205-
it('saves role assignment successfully', async () => {
182+
it('saves role assignment successfully and closes the wizard', async () => {
206183
const user = userEvent.setup();
207-
mockValidateMutateAsync.mockResolvedValue({ invalidUsers: [], validUsers: ['alice'] });
208184
const onClose = jest.fn();
185+
mockAssignMutateAsync.mockResolvedValue({
186+
completed: [{ userIdentifier: 'alice', scope: 'lib:test:123', status: 'role_added' }],
187+
errors: [],
188+
});
209189
renderWizard({ onClose });
210-
await user.type(screen.getByTestId('users-input'), 'alice');
211-
await user.click(screen.getByTestId('role-option-library_admin'));
212-
await user.click(screen.getByRole('button', { name: /Next/i }));
190+
await advanceToStep2(user);
191+
await user.click(screen.getByTestId('toggle-scope'));
192+
await user.click(screen.getByRole('button', { name: /^Save$/i }));
213193
await waitFor(() => {
214-
expect(screen.getByTestId('stepper')).toHaveAttribute('data-active-step', 'define-application-scope');
194+
expect(mockAssignMutateAsync).toHaveBeenCalledWith({
195+
data: { users: ['alice'], role: 'library_admin', scopes: ['lib:test:123'] },
196+
});
197+
expect(onClose).toHaveBeenCalled();
215198
});
216-
await user.click(screen.getByTestId('toggle-scope'));
217-
await user.click(screen.getByRole('button', { name: /Save/i }));
218-
// The save button is in Step 2
219199
});
220200

221-
// Skipped - complex integration test that requires proper scope selection propagation
222-
// The error handling logic is tested in the code and works correctly in production
223-
it.skip('shows error when save fails', async () => {
201+
it('shows error toast when the API returns assignment errors', async () => {
224202
const user = userEvent.setup();
225-
mockValidateMutateAsync.mockResolvedValue({ invalidUsers: [], validUsers: ['alice'] });
203+
mockAssignMutateAsync.mockResolvedValue({
204+
completed: [],
205+
errors: [{ userIdentifier: 'alice', scope: 'lib:test:123', error: 'already has role' }],
206+
});
226207
renderWizard();
227-
await user.type(screen.getByTestId('users-input'), 'alice');
228-
await user.click(screen.getByTestId('role-option-library_admin'));
229-
await user.click(screen.getByRole('button', { name: /Next/i }));
208+
await advanceToStep2(user);
209+
await user.click(screen.getByTestId('toggle-scope'));
210+
await user.click(screen.getByRole('button', { name: /^Save$/i }));
230211
await waitFor(() => {
231-
expect(screen.getByTestId('stepper')).toHaveAttribute('data-active-step', 'define-application-scope');
212+
expect(screen.getByRole('alert')).toBeInTheDocument();
232213
});
233214
});
234215

235-
it('goes back to Step 1', async () => {
216+
it('shows error toast when save throws a network error', async () => {
236217
const user = userEvent.setup();
237-
mockValidateMutateAsync.mockResolvedValue({ invalidUsers: [], validUsers: ['alice'] });
218+
mockAssignMutateAsync.mockRejectedValue(new Error('Network error'));
238219
renderWizard();
239-
await user.type(screen.getByTestId('users-input'), 'alice');
240-
await user.click(screen.getByTestId('role-option-library_admin'));
241-
await user.click(screen.getByRole('button', { name: /Next/i }));
242-
await waitFor(() => {
243-
expect(screen.getByTestId('stepper')).toHaveAttribute('data-active-step', 'define-application-scope');
244-
});
245-
await user.click(screen.getByRole('button', { name: /Back/i }));
220+
await advanceToStep2(user);
221+
await user.click(screen.getByTestId('toggle-scope'));
222+
await user.click(screen.getByRole('button', { name: /^Save$/i }));
246223
await waitFor(() => {
247-
expect(screen.getByTestId('stepper')).toHaveAttribute('data-active-step', 'select-users-and-role');
248-
});
249-
});
250-
});
251-
252-
describe('AssignRoleWizard — error handling', () => {
253-
beforeEach(() => {
254-
jest.clearAllMocks();
255-
mockUseValidateUsers.mockReturnValue({
256-
mutateAsync: mockValidateMutateAsync,
257-
isPending: false,
224+
expect(screen.getByRole('alert')).toBeInTheDocument();
258225
});
259-
(getAuthenticatedUser as jest.Mock).mockReturnValue({ administrator: true });
260-
});
261-
262-
it('returns early when no role selected', async () => {
263-
mockValidateMutateAsync.mockResolvedValue({ invalidUsers: [], validUsers: ['alice'] });
264-
renderWizard();
265-
await userEvent.type(screen.getByTestId('users-input'), 'alice');
266-
await userEvent.click(screen.getByRole('button', { name: /Next/i }));
267-
// Without a role, should not proceed
268-
});
269-
270-
it('returns early when no scopes selected', async () => {
271-
const user = userEvent.setup();
272-
mockValidateMutateAsync.mockResolvedValue({ invalidUsers: [], validUsers: ['alice'] });
273-
renderWizard();
274-
await user.type(screen.getByTestId('users-input'), 'alice');
275-
await user.click(screen.getByTestId('role-option-library_admin'));
276-
await user.click(screen.getByRole('button', { name: /Next/i }));
277-
// Without scopes in step 2, save returns early
278226
});
279227
});

0 commit comments

Comments
 (0)