Skip to content

Commit 2a52e40

Browse files
committed
feat(authz): add role assignation wizard scope selection step
Add scope selection step (step 2) to the role assignation wizard, including ScopeList, ScopeFilterBar, useScopeListData, and useScopePermissions. Migrate useScopes to infinite query, fix useOrgs data extraction, add missing userRoles cache invalidation on role assignment, and align all tests with the updated hook shapes.
1 parent f1371b8 commit 2a52e40

25 files changed

Lines changed: 2516 additions & 158 deletions

src/authz-module/authz-home/index.test.tsx

Lines changed: 10 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -22,6 +22,15 @@ const emptyResponse = {
2222
refetch: jest.fn(),
2323
};
2424

25+
const emptyScopesResponse = {
26+
data: { pages: [] },
27+
error: null,
28+
isLoading: false,
29+
hasNextPage: false,
30+
fetchNextPage: jest.fn(),
31+
isFetchingNextPage: false,
32+
};
33+
2534
const renderAuthzHome = () => renderWithAllProviders(
2635
<ToastManagerProvider>
2736
<AuthzHome />
@@ -32,7 +41,7 @@ describe('AuthzHome', () => {
3241
beforeEach(() => {
3342
(useAllRoleAssignments as jest.Mock).mockReturnValue(emptyResponse);
3443
(useOrgs as jest.Mock).mockReturnValue(emptyResponse);
35-
(useScopes as jest.Mock).mockReturnValue(emptyResponse);
44+
(useScopes as jest.Mock).mockReturnValue(emptyScopesResponse);
3645
});
3746

3847
it('renders without crashing', () => {

src/authz-module/components/TableControlBar/ScopesFilter.test.tsx

Lines changed: 13 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -6,16 +6,20 @@ import ScopesFilter from './ScopesFilter';
66
jest.mock('@src/authz-module/data/hooks', () => ({
77
useScopes: () => ({
88
data: {
9-
results: [
9+
pages: [
1010
{
11-
externalKey: 'course:123',
12-
name: 'Test Course',
13-
organization: { name: 'Test Org' },
14-
},
15-
{
16-
externalKey: 'library:456',
17-
name: 'Test Library',
18-
organization: { name: 'Another Org' },
11+
results: [
12+
{
13+
externalKey: 'course:123',
14+
name: 'Test Course',
15+
organization: { name: 'Test Org' },
16+
},
17+
{
18+
externalKey: 'library:456',
19+
name: 'Test Library',
20+
organization: { name: 'Another Org' },
21+
},
22+
],
1923
},
2024
],
2125
},

src/authz-module/components/TableControlBar/ScopesFilter.tsx

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -15,9 +15,9 @@ const ScopesFilter = ({
1515
}: ScopesFilterProps) => {
1616
const { formatMessage } = useIntl();
1717
const [searchValue, setSearchValue] = useState<string | undefined>(undefined);
18-
const { data: scopesData = { results: [] } } = useScopes(searchValue, 1, DEFAULT_FILTER_PAGE_SIZE);
18+
const { data: scopesData } = useScopes({ search: searchValue, pageSize: DEFAULT_FILTER_PAGE_SIZE });
1919

20-
const filterChoices = useMemo(() => scopesData.results.map((scope) => {
20+
const filterChoices = useMemo(() => (scopesData?.pages?.flatMap((p) => p.results) ?? []).map((scope) => {
2121
const scopeIcon = scope.externalKey?.startsWith('lib') ? RESOURCE_ICONS.LIBRARY : RESOURCE_ICONS.COURSE;
2222
let groupName = formatMessage(messages['authz.team.members.table.group.courses']);
2323
if (scope.externalKey?.startsWith('lib')) {
@@ -30,7 +30,7 @@ const ScopesFilter = ({
3030
groupName,
3131
groupIcon: scopeIcon,
3232
};
33-
}), [scopesData, formatMessage]);
33+
}), [scopesData?.pages, formatMessage]);
3434

3535
const handleSearchChange = (value: string) => {
3636
setSearchValue(value);

src/authz-module/data/api.ts

Lines changed: 17 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -54,13 +54,13 @@ export type PermissionsByRole = {
5454
};
5555
export interface PutAssignTeamMembersRoleResponse {
5656
completed: { userIdentifier: string; status: string }[];
57-
errors: { userIdentifier: string; error: string }[];
57+
errors: { userIdentifier: string; scope: string; error: string }[];
5858
}
5959

6060
export interface AssignTeamMembersRoleRequest {
6161
users: string[];
6262
role: string;
63-
scope: string;
63+
scopes: string[];
6464
}
6565

6666
export interface GetAllRoleAssignmentsResponse {
@@ -97,6 +97,15 @@ export type ValidateUsersResponse = {
9797
};
9898
};
9999

100+
export interface GetScopesParams {
101+
scopeType?: string;
102+
search?: string;
103+
org?: string;
104+
page?: number;
105+
pageSize?: number;
106+
managementPermissionOnly?: boolean;
107+
}
108+
100109
export const getTeamMembers = async (object: string, querySettings: QuerySettings): Promise<GetTeamMembersResponse> => {
101110
const url = new URL(getApiUrl(`/api/authz/v1/roles/users/?scope=${object}`));
102111

@@ -208,17 +217,13 @@ export const getOrgs = async (search?: string, page?: number, pageSize?: number)
208217
return camelCaseObject(data);
209218
};
210219

211-
export const getScopes = async (search?: string, page?: number, pageSize?: number): Promise<GetScopesResponse> => {
220+
export const getScopes = async (params: GetScopesParams): Promise<GetScopesResponse> => {
212221
const url = new URL(getApiUrl('/api/authz/v1/scopes/'));
213-
if (search !== undefined) {
214-
url.searchParams.set('search', search);
215-
}
216-
if (page !== undefined) {
217-
url.searchParams.set('page', page.toString());
218-
}
219-
if (pageSize !== undefined) {
220-
url.searchParams.set('page_size', pageSize.toString());
221-
}
222+
if (params.search) { url.searchParams.set('search', params.search); }
223+
if (params.org) { url.searchParams.set('org', params.org); }
224+
if (params.managementPermissionOnly) { url.searchParams.set('management_permission_only', 'true'); }
225+
url.searchParams.set('page', (params.page ?? 1).toString());
226+
url.searchParams.set('page_size', (params.pageSize ?? 10).toString());
222227
const { data } = await getAuthenticatedHttpClient().get(url);
223228
return camelCaseObject(data);
224229
};

src/authz-module/data/hooks.test.tsx

Lines changed: 129 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -388,7 +388,7 @@ describe('useAssignTeamMembersRole', () => {
388388
});
389389

390390
await act(async () => {
391-
result.current.mutate({ data: { scope: 'lib:123', users: ['jdoe'], role: 'author' } });
391+
result.current.mutate({ data: { scopes: ['lib:123'], users: ['jdoe'], role: 'author' } });
392392
});
393393

394394
await waitFor(() => expect(result.current.isSuccess).toBe(true));
@@ -407,7 +407,7 @@ describe('useAssignTeamMembersRole', () => {
407407
});
408408

409409
await act(async () => {
410-
result.current.mutate({ data: { scope: 'lib:123', users: ['jdoe'], role: 'author' } });
410+
result.current.mutate({ data: { scopes: ['lib:123'], users: ['jdoe'], role: 'author' } });
411411
});
412412

413413
await waitFor(() => expect(result.current.isError).toBe(true));
@@ -465,6 +465,128 @@ describe('useValidateUsers', () => {
465465
});
466466
});
467467

468+
describe('useScopes', () => {
469+
beforeEach(() => {
470+
jest.clearAllMocks();
471+
});
472+
473+
const makeScopesResponse = (next: string | null = null) => ({
474+
results: [{
475+
externalKey: 'lib:testorg:testlib',
476+
displayName: 'Test Library',
477+
org: { id: 1, name: 'Test Org', slug: 'testorg' },
478+
}],
479+
count: 1,
480+
next,
481+
previous: null,
482+
});
483+
484+
it('returns pages data on success', async () => {
485+
getAuthenticatedHttpClient.mockReturnValue({
486+
get: jest.fn().mockResolvedValue({ data: makeScopesResponse() }),
487+
});
488+
489+
const { result } = renderHook(() => useScopes({}), { wrapper: createWrapper() });
490+
491+
await waitFor(() => expect(result.current.isSuccess).toBe(true));
492+
493+
expect(result.current.data?.pages).toHaveLength(1);
494+
expect(result.current.data?.pages[0].results).toHaveLength(1);
495+
});
496+
497+
it('hasNextPage is false when next is null', async () => {
498+
getAuthenticatedHttpClient.mockReturnValue({
499+
get: jest.fn().mockResolvedValue({ data: makeScopesResponse(null) }),
500+
});
501+
502+
const { result } = renderHook(() => useScopes({}), { wrapper: createWrapper() });
503+
504+
await waitFor(() => expect(result.current.isSuccess).toBe(true));
505+
expect(result.current.hasNextPage).toBe(false);
506+
});
507+
508+
it('hasNextPage is true when next URL has page param', async () => {
509+
getAuthenticatedHttpClient.mockReturnValue({
510+
get: jest.fn().mockResolvedValue({
511+
data: makeScopesResponse('http://localhost:8000/api/authz/v1/scopes/?page=2'),
512+
}),
513+
});
514+
515+
const { result } = renderHook(() => useScopes({}), { wrapper: createWrapper() });
516+
517+
await waitFor(() => expect(result.current.isSuccess).toBe(true));
518+
expect(result.current.hasNextPage).toBe(true);
519+
});
520+
521+
it('hasNextPage is false when next URL has no page param', async () => {
522+
getAuthenticatedHttpClient.mockReturnValue({
523+
get: jest.fn().mockResolvedValue({
524+
data: makeScopesResponse('http://localhost:8000/api/authz/v1/scopes/'),
525+
}),
526+
});
527+
528+
const { result } = renderHook(() => useScopes({}), { wrapper: createWrapper() });
529+
530+
await waitFor(() => expect(result.current.isSuccess).toBe(true));
531+
expect(result.current.hasNextPage).toBe(false);
532+
});
533+
534+
it('hasNextPage is false when next is an invalid URL', async () => {
535+
getAuthenticatedHttpClient.mockReturnValue({
536+
get: jest.fn().mockResolvedValue({
537+
data: makeScopesResponse('not-a-valid-url'),
538+
}),
539+
});
540+
541+
const { result } = renderHook(() => useScopes({}), { wrapper: createWrapper() });
542+
543+
await waitFor(() => expect(result.current.isSuccess).toBe(true));
544+
expect(result.current.hasNextPage).toBe(false);
545+
});
546+
547+
it('handles error when API call fails', async () => {
548+
getAuthenticatedHttpClient.mockReturnValue({
549+
get: jest.fn().mockRejectedValue(new Error('Network error')),
550+
});
551+
552+
const { result } = renderHook(() => useScopes({}), { wrapper: createWrapper() });
553+
554+
await waitFor(() => expect(result.current.isError).toBe(true));
555+
expect(result.current.error).toBeDefined();
556+
});
557+
});
558+
559+
describe('useOrgs', () => {
560+
beforeEach(() => {
561+
jest.clearAllMocks();
562+
});
563+
564+
const mockOrgs = [{
565+
id: 1, name: 'Org One', shortName: 'org1', description: '', logo: null, active: true,
566+
}];
567+
568+
it('returns organizations on success', async () => {
569+
getAuthenticatedHttpClient.mockReturnValue({
570+
get: jest.fn().mockResolvedValue({ data: { results: mockOrgs } }),
571+
});
572+
573+
const { result } = renderHook(() => useOrgs(), { wrapper: createWrapper() });
574+
575+
await waitFor(() => expect(result.current.isSuccess).toBe(true));
576+
expect(result.current.data?.results).toEqual(mockOrgs);
577+
});
578+
579+
it('handles error when API fails', async () => {
580+
getAuthenticatedHttpClient.mockReturnValue({
581+
get: jest.fn().mockRejectedValue(new Error('Failed')),
582+
});
583+
584+
const { result } = renderHook(() => useOrgs(), { wrapper: createWrapper() });
585+
586+
await waitFor(() => expect(result.current.isError).toBe(true));
587+
});
588+
});
589+
468590
describe('useRevokeUserRoles', () => {
469591
beforeEach(() => {
470592
jest.clearAllMocks();
@@ -663,9 +785,9 @@ describe('useScopes', () => {
663785
it('fetches and returns scopes', async () => {
664786
const { result } = renderHook(() => useScopes(), { wrapper: createWrapper() });
665787
await waitFor(() => {
666-
expect(result.current.data?.results).toHaveLength(2);
667-
expect(result.current.data?.results[0].displayName).toBe('Open edX Demo Course');
668-
expect(result.current.data?.count).toBe(2);
788+
expect(result.current.data?.pages[0].results).toHaveLength(2);
789+
expect(result.current.data?.pages[0].results[0].displayName).toBe('Open edX Demo Course');
790+
expect(result.current.data?.pages[0].count).toBe(2);
669791
});
670792
});
671793

@@ -679,8 +801,8 @@ describe('useScopes', () => {
679801
});
680802
const { result } = renderHook(() => useScopes(), { wrapper: createWrapper() });
681803
await waitFor(() => {
682-
expect(result.current.data?.results).toEqual([]);
683-
expect(result.current.data?.count).toBe(0);
804+
expect(result.current.data?.pages[0].results).toEqual([]);
805+
expect(result.current.data?.pages[0].count).toBe(0);
684806
});
685807
});
686808
});

0 commit comments

Comments
 (0)