Skip to content

Commit 560cf25

Browse files
committed
feat: Implement DefineApplicationScopeStep with filtering and organization scope management
1 parent f11afbd commit 560cf25

17 files changed

Lines changed: 2246 additions & 51 deletions

src/authz-module/data/api.test.ts

Lines changed: 113 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,8 @@ import {
66
getLibrary,
77
getPermissionsByRole,
88
revokeUserRoles,
9+
getScopes,
10+
getOrganizations,
911
} from './api';
1012

1113
jest.mock('@edx/frontend-platform/auth', () => ({
@@ -171,3 +173,114 @@ describe('revokeUserRoles', () => {
171173
expect(result).toEqual(mockResponse);
172174
});
173175
});
176+
177+
describe('getScopes', () => {
178+
const mockScopesData = {
179+
results: [{
180+
externalKey: 'lib:testorg:testlib',
181+
displayName: 'Test Library',
182+
org: { id: 1, name: 'Test Org', shortName: 'testorg' },
183+
}],
184+
count: 1,
185+
next: null,
186+
previous: null,
187+
};
188+
189+
it('builds URL with default page and pageSize when no optional params', async () => {
190+
mockGet.mockResolvedValue({ data: mockScopesData });
191+
192+
const result = await getScopes({});
193+
194+
const calledUrl = new URL(mockGet.mock.calls[0][0]);
195+
expect(calledUrl.searchParams.get('page')).toBe('1');
196+
expect(calledUrl.searchParams.get('page_size')).toBe('10');
197+
expect(calledUrl.searchParams.get('search')).toBeNull();
198+
expect(calledUrl.searchParams.get('org')).toBeNull();
199+
expect(calledUrl.searchParams.get('management_permission_only')).toBeNull();
200+
expect(result).toEqual(mockScopesData);
201+
});
202+
203+
it('appends search param when provided', async () => {
204+
mockGet.mockResolvedValue({ data: mockScopesData });
205+
206+
await getScopes({ search: 'mylib' });
207+
208+
const calledUrl = new URL(mockGet.mock.calls[0][0]);
209+
expect(calledUrl.searchParams.get('search')).toBe('mylib');
210+
});
211+
212+
it('appends org param when provided', async () => {
213+
mockGet.mockResolvedValue({ data: mockScopesData });
214+
215+
await getScopes({ org: 'testorg' });
216+
217+
const calledUrl = new URL(mockGet.mock.calls[0][0]);
218+
expect(calledUrl.searchParams.get('org')).toBe('testorg');
219+
});
220+
221+
it('appends management_permission_only when set to true', async () => {
222+
mockGet.mockResolvedValue({ data: mockScopesData });
223+
224+
await getScopes({ managementPermissionOnly: true });
225+
226+
const calledUrl = new URL(mockGet.mock.calls[0][0]);
227+
expect(calledUrl.searchParams.get('management_permission_only')).toBe('true');
228+
});
229+
230+
it('uses provided page and pageSize', async () => {
231+
mockGet.mockResolvedValue({ data: mockScopesData });
232+
233+
await getScopes({ page: 3, pageSize: 25 });
234+
235+
const calledUrl = new URL(mockGet.mock.calls[0][0]);
236+
expect(calledUrl.searchParams.get('page')).toBe('3');
237+
expect(calledUrl.searchParams.get('page_size')).toBe('25');
238+
});
239+
240+
it('does not append managementPermissionOnly when false', async () => {
241+
mockGet.mockResolvedValue({ data: mockScopesData });
242+
243+
await getScopes({ managementPermissionOnly: false });
244+
245+
const calledUrl = new URL(mockGet.mock.calls[0][0]);
246+
expect(calledUrl.searchParams.get('management_permission_only')).toBeNull();
247+
});
248+
});
249+
250+
describe('getOrganizations', () => {
251+
const mockOrg = {
252+
id: 1,
253+
name: 'Org One',
254+
shortName: 'org1',
255+
description: '',
256+
logo: null,
257+
active: true,
258+
};
259+
260+
it('fetches from /api/authz/v1/orgs/ and returns results', async () => {
261+
mockGet.mockResolvedValue({ data: { results: [mockOrg] } });
262+
263+
const result = await getOrganizations();
264+
265+
const calledUrl = new URL(mockGet.mock.calls[0][0]);
266+
expect(calledUrl.pathname).toBe('/api/authz/v1/orgs/');
267+
expect(result).toEqual([mockOrg]);
268+
});
269+
270+
it('falls back to data directly when results is not present', async () => {
271+
mockGet.mockResolvedValue({ data: [mockOrg] });
272+
273+
const result = await getOrganizations();
274+
275+
expect(result).toEqual([mockOrg]);
276+
});
277+
278+
it('returns empty array when results is empty', async () => {
279+
mockGet.mockResolvedValue({ data: { results: [] } });
280+
281+
const result = await getOrganizations();
282+
283+
expect(result).toEqual([]);
284+
expect(mockGet).toHaveBeenCalled();
285+
});
286+
});

src/authz-module/data/api.ts

Lines changed: 57 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -120,6 +120,63 @@ export const getPermissionsByRole = async (scope: string): Promise<PermissionsBy
120120
return camelCaseObject(data.results);
121121
};
122122

