Skip to content

Commit aaae8b9

Browse files
committed
feat(authz): enhance role assignment wizard with improved scope handling and UI updates
1 parent 6eccc79 commit aaae8b9

9 files changed

Lines changed: 74 additions & 71 deletions

File tree

src/authz-module/data/hooks.ts

Lines changed: 4 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -173,13 +173,13 @@ export const useAllRoleAssignments = (querySettings: QuerySettings) => {
173173
* Results are cached for 30 minutes — suitable for both filter dropdowns and full listings.
174174
*
175175
* @param search - Optional text filter applied to organization names.
176-
* @param page - Page number to fetch (1-based). Omit to fetch the first page.
177-
* @param pageSize - Number of items per page. Omit to use the API default.
176+
* @param page - 1-based page number; defaults to the first page when omitted.
177+
* @param pageSize - Items per page; defaults to the API default when omitted.
178178
* @returns A `QueryResult<GetOrgsResponse>` with `results`, `count`, `next`, and `previous`.
179179
*
180180
* @example
181181
* ```tsx
182-
* const { data } = useOrgs({ search: 'edX' });
182+
* const { data } = useOrgs('edX');
183183
* const orgs = data?.results ?? [];
184184
* ```
185185
*/
@@ -223,6 +223,7 @@ export const useScopes = (params: Omit<GetScopesParams, 'page'> = {}) => useInfi
223223
},
224224
initialPageParam: 1,
225225
staleTime: 1000 * 60 * 5,
226+
refetchOnWindowFocus: false,
226227
});
227228

