Skip to content

Commit b332f62

Browse files
bra-i-amdcoa
andauthored
feat: [FC-0099] add filters and sorting functionality to the team table (#8)
* feat: add filters and sorting functionality to the team table * feat: add lodash.debounce for improved fetchData performance in TeamTable * feat: implement query settings management for team members table with filtering and pagination * fix: increase staleTime for useTeamMembers hook to 30 minutes * refactor: simplify TableControlBar layout and restore Clear filters button functionality * feat: add internationalization support for sorting and search placeholders * test: fix issues with failing tests * refactor: update SearchFilter to use string & localize Clear filters button text * test: add missing comprehensive tests * refactor: update sorting and group all in a Table * test: update to use useEvent intead of fireEvent * refactor: user retrival for paginated query in user detail view * refactor: separation of i18n messages * style: remove comment in API * fix: adress debaunce time --------- Co-authored-by: Diana Olarte <[email protected]>
1 parent 431314e commit b332f62

25 files changed

Lines changed: 1590 additions & 117 deletions

package-lock.json

Lines changed: 1 addition & 1 deletion
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

package.json

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -43,6 +43,7 @@
4343
"@openedx/frontend-plugin-framework": "^1.7.0",
4444
"@openedx/paragon": "^23.4.5",
4545
"@tanstack/react-query": "5.89.0",
46+
"lodash.debounce": "^4.0.8",
4647
"react": "^18.3.1",
4748
"react-dom": "^18.3.1",
4849
"react-router-dom": "^6.0.0"

src/authz-module/components/AuthZTitle.test.tsx

Lines changed: 5 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,6 @@
11
import { ReactNode } from 'react';
2-
import { render, screen, fireEvent } from '@testing-library/react';
2+
import { render, screen } from '@testing-library/react';
3+
import userEvent from '@testing-library/user-event';
34
import AuthZTitle, { AuthZTitleProps } from './AuthZTitle';
45

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

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

61-
actions.forEach(({ label, onClick }) => {
62+
actions.forEach(async ({ label, onClick }) => {
63+
const user = userEvent.setup();
6264
const button = screen.getByRole('button', { name: label });
6365
expect(button).toBeInTheDocument();
64-
fireEvent.click(button);
66+
await user.click(button);
6567
expect(onClick).toHaveBeenCalled();
6668
});
6769
});

src/authz-module/data/api.ts

Lines changed: 29 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -3,9 +3,18 @@ import { LibraryMetadata, TeamMember } from '@src/types';
33
import { camelCaseObject } from '@edx/frontend-platform';
44
import { getApiUrl, getStudioApiUrl } from '@src/data/utils';
55

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+
615
export interface GetTeamMembersResponse {
7-
members: TeamMember[];
8-
totalCount: number;
16+
results: TeamMember[];
17+
count: number;
918
}
1019

1120
export type PermissionsByRole = {
@@ -24,9 +33,24 @@ export interface AssignTeamMembersRoleRequest {
2433
scope: string;
2534
}
2635

27-
export const getTeamMembers = async (object: string): Promise<TeamMember[]> => {
28-
const { data } = await getAuthenticatedHttpClient().get(getApiUrl(`/api/authz/v1/roles/users/?scope=${object}`));
29-
return camelCaseObject(data.results);
36+
export const getTeamMembers = async (object: string, querySettings: QuerySettings): Promise<GetTeamMembersResponse> => {
37+
const url = new URL(getApiUrl(`/api/authz/v1/roles/users/?scope=${object}`));
38+
39+
if (querySettings.roles) {
40+
url.searchParams.set('roles', querySettings.roles);
41+
}
42+
if (querySettings.search) {
43+
url.searchParams.set('search', querySettings.search);
44+
}
45+
if (querySettings.sortBy && querySettings.order) {
46+
url.searchParams.set('sort_by', querySettings.sortBy);
47+
url.searchParams.set('order', querySettings.order);
48+
}
49+
url.searchParams.set('page_size', querySettings.pageSize.toString());
50+
url.searchParams.set('page', (querySettings.pageIndex + 1).toString());
51+
52+
const { data } = await getAuthenticatedHttpClient().get(url);
53+
return camelCaseObject(data);
3054
};
3155

3256
export const assignTeamMembersRole = async (

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

Lines changed: 29 additions & 17 deletions
Original file line numberDiff line numberDiff line change
@@ -10,20 +10,23 @@ jest.mock('@edx/frontend-platform/auth', () => ({
1010
getAuthenticatedHttpClient: jest.fn(),
1111
}));
1212

13-
const mockMembers = [
14-
{
15-
fullName: 'Alice',
16-
username: 'user1',
17-
18-
roles: ['admin', 'author'],
19-
},
20-
{
21-
fullName: 'Bob',
22-
username: 'user2',
23-
24-
roles: ['contributor'],
25-
},
26-
];
13+
const mockMembers = {
14+
count: 2,
15+
results: [
16+
{
17+
fullName: 'Alice',
18+
username: 'user1',
19+
20+
roles: ['admin', 'author'],
21+
},
22+
{
23+
fullName: 'Bob',
24+
username: 'user2',
25+
26+
roles: ['collaborator'],
27+
},
28+
],
29+
};
2730

2831
const mockLibrary = {
2932
id: 'lib:123',
@@ -32,6 +35,15 @@ const mockLibrary = {
3235
slug: 'test-library',
3336
};
3437

38+
const mockQuerySettings = {
39+
roles: null,
40+
search: null,
41+
order: null,
42+
sortBy: null,
43+
pageSize: 10,
44+
pageIndex: 0,
45+
};
46+
3547
const createWrapper = () => {
3648
const queryClient = new QueryClient({
3749
defaultOptions: {
@@ -58,10 +70,10 @@ describe('useTeamMembers', () => {
5870

5971
it('returns data when API call succeeds', async () => {
6072
getAuthenticatedHttpClient.mockReturnValue({
61-
get: jest.fn().mockResolvedValue({ data: { results: mockMembers } }),
73+
get: jest.fn().mockResolvedValue({ data: mockMembers }),
6274
});
6375

64-
const { result } = renderHook(() => useTeamMembers('lib:123'), {
76+
const { result } = renderHook(() => useTeamMembers('lib:123', mockQuerySettings), {
6577
wrapper: createWrapper(),
6678
});
6779

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

79-
const { result } = renderHook(() => useTeamMembers('lib:123'), {
91+
const { result } = renderHook(() => useTeamMembers('lib:123', mockQuerySettings), {
8092
wrapper: createWrapper(),
8193
});
8294

src/authz-module/data/hooks.ts

Lines changed: 14 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -2,16 +2,17 @@ import {
22
useMutation, useQuery, useQueryClient, useSuspenseQuery,
33
} from '@tanstack/react-query';
44
import { appId } from '@src/constants';
5-
import { LibraryMetadata, TeamMember } from '@src/types';
5+
import { LibraryMetadata } from '@src/types';
66
import {
7-
assignTeamMembersRole,
8-
AssignTeamMembersRoleRequest,
9-
getLibrary, getPermissionsByRole, getTeamMembers, PermissionsByRole,
7+
assignTeamMembersRole, AssignTeamMembersRoleRequest, getLibrary, getPermissionsByRole, getTeamMembers,
8+
GetTeamMembersResponse, PermissionsByRole, QuerySettings,
109
} from './api';
1110

1211
const authzQueryKeys = {
1312
all: [appId, 'authz'] as const,
14-
teamMembers: (object: string) => [...authzQueryKeys.all, 'teamMembers', object] as const,
13+
teamMembersAll: (scope: string) => [...authzQueryKeys.all, 'teamMembers', scope] as const,
14+
teamMembers: (scope: string, querySettings?: QuerySettings) => [
15+
...authzQueryKeys.teamMembersAll(scope), querySettings] as const,
1516
permissionsByRole: (scope: string) => [...authzQueryKeys.all, 'permissionsByRole', scope] as const,
1617
library: (libraryId: string) => [...authzQueryKeys.all, 'library', libraryId] as const,
1718
};
@@ -20,17 +21,19 @@ const authzQueryKeys = {
2021
* React Query hook to fetch all team members for a specific object/scope.
2122
* It retrieves the full list of members who have access to the given scope.
2223
*
23-
* @param object - The unique identifier of the object/scope
24+
* @param scope - The unique identifier of the object/scope
25+
* @param querySettings - Optional query parameters for filtering, sorting, and pagination
2426
*
2527
* @example
2628
* ```tsx
27-
* const { data: teamMembers, isLoading, isError } = useTeamMembers('lib:123');
29+
* const { data: teamMembers, isLoading, isError } = useTeamMembers('lib:123', querySettings);
2830
* ```
2931
*/
30-
export const useTeamMembers = (object: string) => useQuery<TeamMember[], Error>({
31-
queryKey: authzQueryKeys.teamMembers(object),
32-
queryFn: () => getTeamMembers(object),
32+
export const useTeamMembers = (scope: string, querySettings: QuerySettings) => useQuery<GetTeamMembersResponse, Error>({
33+
queryKey: authzQueryKeys.teamMembers(scope, querySettings),
34+
queryFn: () => getTeamMembers(scope, querySettings),
3335
staleTime: 1000 * 60 * 30, // refetch after 30 minutes
36+
refetchOnWindowFocus: false,
3437
});
3538

3639
/**
@@ -80,7 +83,7 @@ export const useAssignTeamMembersRole = () => {
8083
data: AssignTeamMembersRoleRequest
8184
}) => assignTeamMembersRole(data),
8285
onSettled: (_data, _error, { data: { scope } }) => {
83-
queryClient.invalidateQueries({ queryKey: authzQueryKeys.teamMembers(scope) });
86+
queryClient.invalidateQueries({ queryKey: authzQueryKeys.teamMembersAll(scope) });
8487
},
8588
});
8689
};

src/authz-module/libraries-manager/LibrariesUserManager.test.tsx

Lines changed: 9 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -62,13 +62,15 @@ describe('LibrariesUserManager', () => {
6262

6363
// Mock team members
6464
(useTeamMembers as jest.Mock).mockReturnValue({
65-
data: [
66-
{
67-
username: 'testuser',
68-
69-
roles: ['admin'],
70-
},
71-
],
65+
data: {
66+
results: [
67+
{
68+
username: 'testuser',
69+
70+
roles: ['admin'],
71+
},
72+
],
73+
},
7274
});
7375
});
7476

src/authz-module/libraries-manager/LibrariesUserManager.tsx

Lines changed: 13 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -21,9 +21,18 @@ const LibrariesUserManager = () => {
2121
const { data: library } = useLibrary(libraryId);
2222
const rootBreadcrumb = intl.formatMessage(messages['library.authz.breadcrumb.root']) || '';
2323
const pageManageTitle = intl.formatMessage(messages['library.authz.manage.page.title']);
24+
const querySettings = {
25+
order: null,
26+
pageIndex: 0,
27+
pageSize: 1,
28+
roles: null,
29+
search: username || null,
30+
sortBy: null,
31+
};
32+
33+
const { data: teamMember, isLoading: isLoadingTeamMember } = useTeamMembers(libraryId, querySettings);
34+
const user = teamMember?.results?.find(member => member.username === username);
2435

25-
const { data: teamMembers, isLoading } = useTeamMembers(libraryId);
26-
const user = teamMembers?.find(member => member.username === username);
2736
const userRoles = useMemo(() => {
2837
const assignedRoles = roles.filter(role => user?.roles.includes(role.role))
2938
.map(role => ({
@@ -52,10 +61,10 @@ const LibrariesUserManager = () => {
5261
: []}
5362
>
5463
<Container className="bg-light-200 p-5">
55-
{isLoading ? <Skeleton count={2} height={200} /> : null}
64+
{isLoadingTeamMember ? <Skeleton count={2} height={200} /> : null}
5665
{userRoles && userRoles.map(role => (
5766
<RoleCard
58-
key={`${role}-${username}`}
67+
key={`${role.role}-${username}`}
5968
title={role.name}
6069
objectName={library.title}
6170
description={role.description}

src/authz-module/libraries-manager/components/AssignNewRoleModal/AssignNewRoleModal.tsx

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -39,7 +39,7 @@ const AssignNewRoleModal: FC<AssignNewRoleModalProps> = ({
3939

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

0 commit comments

Comments
 (0)