123+
export interface ScopeItem {
124+
externalKey: string;
125+
displayName: string;
126+
org: { id: number; name: string; shortName: string } | null;
127+
description?: string; // synthetic field used only for aggregate items created on the frontend
128+
}
129+
130+
export interface GetScopesResponse {
131+
results: ScopeItem[];
132+
count: number;
133+
next: string | null;
134+
previous: string | null;
135+
}
136+
137+
export interface GetScopesParams {
138+
scopeType?: string;
139+
search?: string;
140+
org?: string;
141+
page?: number;
142+
pageSize?: number;
143+
managementPermissionOnly?: boolean;
144+
}
145+
146+
export interface OrganizationItem {
147+
id: number;
148+
name: string;
149+
shortName: string;
150+
description: string;
151+
logo: string | null;
152+
active: boolean;
153+
}
154+
155+
export interface GetOrganizationsResponse {
156+
results: OrganizationItem[];
157+
count: number;
158+
next: string | null;
159+
previous: string | null;
160+
}
161+
162+
export const getScopes = async (params: GetScopesParams): Promise<GetScopesResponse> => {
163+
const url = new URL(getApiUrl('/api/authz/v1/scopes/'));
164+
if (params.scopeType) { url.searchParams.set('scope_type', params.scopeType); }
165+
if (params.search) { url.searchParams.set('search', params.search); }
166+
if (params.org) { url.searchParams.set('org', params.org); }
167+
if (params.managementPermissionOnly) { url.searchParams.set('management_permission_only', 'true'); }
168+
url.searchParams.set('page', (params.page ?? 1).toString());
169+
url.searchParams.set('page_size', (params.pageSize ?? 10).toString());
170+
const { data } = await getAuthenticatedHttpClient().get(url);
171+
return camelCaseObject(data);
172+
};
173+
174+
export const getOrganizations = async (): Promise<OrganizationItem[]> => {
175+
const url = new URL(getApiUrl('/api/authz/v1/orgs/'));
176+
const { data } = await getAuthenticatedHttpClient().get(url);
177+
return camelCaseObject(data.results ?? data);
178+
};
179+
123180
export const revokeUserRoles = async (
124181
data: RevokeUserRolesRequest,
125182
): 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', () => ({
@@ -290,6 +290,128 @@ describe('useValidateUsers', () => {
290290
});
291291
});
292292