228229
/*

src/authz-module/index.scss

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -127,6 +127,10 @@
127127
max-height: 500px;
128128
}
129129

130+
.scope-search-input {
131+
width: 18.75rem; // 300px
132+
}
133+
130134
.toast-container {
131135
// Ensure toast appears above modal
132136
z-index: 1000;

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

Lines changed: 8 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -103,7 +103,7 @@ describe('AssignRoleWizard — Step 1', () => {
103103
await user.click(getNextButton());
104104
await waitFor(() => {
105105
expect(screen.getByText(/not associated with an account/i)).toBeInTheDocument();
106-
expect(screen.queryByTestId('toggle-scope')).not.toBeInTheDocument();
106+
expect(screen.queryByTestId('toggle-scope-*')).not.toBeInTheDocument();
107107
});
108108
});
109109

@@ -115,7 +115,7 @@ describe('AssignRoleWizard — Step 1', () => {
115115
await user.click(getRoleRadio(/Library Admin/i));
116116
await user.click(getNextButton());
117117
await waitFor(() => {
118-
expect(screen.getByTestId('toggle-scope')).toBeInTheDocument();
118+
expect(screen.getByTestId('toggle-scope-*')).toBeInTheDocument();
119119
});
120120
});
121121

@@ -128,7 +128,7 @@ describe('AssignRoleWizard — Step 1', () => {
128128
await user.click(getNextButton());
129129
await waitFor(() => {
130130
expect(screen.getByRole('alert')).toBeInTheDocument();
131-
expect(screen.queryByTestId('toggle-scope')).not.toBeInTheDocument();
131+
expect(screen.queryByTestId('toggle-scope-*')).not.toBeInTheDocument();
132132
});
133133
});
134134

@@ -156,7 +156,7 @@ describe('AssignRoleWizard — Step 1', () => {
156156
await waitFor(() => expect(screen.getByRole('alert')).toBeInTheDocument());
157157
await user.click(screen.getByRole('button', { name: /retry/i }));
158158
await waitFor(() => {
159-
expect(screen.getByTestId('toggle-scope')).toBeInTheDocument();
159+
expect(screen.getByTestId('toggle-scope-*')).toBeInTheDocument();
160160
});
161161
});
162162
});
@@ -168,7 +168,7 @@ describe('AssignRoleWizard — Step 2', () => {
168168
await user.click(getRoleRadio(/Library Admin/i));
169169
await user.click(getNextButton());
170170
await waitFor(() => {
171-
expect(screen.getByTestId('toggle-scope')).toBeInTheDocument();
171+
expect(screen.getByTestId('toggle-scope-*')).toBeInTheDocument();
172172
});
173173
};
174174

@@ -207,7 +207,7 @@ describe('AssignRoleWizard — Step 2', () => {
207207
});
208208
renderWizard({ onClose });
209209
await advanceToStep2(user);
210-
await user.click(screen.getByTestId('toggle-scope'));
210+
await user.click(screen.getByTestId('toggle-scope-*'));
211211
await user.click(screen.getByRole('button', { name: /^Save$/i }));
212212
await waitFor(() => {
213213
expect(mockAssignMutateAsync).toHaveBeenCalledWith({
@@ -225,7 +225,7 @@ describe('AssignRoleWizard — Step 2', () => {
225225
});
226226
renderWizard();
227227
await advanceToStep2(user);
228-
await user.click(screen.getByTestId('toggle-scope'));
228+
await user.click(screen.getByTestId('toggle-scope-*'));
229229
await user.click(screen.getByRole('button', { name: /^Save$/i }));
230230
await waitFor(() => {
231231
expect(screen.getByText(/Some assignments could not be completed/i)).toBeInTheDocument();
@@ -239,7 +239,7 @@ describe('AssignRoleWizard — Step 2', () => {
239239
mockAssignMutateAsync.mockRejectedValue(new Error('Network error'));
240240
renderWizard();
241241
await advanceToStep2(user);
242-
await user.click(screen.getByTestId('toggle-scope'));
242+
await user.click(screen.getByTestId('toggle-scope-*'));
243243
await user.click(screen.getByRole('button', { name: /^Save$/i }));
244244
await waitFor(() => {
245245
expect(screen.getByRole('alert')).toBeInTheDocument();

src/authz-module/role-assignation-wizard/components/DefineApplicationScopeStep.tsx

Lines changed: 2 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -58,7 +58,7 @@ const DefineApplicationScopeStep = ({
5858
totalCount,
5959
queryState,
6060
platformAggregateScopeItem,
61-
showOrgAggregates,
61+
orgAggregateScopeItems,
6262
} = useScopeListData({ contextType, search: debouncedSearch, org: selectedOrgs[0] || '' });
6363

6464
return (
@@ -97,8 +97,7 @@ const DefineApplicationScopeStep = ({
9797
onScopeToggle={onScopeToggle}
9898
queryState={queryState}
9999
platformAggregateScopeItem={platformAggregateScopeItem}
100-
showOrgAggregates={showOrgAggregates}
101-
contextType={contextType}
100+
orgAggregateScopeItems={orgAggregateScopeItems}
102101
/>
103102
</div>
104103
);

src/authz-module/role-assignation-wizard/components/ScopeCheckboxItem.tsx

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -12,7 +12,7 @@ const ScopeCheckboxItem = ({ scope, checked, onToggle }: ScopeCheckboxItemProps)
1212
<Form.Checkbox
1313
checked={checked}
1414
onChange={() => onToggle(scope.externalKey)}
15-
data-testid="toggle-scope"
15+
data-testid={`toggle-scope-${scope.externalKey}`}
1616
>
1717
{scope.displayName}
1818
</Form.Checkbox>

src/authz-module/role-assignation-wizard/components/ScopeFilterBar.tsx

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -34,7 +34,7 @@ const ScopeFilterBar = ({
3434
<>
3535
<div className="d-flex align-items-center justify-content-between gap-3 mb-2 flex-wrap">
3636
<div className="d-flex align-items-center gap-3">
37-
<div style={{ width: '300px' }}>
37+
<div className="scope-search-input">
3838
<Form.Group controlId="scope-search" className="mb-0">
3939
<Form.Control
4040
type="text"
@@ -49,7 +49,7 @@ const ScopeFilterBar = ({
4949
<OrgFilter
5050
filterButtonText={intl.formatMessage(messages['wizard.step2.filter.org.label'])}
5151
filterValue={selectedOrgs}
52-
setFilter={(value) => onOrgsChange(value)}
52+
setFilter={onOrgsChange}
5353
/>
5454
</div>
5555

src/authz-module/role-assignation-wizard/components/ScopeList.tsx

Lines changed: 12 additions & 39 deletions
Original file line numberDiff line numberDiff line change
@@ -22,8 +22,7 @@ interface ScopeListProps {
2222
onScopeToggle: (scopeId: string) => void;
2323
queryState: ScopeListQueryState;
2424
platformAggregateScopeItem: Scope | null;
25-
showOrgAggregates: boolean;
26-
contextType: string | undefined;
25+
orgAggregateScopeItems: Record<string, Scope>;
2726
}
2827

2928
const ScopeList = ({
@@ -34,27 +33,12 @@ const ScopeList = ({
3433
onScopeToggle,
3534
queryState,
3635
platformAggregateScopeItem,
37-
showOrgAggregates,
38-
contextType,
36+
orgAggregateScopeItems,
3937
}: ScopeListProps) => {
4038
const intl = useIntl();
4139
const {
4240
isLoading, isFetchingNextPage, hasNextPage, isError, fetchNextPage,
4341
} = queryState;
44-
const aggregateLabelMessages = {
45-
course: messages['wizard.step2.scopeList.aggregate.label.course'],
46-
library: messages['wizard.step2.scopeList.aggregate.label.library'],
47-
};
48-
const aggregateDescriptionMessages = {
49-
course: messages['wizard.step2.scope.aggregate.description.course'],
50-
library: messages['wizard.step2.scope.aggregate.description.library'],
51-
};
52-
const aggregateLabel = contextType && aggregateLabelMessages[contextType]
53-
? intl.formatMessage(aggregateLabelMessages[contextType])
54-
: '';
55-
const aggregateDescription = contextType && aggregateDescriptionMessages[contextType]
56-
? intl.formatMessage(aggregateDescriptionMessages[contextType])
57-
: '';
5842
const loadMoreRef = useRef<HTMLDivElement>(null);
5943

6044
useEffect(() => {
@@ -88,27 +72,16 @@ const ScopeList = ({
8872
/>
8973
)}
9074

91-
{orderedOrgs.map((org) => {
92-
const aggregateScopeItem: Scope | undefined = (contextType && showOrgAggregates)
93-
? {
94-
externalKey: contextType === 'course' ? `course-v1:${org}+*` : `lib:${org}:*`,
95-
displayName: aggregateLabel,
96-
description: aggregateDescription,
97-
org: { id: '', name: org, shortName: org },
98-
}
99-
: undefined;
100-
101-
return (
102-
<OrgSection
103-
key={org}
104-
orgName={organizations?.find((o) => o.shortName === org)?.name || org}
105-
scopes={scopesByOrg[org]}
106-
selectedScopes={selectedScopes}
107-
onScopeToggle={onScopeToggle}
108-
aggregateScopeItem={aggregateScopeItem}
109-
/>
110-
);
111-
})}
75+
{orderedOrgs.map((org) => (
76+
<OrgSection
77+
key={org}
78+
orgName={organizations?.find((o) => o.shortName === org)?.name || org}
79+
scopes={scopesByOrg[org]}
80+
selectedScopes={selectedScopes}
81+
onScopeToggle={onScopeToggle}
82+
aggregateScopeItem={orgAggregateScopeItems[org]}
83+
/>
84+
))}
11285

11386
{orderedOrgs.length === 0 && (
11487
<p className="text-muted text-center py-3">{intl.formatMessage(messages['wizard.step2.scopeList.empty'])}</p>

src/authz-module/role-assignation-wizard/hooks/useScopeListData.test.ts

Lines changed: 1 addition & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -225,8 +225,7 @@ describe('useScopeListData', () => {
225225
org: '',
226226
}), { wrapper });
227227

228-
// Object.keys preserves insertion order from the reduce operation (org2 first, then org1, then org3)
229-
expect(result.current.orderedOrgs).toEqual(['org2', 'org1', 'org3']);
228+
expect(result.current.orderedOrgs).toEqual(['org1', 'org2', 'org3']);
230229
});
231230
});
232231

src/authz-module/role-assignation-wizard/hooks/useScopeListData.ts

Lines changed: 40 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,13 @@ interface UseScopeListDataParams {
1111
org: string;
1212
}
1313

14+
const groupByOrg = (acc: Record<string, Scope[]>, scope: Scope): Record<string, Scope[]> => {
15+
const orgSlug = scope.org!.shortName;
16+
if (!acc[orgSlug]) { acc[orgSlug] = []; }
17+
acc[orgSlug].push(scope);
18+
return acc;
19+
};
20+
1421
const useScopeListData = ({ contextType, search, org }: UseScopeListDataParams) => {
1522
const intl = useIntl();
1623
const {
@@ -36,16 +43,14 @@ const useScopeListData = ({ contextType, search, org }: UseScopeListDataParams)
3643

3744
const totalCount = scopesData?.pages[0]?.count ?? 0;
3845

39-
const scopesByOrg = useMemo(() => allScopes
40-
.filter((s: Scope) => !!s.org)
41-
.reduce<Record<string, Scope[]>>((acc, scope: Scope) => {
42-
const orgSlug = scope.org!.shortName;
43-
if (!acc[orgSlug]) { acc[orgSlug] = []; }
44-
acc[orgSlug].push(scope);
45-
return acc;
46-
}, {}), [allScopes]);
46+
const scopesByOrg = useMemo(
47+
() => allScopes
48+
.filter((s: Scope) => !!s.org)
49+
.reduce<Record<string, Scope[]>>(groupByOrg, {}),
50+
[allScopes],
51+
);
4752

48-
const orderedOrgs = useMemo(() => Object.keys(scopesByOrg), [scopesByOrg]);
53+
const orderedOrgs = useMemo(() => Object.keys(scopesByOrg).sort(), [scopesByOrg]);
4954

5055
const aggregateDescription = contextType === 'course'
5156
? intl.formatMessage(messages['wizard.step2.scope.aggregate.description.course'])
@@ -55,9 +60,16 @@ const useScopeListData = ({ contextType, search, org }: UseScopeListDataParams)
5560
? intl.formatMessage(messages['wizard.step2.scope.aggregate.platform.label.course'])
5661
: intl.formatMessage(messages['wizard.step2.scope.aggregate.platform.label.library']);
5762

58-
// Only show platform aggregate and org aggregates for administrators
59-
const user = getAuthenticatedUser();
60-
const isPlatformAdmin = user?.administrator === true;
63+
const orgAggregateLabel = contextType === 'course'
64+
? intl.formatMessage(messages['wizard.step2.scopeList.aggregate.label.course'])
65+
: intl.formatMessage(messages['wizard.step2.scopeList.aggregate.label.library']);
66+
67+
// Only show platform aggregate and org aggregates for administrators.
68+
// getAuthenticatedUser() is stable for the lifetime of a session (no hot user-switching).
69+
const isPlatformAdmin = useMemo(
70+
() => getAuthenticatedUser()?.administrator === true,
71+
[],
72+
);
6173

6274
const platformAggregateScopeItem: Scope | null = (contextType && isPlatformAdmin)
6375
? {
@@ -68,9 +80,23 @@ const useScopeListData = ({ contextType, search, org }: UseScopeListDataParams)
6880
}
6981
: null;
7082

71-
// Also control whether org aggregates are shown - only for admins
7283
const showOrgAggregates = isPlatformAdmin;
7384

85+
const orgAggregateScopeItems = useMemo<Record<string, Scope>>(() => {
86+
if (!contextType || !showOrgAggregates) { return {}; }
87+
return Object.fromEntries(
88+
orderedOrgs.map((orgSlug) => [
89+
orgSlug,
90+
{
91+
externalKey: contextType === 'course' ? `course-v1:${orgSlug}+*` : `lib:${orgSlug}:*`,
92+
displayName: orgAggregateLabel,
93+
description: aggregateDescription,
94+
org: { id: '0', name: orgSlug, shortName: orgSlug },
95+
} satisfies Scope,
96+
]),
97+
);
98+
}, [orderedOrgs, contextType, showOrgAggregates, orgAggregateLabel, aggregateDescription]);
99+
74100
return {
75101
organizations,
76102
orderedOrgs,
@@ -82,6 +108,7 @@ const useScopeListData = ({ contextType, search, org }: UseScopeListDataParams)
82108
},
83109
platformAggregateScopeItem,
84110
showOrgAggregates,
111+
orgAggregateScopeItems,
85112
};
86113
};
87114

0 commit comments

Comments
 (0)