Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion package-lock.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

1 change: 1 addition & 0 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -43,6 +43,7 @@
"@openedx/frontend-plugin-framework": "^1.7.0",
"@openedx/paragon": "^23.4.5",
"@tanstack/react-query": "5.89.0",
"lodash.debounce": "^4.0.8",
"react": "^18.3.1",
"react-dom": "^18.3.1",
"react-router-dom": "^6.0.0"
Expand Down
8 changes: 5 additions & 3 deletions src/authz-module/components/AuthZTitle.test.tsx
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
import { ReactNode } from 'react';
import { render, screen, fireEvent } from '@testing-library/react';
import { render, screen } from '@testing-library/react';
import userEvent from '@testing-library/user-event';
import AuthZTitle, { AuthZTitleProps } from './AuthZTitle';

jest.mock('react-router-dom', () => ({
Expand Down Expand Up @@ -58,10 +59,11 @@ describe('AuthZTitle', () => {

render(<AuthZTitle {...defaultProps} actions={actions} />);

actions.forEach(({ label, onClick }) => {
actions.forEach(async ({ label, onClick }) => {
const user = userEvent.setup();
const button = screen.getByRole('button', { name: label });
expect(button).toBeInTheDocument();
fireEvent.click(button);
await user.click(button);
expect(onClick).toHaveBeenCalled();
});
});
Expand Down
34 changes: 29 additions & 5 deletions src/authz-module/data/api.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,9 +3,18 @@ import { LibraryMetadata, TeamMember } from '@src/types';
import { camelCaseObject } from '@edx/frontend-platform';
import { getApiUrl, getStudioApiUrl } from '@src/data/utils';

export interface QuerySettings {
roles: string | null;
search: string | null;
order: string | null;
sortBy: string | null;
pageSize: number;
pageIndex: number;
}

export interface GetTeamMembersResponse {
members: TeamMember[];
totalCount: number;
results: TeamMember[];
count: number;
}

export type PermissionsByRole = {
Expand All @@ -24,9 +33,24 @@ export interface AssignTeamMembersRoleRequest {
scope: string;
}

export const getTeamMembers = async (object: string): Promise<TeamMember[]> => {
const { data } = await getAuthenticatedHttpClient().get(getApiUrl(`/api/authz/v1/roles/users/?scope=${object}`));
return camelCaseObject(data.results);
export const getTeamMembers = async (object: string, querySettings: QuerySettings): Promise<GetTeamMembersResponse> => {
const url = new URL(getApiUrl(`/api/authz/v1/roles/users/?scope=${object}`));

if (querySettings.roles) {
url.searchParams.set('roles', querySettings.roles);
}
if (querySettings.search) {
url.searchParams.set('search', querySettings.search);
}
if (querySettings.sortBy && querySettings.order) {
url.searchParams.set('sort_by', querySettings.sortBy);
url.searchParams.set('order', querySettings.order);
}
Comment on lines +45 to +48

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Would there be a possible scenario where we'd have sortBy but not order? I know for the current sort options (Name A-Z, Name Z-A) we'll always have both so this isn't a blocking comment.

url.searchParams.set('page_size', querySettings.pageSize.toString());
url.searchParams.set('page', (querySettings.pageIndex + 1).toString());

const { data } = await getAuthenticatedHttpClient().get(url);
return camelCaseObject(data);
};

export const assignTeamMembersRole = async (
Expand Down
46 changes: 29 additions & 17 deletions src/authz-module/data/hooks.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -10,20 +10,23 @@ jest.mock('@edx/frontend-platform/auth', () => ({
getAuthenticatedHttpClient: jest.fn(),
}));

const mockMembers = [
{
fullName: 'Alice',
username: 'user1',
email: 'alice@example.com',
roles: ['admin', 'author'],
},
{
fullName: 'Bob',
username: 'user2',
email: 'bob@example.com',
roles: ['contributor'],
},
];
const mockMembers = {
count: 2,
results: [
{
fullName: 'Alice',
username: 'user1',
email: 'alice@example.com',
roles: ['admin', 'author'],
},
{
fullName: 'Bob',
username: 'user2',
email: 'bob@example.com',
roles: ['collaborator'],
},
],
};

const mockLibrary = {
id: 'lib:123',
Expand All @@ -32,6 +35,15 @@ const mockLibrary = {
slug: 'test-library',
};

const mockQuerySettings = {
roles: null,
search: null,
order: null,
sortBy: null,
pageSize: 10,
pageIndex: 0,
};

const createWrapper = () => {
const queryClient = new QueryClient({
defaultOptions: {
Expand All @@ -58,10 +70,10 @@ describe('useTeamMembers', () => {

it('returns data when API call succeeds', async () => {
getAuthenticatedHttpClient.mockReturnValue({
get: jest.fn().mockResolvedValue({ data: { results: mockMembers } }),
get: jest.fn().mockResolvedValue({ data: mockMembers }),
});

const { result } = renderHook(() => useTeamMembers('lib:123'), {
const { result } = renderHook(() => useTeamMembers('lib:123', mockQuerySettings), {
wrapper: createWrapper(),
});

Expand All @@ -76,7 +88,7 @@ describe('useTeamMembers', () => {
get: jest.fn().mockRejectedValue(new Error('API failure')),
});

const { result } = renderHook(() => useTeamMembers('lib:123'), {
const { result } = renderHook(() => useTeamMembers('lib:123', mockQuerySettings), {
wrapper: createWrapper(),
});

Expand Down
25 changes: 14 additions & 11 deletions src/authz-module/data/hooks.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,16 +2,17 @@ import {
useMutation, useQuery, useQueryClient, useSuspenseQuery,
} from '@tanstack/react-query';
import { appId } from '@src/constants';
import { LibraryMetadata, TeamMember } from '@src/types';
import { LibraryMetadata } from '@src/types';
import {
assignTeamMembersRole,
AssignTeamMembersRoleRequest,
getLibrary, getPermissionsByRole, getTeamMembers, PermissionsByRole,
assignTeamMembersRole, AssignTeamMembersRoleRequest, getLibrary, getPermissionsByRole, getTeamMembers,
GetTeamMembersResponse, PermissionsByRole, QuerySettings,
} from './api';

const authzQueryKeys = {
all: [appId, 'authz'] as const,
teamMembers: (object: string) => [...authzQueryKeys.all, 'teamMembers', object] as const,
teamMembersAll: (scope: string) => [...authzQueryKeys.all, 'teamMembers', scope] as const,
teamMembers: (scope: string, querySettings?: QuerySettings) => [
...authzQueryKeys.teamMembersAll(scope), querySettings] as const,
permissionsByRole: (scope: string) => [...authzQueryKeys.all, 'permissionsByRole', scope] as const,
library: (libraryId: string) => [...authzQueryKeys.all, 'library', libraryId] as const,
};
Expand All @@ -20,17 +21,19 @@ const authzQueryKeys = {
* React Query hook to fetch all team members for a specific object/scope.
* It retrieves the full list of members who have access to the given scope.
*
* @param object - The unique identifier of the object/scope
* @param scope - The unique identifier of the object/scope
* @param querySettings - Optional query parameters for filtering, sorting, and pagination
*
* @example
* ```tsx
* const { data: teamMembers, isLoading, isError } = useTeamMembers('lib:123');
* const { data: teamMembers, isLoading, isError } = useTeamMembers('lib:123', querySettings);
* ```
*/
export const useTeamMembers = (object: string) => useQuery<TeamMember[], Error>({
queryKey: authzQueryKeys.teamMembers(object),
queryFn: () => getTeamMembers(object),
export const useTeamMembers = (scope: string, querySettings: QuerySettings) => useQuery<GetTeamMembersResponse, Error>({
queryKey: authzQueryKeys.teamMembers(scope, querySettings),
queryFn: () => getTeamMembers(scope, querySettings),
staleTime: 1000 * 60 * 30, // refetch after 30 minutes
refetchOnWindowFocus: false,

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Just curious as to why this is being added. It doesn't seem like something that would be a sort/filter/search specific change.

});

/**
Expand Down Expand Up @@ -80,7 +83,7 @@ export const useAssignTeamMembersRole = () => {
data: AssignTeamMembersRoleRequest
}) => assignTeamMembersRole(data),
onSettled: (_data, _error, { data: { scope } }) => {
queryClient.invalidateQueries({ queryKey: authzQueryKeys.teamMembers(scope) });
queryClient.invalidateQueries({ queryKey: authzQueryKeys.teamMembersAll(scope) });
},
});
};
16 changes: 9 additions & 7 deletions src/authz-module/libraries-manager/LibrariesUserManager.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -62,13 +62,15 @@ describe('LibrariesUserManager', () => {

// Mock team members
(useTeamMembers as jest.Mock).mockReturnValue({
data: [
{
username: 'testuser',
email: 'testuser@example.com',
roles: ['admin'],
},
],
data: {
results: [
{
username: 'testuser',
email: 'testuser@example.com',
roles: ['admin'],
},
],
},
});
});

Expand Down
17 changes: 13 additions & 4 deletions src/authz-module/libraries-manager/LibrariesUserManager.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -21,9 +21,18 @@ const LibrariesUserManager = () => {
const { data: library } = useLibrary(libraryId);
const rootBreadcrumb = intl.formatMessage(messages['library.authz.breadcrumb.root']) || '';
const pageManageTitle = intl.formatMessage(messages['library.authz.manage.page.title']);
const querySettings = {
order: null,
pageIndex: 0,
pageSize: 1,
roles: null,
search: username || null,
sortBy: null,
};

const { data: teamMember, isLoading: isLoadingTeamMember } = useTeamMembers(libraryId, querySettings);
const user = teamMember?.results?.find(member => member.username === username);

const { data: teamMembers, isLoading } = useTeamMembers(libraryId);
const user = teamMembers?.find(member => member.username === username);
const userRoles = useMemo(() => {
const assignedRoles = roles.filter(role => user?.roles.includes(role.role))
.map(role => ({
Expand Down Expand Up @@ -52,10 +61,10 @@ const LibrariesUserManager = () => {
: []}
>
<Container className="bg-light-200 p-5">
{isLoading ? <Skeleton count={2} height={200} /> : null}
{isLoadingTeamMember ? <Skeleton count={2} height={200} /> : null}
{userRoles && userRoles.map(role => (
<RoleCard
key={`${role}-${username}`}
key={`${role.role}-${username}`}
title={role.name}
objectName={library.title}
description={role.description}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -39,7 +39,7 @@ const AssignNewRoleModal: FC<AssignNewRoleModalProps> = ({

<ModalDialog.Body className="my-4">
<Form.Group controlId="role_options">
<Form.Label>{intl.formatMessage(messages['library.authz.team.table.roles'])}</Form.Label>
<Form.Label>{intl.formatMessage(messages['library.authz.manage.role.select.label'])}</Form.Label>
<Form.Control as="select" name="role" value={selectedRole} onChange={handleChangeSelectedRole}>
<option value="" disabled>Select a role</option>
{roleOptions.map((role) => <option key={role.role} value={role.role}>{role.name}</option>)}
Expand Down
Loading