293+
describe('useScopes', () => {
294+
beforeEach(() => {
295+
jest.clearAllMocks();
296+
});
297+
298+
const makeScopesResponse = (next: string | null = null) => ({
299+
results: [{
300+
externalKey: 'lib:testorg:testlib',
301+
displayName: 'Test Library',
302+
org: { id: 1, name: 'Test Org', slug: 'testorg' },
303+
}],
304+
count: 1,
305+
next,
306+
previous: null,
307+
});
308+
309+
it('returns pages data on success', async () => {
310+
getAuthenticatedHttpClient.mockReturnValue({
311+
get: jest.fn().mockResolvedValue({ data: makeScopesResponse() }),
312+
});
313+
314+
const { result } = renderHook(() => useScopes({}), { wrapper: createWrapper() });
315+
316+
await waitFor(() => expect(result.current.isSuccess).toBe(true));
317+
318+
expect(result.current.data?.pages).toHaveLength(1);
319+
expect(result.current.data?.pages[0].results).toHaveLength(1);
320+
});
321+
322+
it('hasNextPage is false when next is null', async () => {
323+
getAuthenticatedHttpClient.mockReturnValue({
324+
get: jest.fn().mockResolvedValue({ data: makeScopesResponse(null) }),
325+
});
326+
327+
const { result } = renderHook(() => useScopes({}), { wrapper: createWrapper() });
328+
329+
await waitFor(() => expect(result.current.isSuccess).toBe(true));
330+
expect(result.current.hasNextPage).toBe(false);
331+
});
332+
333+
it('hasNextPage is true when next URL has page param', async () => {
334+
getAuthenticatedHttpClient.mockReturnValue({
335+
get: jest.fn().mockResolvedValue({
336+
data: makeScopesResponse('http://localhost:8000/api/authz/v1/scopes/?page=2'),
337+
}),
338+
});
339+
340+
const { result } = renderHook(() => useScopes({}), { wrapper: createWrapper() });
341+
342+
await waitFor(() => expect(result.current.isSuccess).toBe(true));
343+
expect(result.current.hasNextPage).toBe(true);
344+
});
345+
346+
it('hasNextPage is false when next URL has no page param', async () => {
347+
getAuthenticatedHttpClient.mockReturnValue({
348+
get: jest.fn().mockResolvedValue({
349+
data: makeScopesResponse('http://localhost:8000/api/authz/v1/scopes/'),
350+
}),
351+
});
352+
353+
const { result } = renderHook(() => useScopes({}), { wrapper: createWrapper() });
354+
355+
await waitFor(() => expect(result.current.isSuccess).toBe(true));
356+
expect(result.current.hasNextPage).toBe(false);
357+
});
358+
359+
it('hasNextPage is false when next is an invalid URL', async () => {
360+
getAuthenticatedHttpClient.mockReturnValue({
361+
get: jest.fn().mockResolvedValue({
362+
data: makeScopesResponse('not-a-valid-url'),
363+
}),
364+
});
365+
366+
const { result } = renderHook(() => useScopes({}), { wrapper: createWrapper() });
367+
368+
await waitFor(() => expect(result.current.isSuccess).toBe(true));
369+
expect(result.current.hasNextPage).toBe(false);
370+
});
371+
372+
it('handles error when API call fails', async () => {
373+
getAuthenticatedHttpClient.mockReturnValue({
374+
get: jest.fn().mockRejectedValue(new Error('Network error')),
375+
});
376+
377+
const { result } = renderHook(() => useScopes({}), { wrapper: createWrapper() });
378+
379+
await waitFor(() => expect(result.current.isError).toBe(true));
380+
expect(result.current.error).toBeDefined();
381+
});
382+
});
383+
384+
describe('useOrganizations', () => {
385+
beforeEach(() => {
386+
jest.clearAllMocks();
387+
});
388+
389+
const mockOrgs = [{
390+
id: 1, name: 'Org One', shortName: 'org1', description: '', logo: null, active: true,
391+
}];
392+
393+
it('returns organizations on success', async () => {
394+
getAuthenticatedHttpClient.mockReturnValue({
395+
get: jest.fn().mockResolvedValue({ data: { results: mockOrgs } }),
396+
});
397+
398+
const { result } = renderHook(() => useOrganizations(), { wrapper: createWrapper() });
399+
400+
await waitFor(() => expect(result.current.isSuccess).toBe(true));
401+
expect(result.current.data).toEqual(mockOrgs);
402+
});
403+
404+
it('handles error when API fails', async () => {
405+
getAuthenticatedHttpClient.mockReturnValue({
406+
get: jest.fn().mockRejectedValue(new Error('Failed')),
407+
});
408+
409+
const { result } = renderHook(() => useOrganizations(), { wrapper: createWrapper() });
410+
411+
await waitFor(() => expect(result.current.isError).toBe(true));
412+
});
413+
});
414+
293415
describe('useRevokeUserRoles', () => {
294416
beforeEach(() => {
295417
jest.clearAllMocks();

src/authz-module/data/hooks.ts

Lines changed: 40 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -1,12 +1,13 @@
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, AssignTeamMembersRoleRequest, getLibrary, getOrganizations,
8+
getPermissionsByRole, getScopes, GetScopesParams, GetScopesResponse, getTeamMembers,
9+
GetTeamMembersResponse, OrganizationItem, PermissionsByRole, QuerySettings, revokeUserRoles,
10+
RevokeUserRolesRequest, validateUsers, ValidateUsersRequest,
1011
} from './api';
1112

1213
const authzQueryKeys = {
@@ -106,6 +107,41 @@ export const useValidateUsers = () => useMutation({
106107
}) => validateUsers(data),
107108
});
108109

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

0 commit comments

Comments
 (0)