Skip to content

Commit 39d69ab

Browse files
committed
feat: implement Roles Assigment Wizard step 2
- Added useScopeListData hook to manage scope data retrieval and organization. - Introduced useScopePermissions hook to validate user permissions for scopes. - Created unit tests for useScopeListData covering various scenarios including loading, error states, and data structure validation. - Developed unit tests for useScopePermissions to ensure correct permission handling for courses and libraries. - Updated messages for consistency in the UI.
1 parent e04f840 commit 39d69ab

20 files changed

Lines changed: 2524 additions & 108 deletions

src/authz-module/data/api.ts

Lines changed: 33 additions & 62 deletions
Original file line numberDiff line numberDiff line change
@@ -1,68 +1,21 @@
11
import { getAuthenticatedHttpClient } from '@edx/frontend-platform/auth';
2-
import { LibraryMetadata, TeamMember } from '@src/types';
2+
import { LibraryMetadata } from '@src/types';
33
import { camelCaseObject } from '@edx/frontend-platform';
44
import { getApiUrl, getStudioApiUrl } from '@src/data/utils';
5-
6-
export interface QuerySettings {
7-
roles: string | null;
8-
search: string | null;
9-
order: string | null;
10-
sortBy: string | null;
11-
pageSize: number;
12-
pageIndex: number;
13-
}
14-
15-
export interface GetTeamMembersResponse {
16-
results: TeamMember[];
17-
count: number;
18-
}
19-
20-
export type RevokeUserRolesRequest = {
21-
users: string;
22-
role: string;
23-
scope: string;
24-
};
25-
26-
export interface DeleteRevokeUserRolesResponse {
27-
completed: {
28-
userIdentifiers: string;
29-
status: string;
30-
}[],
31-
errors: {
32-
userIdentifiers: string;
33-
error: string;
34-
}[],
35-
}
36-
37-
export type PermissionsByRole = {
38-
role: string;
39-
permissions: string[];
40-
userCount: number;
41-
};
42-
export interface PutAssignTeamMembersRoleResponse {
43-
completed: { userIdentifier: string; status: string }[];
44-
errors: { userIdentifier: string; error: string }[];
45-
}
46-
47-
export interface AssignTeamMembersRoleRequest {
48-
users: string[];
49-
role: string;
50-
scope: string;
51-
}
52-
53-
export type ValidateUsersRequest = {
54-
users: string[];
55-
};
56-
57-
export type ValidateUsersResponse = {
58-
validUsers: string[];
59-
invalidUsers: string[];
60-
summary: {
61-
total: number;
62-
validCount: number;
63-
invalidCount: number;
64-
};
65-
};
5+
import {
6+
QuerySettings,
7+
GetTeamMembersResponse,
8+
AssignTeamMembersRoleRequest,
9+
PutAssignTeamMembersRoleResponse,
10+
ValidateUsersRequest,
11+
ValidateUsersResponse,
12+
PermissionsByRole,
13+
GetScopesParams,
14+
GetScopesResponse,
15+
OrganizationItem,
16+
RevokeUserRolesRequest,
17+
DeleteRevokeUserRolesResponse,
18+
} from './types';
6619

6720
export const getTeamMembers = async (object: string, querySettings: QuerySettings): Promise<GetTeamMembersResponse> => {
6821
const url = new URL(getApiUrl(`/api/authz/v1/roles/users/?scope=${object}`));
@@ -120,6 +73,24 @@ export const getPermissionsByRole = async (scope: string): Promise<PermissionsBy
12073
return camelCaseObject(data.results);
12174
};
12275

