Skip to content

Commit 1c27373

Browse files
committed
feat: enhance Assign Role Wizard
1 parent fc78cad commit 1c27373

9 files changed

Lines changed: 93 additions & 94 deletions

File tree

src/authz-module/constants.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -162,7 +162,7 @@ export const getPermissions = (resourceType: ResourceType) => {
162162
}
163163
};
164164

165-
// Get resource types by resource type
165+
// Get the resource sub-types (e.g. library, content, collection) for the given resource type
166166
export const getResourceTypes = (resourceType: ResourceType) => {
167167
switch (resourceType) {
168168
case RESOURCE_TYPES.LIBRARY:

src/authz-module/data/api.ts

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -50,14 +50,18 @@ export interface AssignTeamMembersRoleRequest {
5050
scope: string;
5151
}
5252

53-
// TODO: Validate Users API
5453
export type ValidateUsersRequest = {
5554
users: string[];
5655
};
5756

5857
export type ValidateUsersResponse = {
5958
validUsers: string[];
6059
invalidUsers: string[];
60+
summary: {
61+
total: number;
62+
validCount: number;
63+
invalidCount: number;
64+
};
6165
};
6266

6367
export const getTeamMembers = async (object: string, querySettings: QuerySettings): Promise<GetTeamMembersResponse> => {

src/authz-module/libraries-manager/components/AddNewTeamMemberModal/AddNewTeamMemberTrigger.tsx

Lines changed: 21 additions & 28 deletions
Original file line numberDiff line numberDiff line change
@@ -138,37 +138,30 @@ const AddNewTeamMemberTrigger = ({ libraryId }: AddNewTeamMemberTriggerProps) =>
138138
.filter(Boolean),
139139
)];
140140

141-
let usersToAssign = normalizedUsers;
142-
143-
try {
144-
const { invalidUsers } = await validateUsersAsync({ data: { users: normalizedUsers } });
145-
146-
if (invalidUsers.length > 0) {
147-
const notFoundMessage = intl.formatMessage(
148-
messages['libraries.authz.manage.add.member.failure.not.found'],
149-
{
150-
count: invalidUsers.length,
151-
userIds: invalidUsers.join(', '),
152-
Bold,
153-
Br,
154-
},
155-
);
156-
showToast({ message: notFoundMessage, type: 'error', delay: DEFAULT_TOAST_DELAY });
157-
setErrorUsers(invalidUsers);
158-
setIsError(true);
159-
setFormValues((prev) => ({ ...prev, users: invalidUsers.join(', ') }));
160-
return;
161-
}
162-
163-
usersToAssign = normalizedUsers.filter((u) => !invalidUsers.includes(u));
164-
} catch (validationError) {
165-
// If validation endpoint fails, fall through and let assignTeamMembersRole handle errors
141+
const { invalidUsers } = await validateUsersAsync({ data: { users: normalizedUsers } })
142+
.catch(() => ({ invalidUsers: [] }));
143+
144+
if (invalidUsers.length > 0) {
145+
const notFoundMessage = intl.formatMessage(
146+
messages['libraries.authz.manage.add.member.failure.not.found'],
147+
{
148+
count: invalidUsers.length,
149+
userIds: invalidUsers.join(', '),
150+
Bold,
151+
Br,
152+
},
153+
);
154+
showToast({ message: notFoundMessage, type: 'error', delay: DEFAULT_TOAST_DELAY });
155+
setErrorUsers(invalidUsers);
156+
setIsError(true);
157+
setFormValues((prev) => ({ ...prev, users: invalidUsers.join(', ') }));
158+
return;
166159
}
167160

168-
if (usersToAssign.length === 0) { return; }
161+
if (normalizedUsers.length === 0) { return; }
169162

170163
const payload = {
171-
users: usersToAssign,
164+
users: normalizedUsers,
172165
role: formValues.role,
173166
scope: libraryId,
174167
};
@@ -198,7 +191,7 @@ const AddNewTeamMemberTrigger = ({ libraryId }: AddNewTeamMemberTriggerProps) =>
198191
...roleAlreadyAssignedUsers.map(r => r.userIdentifier),
199192
];
200193

201-
const errorUserIds = usersToAssign.filter((user) => !successUserIds.includes(user));
194+
const errorUserIds = normalizedUsers.filter((user) => !successUserIds.includes(user));
202195
setErrorUsers(errorUserIds);
203196
setIsError(true);
204197
setFormValues((prev) => ({

src/authz-module/libraries-manager/components/TeamTable/index.test.tsx

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -4,7 +4,7 @@ import { renderWrapper } from '@src/setupTest';
44
import { useTeamMembers } from '@src/authz-module/data/hooks';
55
import { useLibraryAuthZ } from '@src/authz-module/libraries-manager/context';
66
import { ToastManagerProvider } from '@src/authz-module/libraries-manager/ToastManagerContext';
7-
import { CONTENT_LIBRARY_PERMISSIONS } from '@src/authz-module/libraries-manager/constants';
7+
import { CONTENT_LIBRARY_PERMISSIONS } from '@src/authz-module/constants';
88
import TeamTable from './index';
99

1010
const mockNavigate = jest.fn();

src/authz-module/wizard/AssignRoleWizard.tsx

Lines changed: 27 additions & 34 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,5 @@
11
import {
2-
useState, useCallback, useEffect, useMemo,
2+
useState, useCallback, useMemo, useRef, useEffect,
33
} from 'react';
44
import { useIntl } from '@edx/frontend-platform/i18n';
55
import {
@@ -8,11 +8,10 @@ import {
88
import { SpinnerSimple } from '@openedx/paragon/icons';
99
import SelectUsersAndRoleStep from './SelectUsersAndRoleStep';
1010
import DefineApplicationScopeStep from './DefineApplicationScopeStep';
11-
import { useValidateUsers, useAssignTeamMembersRole } from '../data/hooks';
11+
import { useValidateUsers } from '../data/hooks';
1212
import {
1313
CONTENT_LIBRARY_PERMISSIONS, COURSE_PERMISSIONS, courseRolesMetadata, libraryRolesMetadata,
1414
} from '../constants';
15-
import { useToastManager } from '../libraries-manager/ToastManagerContext';
1615
import { useValidateUserPermissions } from '../../data/hooks';
1716
import messages from './messages';
1817

@@ -47,9 +46,9 @@ const AssignRoleWizard = ({ onClose, scope, initialUsers = '' }: AssignRoleWizar
4746
const [invalidUsers, setInvalidUsers] = useState<string[]>([]);
4847
const [validatedUsers, setValidatedUsers] = useState<string[]>([]);
4948

49+
const usersInputRef = useRef<HTMLTextAreaElement>(null);
50+
5051
const validateUsersMutation = useValidateUsers();
51-
const assignRoleMutation = useAssignTeamMembersRole();
52-
const { showToast, showErrorToast } = useToastManager();
5352

5453
// Filter role groups based on what the current user is allowed to manage
5554
const permissionChecks = useMemo(() => [
@@ -59,12 +58,10 @@ const AssignRoleWizard = ({ onClose, scope, initialUsers = '' }: AssignRoleWizar
5958

6059
const { data: permissionsData } = useValidateUserPermissions(permissionChecks);
6160

62-
// Clear highlights as soon as the user edits the input
63-
useEffect(() => {
64-
if (invalidUsers.length > 0) {
65-
setInvalidUsers([]);
66-
}
67-
}, [users]); // eslint-disable-line react-hooks/exhaustive-deps
61+
const handleUsersChange = useCallback((value: string) => {
62+
setInvalidUsers((prev) => (prev.length > 0 ? [] : prev));
63+
setUsers(value);
64+
}, []);
6865

6966
const filteredRoles = useMemo(() => {
7067
const allowedContextTypes = new Set(
@@ -76,12 +73,6 @@ const AssignRoleWizard = ({ onClose, scope, initialUsers = '' }: AssignRoleWizar
7673
return allRolesMetadata.filter((r) => allowedContextTypes.has(r.contextType || ''));
7774
}, [permissionsData]);
7875

79-
useEffect(() => {
80-
if (filteredRoles.length === 0) {
81-
onClose();
82-
}
83-
}, [filteredRoles.length, onClose]);
84-
8576
const handleClose = () => {
8677
setActiveStep(STEPS.SELECT_USERS_AND_ROLE);
8778
setUsers('');
@@ -126,43 +117,44 @@ const AssignRoleWizard = ({ onClose, scope, initialUsers = '' }: AssignRoleWizar
126117
});
127118
}, []);
128119

129-
const handleSave = async () => {
120+
// TODO: replace with real assignment API call
121+
const handleSave = () => {
130122
if (!selectedRole || selectedScopes.size === 0 || validatedUsers.length === 0) { return; }
131123

132-
try {
133-
await Promise.all(
134-
Array.from(selectedScopes).map((selectedScope) => assignRoleMutation.mutateAsync({
135-
data: { users: validatedUsers, role: selectedRole, scope: selectedScope },
136-
})),
137-
);
138-
showToast({ message: intl.formatMessage(messages['wizard.save.success']), type: 'success', delay: 5000 });
139-
handleClose();
140-
} catch (error: unknown) {
141-
showErrorToast(error, handleSave);
142-
}
124+
// eslint-disable-next-line no-console
125+
console.log('[AssignRoleWizard] handleSave', { users: validatedUsers, role: selectedRole, scopes: Array.from(selectedScopes) });
126+
handleClose();
143127
};
144128

145-
const canProceed = users.trim() && selectedRole && !validateUsersMutation.isPending;
146-
const canSave = selectedScopes.size > 0 && !assignRoleMutation.isPending;
129+
const isError = invalidUsers.length > 0 || !!validationError;
130+
131+
useEffect(() => {
132+
if (isError && usersInputRef.current) {
133+
usersInputRef.current.scrollIntoView({ behavior: 'smooth', block: 'center' });
134+
}
135+
}, [isError]);
136+
137+
const canProceed = !!users.trim() && !!selectedRole;
138+
const canSave = selectedScopes.size > 0;
147139

148140
return (
149141
<Stepper activeKey={activeStep}>
150142
<Stepper.Header className="bg-info-100" />
151143

152144
<Container className="p-5">
153145
<Stepper.Step
154-
onClick={() => setActiveStep(STEPS.SELECT_USERS_AND_ROLE)}
155146
eventKey={STEPS.SELECT_USERS_AND_ROLE}
156147
title={intl.formatMessage(messages['wizard.step.selectUsersAndRole.title'])}
157148
>
158149
<SelectUsersAndRoleStep
159150
users={users}
160-
setUsers={setUsers}
151+
setUsers={handleUsersChange}
161152
selectedRole={selectedRole}
162153
setSelectedRole={setSelectedRole}
163154
roles={filteredRoles}
164155
validationError={validationError}
165156
invalidUsers={invalidUsers}
157+
inputRef={usersInputRef}
166158
/>
167159
</Stepper.Step>
168160

@@ -191,6 +183,7 @@ const AssignRoleWizard = ({ onClose, scope, initialUsers = '' }: AssignRoleWizar
191183
pending: intl.formatMessage(messages['wizard.button.next.pending']),
192184
}}
193185
icons={{ pending: <Icon src={SpinnerSimple} /> }}
186+
variant={isError ? 'danger' : 'primary'}
194187
state={validateUsersMutation.isPending ? 'pending' : 'default'}
195188
onClick={validateUsersAndProceed}
196189
disabled={!canProceed || validateUsersMutation.isPending}
@@ -211,7 +204,7 @@ const AssignRoleWizard = ({ onClose, scope, initialUsers = '' }: AssignRoleWizar
211204
pending: intl.formatMessage(messages['wizard.button.save.pending']),
212205
}}
213206
icons={{ pending: <Icon src={SpinnerSimple} /> }}
214-
state={assignRoleMutation.isPending ? 'pending' : 'default'}
207+
state="default"
215208
onClick={handleSave}
216209
disabled={!canSave}
217210
/>

src/authz-module/wizard/AssignRoleWizardPage.tsx

Lines changed: 3 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -7,22 +7,15 @@ import { ROUTES } from '../constants';
77
import messages from './messages';
88

99
const AssignRoleWizardPage = () => {
10-
const navigate = useNavigate();
1110
const intl = useIntl();
11+
const navigate = useNavigate();
1212
const [searchParams] = useSearchParams();
13-
const scope = searchParams.get('scope') || '';
13+
const scope = searchParams.get('scope')!;
1414
const initialUsers = searchParams.get('users') || '';
15-
1615
const { data: library } = useLibrary(scope);
1716

18-
if (!scope || !library) { return null; }
19-
2017
const teamMembersPath = `/authz${ROUTES.LIBRARIES_TEAM_PATH.replace(':libraryId', scope)}`;
2118

22-
const handleCancel = () => {
23-
navigate(teamMembersPath);
24-
};
25-
2619
return (
2720
<AuthZLayout
2821
context={{ id: scope, title: library.title, org: library.org }}
@@ -33,7 +26,7 @@ const AssignRoleWizardPage = () => {
3326
actions={[]}
3427
>
3528
<AssignRoleWizard
36-
onClose={handleCancel}
29+
onClose={() => navigate(teamMembersPath)}
3730
scope={scope}
3831
initialUsers={initialUsers}
3932
/>

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

Lines changed: 7 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,7 @@ import userEvent from '@testing-library/user-event';
33
import HighlightedUsersInput from './HighlightedUsersInput';
44

55
const defaultProps = {
6+
id: 'users-input',
67
value: '',
78
onChange: jest.fn(),
89
invalidUsers: [],
@@ -33,23 +34,20 @@ describe('HighlightedUsersInput', () => {
3334
const { container } = render(
3435
<HighlightedUsersInput {...defaultProps} value="jdoe, baduser" invalidUsers={['baduser']} />,
3536
);
36-
const overlay = container.querySelector('[aria-hidden="true"]');
37-
const spans = overlay!.querySelectorAll('span');
38-
const redSpan = Array.from(spans).find((s) => (s as HTMLElement).style.color === 'rgb(198, 40, 40)');
39-
expect(redSpan).toBeDefined();
40-
expect(redSpan!.textContent).toContain('baduser');
37+
const invalidSpan = container.querySelector('[data-invalid]');
38+
expect(invalidSpan).toBeInTheDocument();
39+
expect(invalidSpan!.textContent).toContain('baduser');
4140
});
4241

4342
it('renders valid user parts in dark color', () => {
4443
const { container } = render(
4544
<HighlightedUsersInput {...defaultProps} value="jdoe, baduser" invalidUsers={['baduser']} />,
4645
);
4746
const overlay = container.querySelector('[aria-hidden="true"]');
48-
const spans = overlay!.querySelectorAll('span');
49-
const darkSpan = Array.from(spans).find(
50-
(s) => (s as HTMLElement).style.color === 'rgb(33, 37, 41)' && s.textContent?.includes('jdoe'),
47+
const validSpan = Array.from(overlay!.querySelectorAll('span:not([data-invalid])')).find(
48+
(s) => s.textContent?.includes('jdoe'),
5149
);
52-
expect(darkSpan).toBeDefined();
50+
expect(validSpan).toBeDefined();
5351
});
5452

5553
it('shows placeholder when no invalid users', () => {

src/authz-module/wizard/HighlightedUsersInput.tsx

Lines changed: 23 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,6 @@
1-
import { useMemo, useRef } from 'react';
1+
import {
2+
useMemo, useRef, useCallback, forwardRef,
3+
} from 'react';
24

35
// Shared styles between overlay and textarea to keep text positions in sync
46
const INPUT_STYLE: React.CSSProperties = {
@@ -14,16 +16,17 @@ const INPUT_STYLE: React.CSSProperties = {
1416
};
1517

1618
interface HighlightedUsersInputProps {
19+
id: string;
1720
value: string;
1821
onChange: (val: string) => void;
1922
invalidUsers: string[];
2023
placeholder?: string;
2124
hasNetworkError?: boolean;
2225
}
2326

24-
const HighlightedUsersInput = ({
25-
value, onChange, invalidUsers, placeholder, hasNetworkError = false,
26-
}: HighlightedUsersInputProps) => {
27+
const HighlightedUsersInput = forwardRef<HTMLTextAreaElement, HighlightedUsersInputProps>(({
28+
id, value, onChange, invalidUsers, placeholder, hasNetworkError = false,
29+
}, ref) => {
2730
const overlayRef = useRef<HTMLDivElement>(null);
2831

2932
const invalidSet = useMemo(
@@ -42,18 +45,26 @@ const HighlightedUsersInput = ({
4245
const trimmed = part.trim();
4346
const isInvalid = trimmed.length > 0 && invalidSet.has(trimmed);
4447
return (
45-
<span key={`part-${key}`} style={{ color: isInvalid ? '#c62828' : '#212529' }}>
48+
<span
49+
key={`part-${key}`}
50+
data-invalid={isInvalid || undefined}
51+
style={{
52+
color: isInvalid
53+
? 'var(--pgn-color-danger-500)'
54+
: 'var(--pgn-color-gray-700)',
55+
}}
56+
>
4657
{part}
4758
</span>
4859
);
4960
});
5061
}, [value, invalidSet, hasHighlights]);
5162

52-
const handleScroll = (e: React.UIEvent<HTMLTextAreaElement>) => {
63+
const handleScroll = useCallback((e: { currentTarget: HTMLTextAreaElement }) => {
5364
if (overlayRef.current) {
5465
overlayRef.current.scrollTop = e.currentTarget.scrollTop;
5566
}
56-
};
67+
}, []);
5768

5869
return (
5970
<div style={{ position: 'relative' }}>
@@ -80,7 +91,8 @@ const HighlightedUsersInput = ({
8091

8192
{/* Actual textarea — text is transparent when overlay is active */}
8293
<textarea
83-
id="users-input"
94+
ref={ref}
95+
id={id}
8496
rows={4}
8597
value={value}
8698
onChange={(e) => onChange(e.target.value)}
@@ -101,6 +113,8 @@ const HighlightedUsersInput = ({
101113
/>
102114
</div>
103115
);
104-
};
116+
});
117+
118+
HighlightedUsersInput.displayName = 'HighlightedUsersInput';
105119

106120
export default HighlightedUsersInput;

0 commit comments

Comments
 (0)