76+
export const getScopes = async (params: GetScopesParams): Promise<GetScopesResponse> => {
77+
const url = new URL(getApiUrl('/api/authz/v1/scopes/'));
78+
if (params.scopeType) { url.searchParams.set('scope_type', params.scopeType); }
79+
if (params.search) { url.searchParams.set('search', params.search); }
80+
if (params.org) { url.searchParams.set('org', params.org); }
81+
if (params.managementPermissionOnly) { url.searchParams.set('management_permission_only', 'true'); }
82+
url.searchParams.set('page', (params.page ?? 1).toString());
83+
url.searchParams.set('page_size', (params.pageSize ?? 10).toString());
84+
const { data } = await getAuthenticatedHttpClient().get(url);
85+
return camelCaseObject(data);
86+
};
87+
88+
export const getOrganizations = async (): Promise<OrganizationItem[]> => {
89+
const url = new URL(getApiUrl('/api/authz/v1/orgs/'));
90+
const { data } = await getAuthenticatedHttpClient().get(url);
91+
return camelCaseObject(data.results ?? data);
92+
};
93+
12394
export const revokeUserRoles = async (
12495
data: RevokeUserRolesRequest,
12596
): Promise<DeleteRevokeUserRolesResponse> => {

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

Lines changed: 123 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -4,7 +4,7 @@ import { QueryClient, QueryClientProvider } from '@tanstack/react-query';
44
import { getAuthenticatedHttpClient } from '@edx/frontend-platform/auth';
55
import {
66
useLibrary, usePermissionsByRole, useTeamMembers, useAssignTeamMembersRole, useRevokeUserRoles,
7-
useValidateUsers,
7+
useValidateUsers, useScopes, useOrganizations,
88
} from './hooks';
99

1010
jest.mock('@edx/frontend-platform/auth', () => ({
@@ -346,6 +346,128 @@ describe('useValidateUsers', () => {
346346
});
347347
});
348348

349+
describe('useScopes', () => {
350+
beforeEach(() => {
351+
jest.clearAllMocks();
352+
});
353+
354+
const makeScopesResponse = (next: string | null = null) => ({
355+
results: [{
356+
externalKey: 'lib:testorg:testlib',
357+
displayName: 'Test Library',
358+
org: { id: 1, name: 'Test Org', slug: 'testorg' },
359+
}],
360+
count: 1,
361+
next,
362+
previous: null,
363+
});
364+
365+
it('returns pages data on success', async () => {
366+
getAuthenticatedHttpClient.mockReturnValue({
367+
get: jest.fn().mockResolvedValue({ data: makeScopesResponse() }),
368+
});
369+
370+
const { result } = renderHook(() => useScopes({}), { wrapper: createWrapper() });
371+
372+
await waitFor(() => expect(result.current.isSuccess).toBe(true));
373+
374+
expect(result.current.data?.pages).toHaveLength(1);
375+
expect(result.current.data?.pages[0].results).toHaveLength(1);
376+
});
377+
378+
it('hasNextPage is false when next is null', async () => {
379+
getAuthenticatedHttpClient.mockReturnValue({
380+
get: jest.fn().mockResolvedValue({ data: makeScopesResponse(null) }),
381+
});
382+
383+
const { result } = renderHook(() => useScopes({}), { wrapper: createWrapper() });
384+
385+
await waitFor(() => expect(result.current.isSuccess).toBe(true));
386+
expect(result.current.hasNextPage).toBe(false);
387+
});
388+
389+
it('hasNextPage is true when next URL has page param', async () => {
390+
getAuthenticatedHttpClient.mockReturnValue({
391+
get: jest.fn().mockResolvedValue({
392+
data: makeScopesResponse('http://localhost:8000/api/authz/v1/scopes/?page=2'),
393+
}),
394+
});
395+
396+
const { result } = renderHook(() => useScopes({}), { wrapper: createWrapper() });
397+
398+
await waitFor(() => expect(result.current.isSuccess).toBe(true));
399+
expect(result.current.hasNextPage).toBe(true);
400+
});
401+
402+
it('hasNextPage is false when next URL has no page param', async () => {
403+
getAuthenticatedHttpClient.mockReturnValue({
404+
get: jest.fn().mockResolvedValue({
405+
data: makeScopesResponse('http://localhost:8000/api/authz/v1/scopes/'),
406+
}),
407+
});
408+
409+
const { result } = renderHook(() => useScopes({}), { wrapper: createWrapper() });
410+
411+
await waitFor(() => expect(result.current.isSuccess).toBe(true));
412+
expect(result.current.hasNextPage).toBe(false);
413+
});
414+
415+
it('hasNextPage is false when next is an invalid URL', async () => {
416+
getAuthenticatedHttpClient.mockReturnValue({
417+
get: jest.fn().mockResolvedValue({
418+
data: makeScopesResponse('not-a-valid-url'),
419+
}),
420+
});
421+
422+
const { result } = renderHook(() => useScopes({}), { wrapper: createWrapper() });
423+
424+
await waitFor(() => expect(result.current.isSuccess).toBe(true));
425+
expect(result.current.hasNextPage).toBe(false);
426+
});
427+
428+
it('handles error when API call fails', async () => {
429+
getAuthenticatedHttpClient.mockReturnValue({
430+
get: jest.fn().mockRejectedValue(new Error('Network error')),
431+
});
432+
433+
const { result } = renderHook(() => useScopes({}), { wrapper: createWrapper() });
434+
435+
await waitFor(() => expect(result.current.isError).toBe(true));
436+
expect(result.current.error).toBeDefined();
437+
});
438+
});
439+
440+
describe('useOrganizations', () => {
441+
beforeEach(() => {
442+
jest.clearAllMocks();
443+
});
444+
445+
const mockOrgs = [{
446+
id: 1, name: 'Org One', shortName: 'org1', description: '', logo: null, active: true,
447+
}];
448+
449+
it('returns organizations on success', async () => {
450+
getAuthenticatedHttpClient.mockReturnValue({
451+
get: jest.fn().mockResolvedValue({ data: { results: mockOrgs } }),
452+
});
453+
454+
const { result } = renderHook(() => useOrganizations(), { wrapper: createWrapper() });
455+
456+
await waitFor(() => expect(result.current.isSuccess).toBe(true));
457+
expect(result.current.data).toEqual(mockOrgs);
458+
});
459+
460+
it('handles error when API fails', async () => {
461+
getAuthenticatedHttpClient.mockReturnValue({
462+
get: jest.fn().mockRejectedValue(new Error('Failed')),
463+
});
464+
465+
const { result } = renderHook(() => useOrganizations(), { wrapper: createWrapper() });
466+
467+
await waitFor(() => expect(result.current.isError).toBe(true));
468+
});
469+
});
470+
349471
describe('useRevokeUserRoles', () => {
350472
beforeEach(() => {
351473
jest.clearAllMocks();

src/authz-module/data/hooks.ts

Lines changed: 44 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -1,13 +1,18 @@
11
import {
2-
useMutation, useQuery, useQueryClient, useSuspenseQuery,
2+
useInfiniteQuery, useMutation, useQuery, useQueryClient, useSuspenseQuery,
33
} from '@tanstack/react-query';
44
import { appId } from '@src/constants';
55
import { LibraryMetadata } from '@src/types';
66
import {
7-
assignTeamMembersRole, AssignTeamMembersRoleRequest, getLibrary, getPermissionsByRole, getTeamMembers,
8-
GetTeamMembersResponse, PermissionsByRole, QuerySettings, revokeUserRoles, RevokeUserRolesRequest,
9-
validateUsers, ValidateUsersRequest,
7+
assignTeamMembersRole, getLibrary, getOrganizations,
8+
getPermissionsByRole, getScopes, getTeamMembers,
9+
revokeUserRoles, validateUsers,
1010
} from './api';
11+
import {
12+
AssignTeamMembersRoleRequest, GetScopesParams, GetScopesResponse,
13+
GetTeamMembersResponse, OrganizationItem, PermissionsByRole, QuerySettings,
14+
RevokeUserRolesRequest, ValidateUsersRequest,
15+
} from './types';
1116

1217
const authzQueryKeys = {
1318
all: [appId, 'authz'] as const,
@@ -106,6 +111,41 @@ export const useValidateUsers = () => useMutation({
106111
}) => validateUsers(data),
107112
});
108113

114+
/**
115+
* React Query hook to fetch a paginated, searchable list of scopes (courses or libraries).
116+
* Uses infinite query to support infinite scroll.
117+
*
118+
* @param params - Filter params: contextType, search, org, pageSize
119+
*/
120+
export const useScopes = (params: Omit<GetScopesParams, 'page'>) => useInfiniteQuery<GetScopesResponse, Error>({
121+
queryKey: [...authzQueryKeys.all, 'scopes', params],
122+
queryFn: ({ pageParam }) => getScopes({ ...params, page: pageParam as number }),
123+
getNextPageParam: (lastPage) => {
124+
if (!lastPage.next) { return undefined; }
125+
try {
126+
const nextUrl = new URL(lastPage.next);
127+
const page = nextUrl.searchParams.get('page');
128+
return page ? parseInt(page, 10) : undefined;
129+
} catch {
130+
return undefined;
131+
}
132+
},
133+
initialPageParam: 1,
134+
staleTime: 1000 * 60 * 5,
135+
});
136+
137+
/**
138+
* React Query hook to fetch the list of organizations for a given context type.
139+
* Used to populate the Organization filter dropdown in the scope selector.
140+
*
141+
* @param contextType - 'course' | 'library'
142+
*/
143+
export const useOrganizations = () => useQuery<OrganizationItem[], Error>({
144+
queryKey: [...authzQueryKeys.all, 'organizations'],
145+
queryFn: () => getOrganizations(),
146+
staleTime: 1000 * 60 * 30,
147+
});
148+
109149
/**
110150
* React Query hook to remove roles for a specific team member within a scope.
111151
*

0 commit comments

Comments
 (0)