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 <diana.olarte@edunext.co>
This commit is contained in:
Brayan Cerón
2025-10-23 10:18:11 -05:00
committed by GitHub
parent 431314e44b
commit b332f625d7
25 changed files with 1590 additions and 117 deletions

2
package-lock.json generated
View File

@@ -16,6 +16,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"
@@ -20939,7 +20940,6 @@
"version": "4.0.8",
"resolved": "https://registry.npmjs.org/lodash.debounce/-/lodash.debounce-4.0.8.tgz",
"integrity": "sha512-FT1yDzDYEoYWhnSGnpE/4Kj1fLZkDFyqRb7fNt6FdYOSxlUWAtp42Eh6Wb0rGIv/m9Bgo7x4GhQbm5Ys4SG5ow==",
"devOptional": true,
"license": "MIT"
},
"node_modules/lodash.memoize": {

View File

@@ -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"

View File

@@ -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', () => ({
@@ -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();
});
});

View File

@@ -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 = {
@@ -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);
}
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 (

View File

@@ -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',
@@ -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: {
@@ -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(),
});
@@ -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(),
});

View File

@@ -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,
};
@@ -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,
});
/**
@@ -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) });
},
});
};

View File

@@ -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'],
},
],
},
});
});

View File

@@ -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 => ({
@@ -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}

View File

@@ -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>)}

View File

@@ -0,0 +1,115 @@
import { render, screen } from '@testing-library/react';
import userEvent from '@testing-library/user-event';
import MultipleChoiceFilter from './MultipleChoiceFilter';
describe('MultipleChoiceFilter', () => {
const mockSetFilter = jest.fn();
const defaultProps = {
Header: 'Test Filter',
filterChoices: [
{ name: 'Option 1', number: 5, value: 'option1' },
{ name: 'Option 2', number: 3, value: 'option2' },
{ name: 'Option 3', number: 0, value: 'option3' },
],
filterValue: [],
setFilter: mockSetFilter,
};
beforeEach(() => {
jest.clearAllMocks();
});
it('should render dropdown with correct header', () => {
render(<MultipleChoiceFilter {...defaultProps} />);
expect(screen.getByRole('button')).toBeInTheDocument();
expect(screen.getByText('Test Filter')).toBeInTheDocument();
});
it('should render FilterList icon', () => {
render(<MultipleChoiceFilter {...defaultProps} />);
const button = screen.getByRole('button');
const icon = button.querySelector('svg');
expect(icon).toBeInTheDocument();
});
it('should show all filter choices when dropdown is opened', async () => {
const user = userEvent.setup();
render(<MultipleChoiceFilter {...defaultProps} />);
await user.click(screen.getByRole('button'));
expect(screen.getByText('Option 1 (5)')).toBeInTheDocument();
expect(screen.getByText('Option 2 (3)')).toBeInTheDocument();
expect(screen.getByText('Option 3 (0)')).toBeInTheDocument();
});
it('should add value to filter when checkbox is checked', async () => {
const user = userEvent.setup();
render(<MultipleChoiceFilter {...defaultProps} />);
await user.click(screen.getByRole('button'));
const checkbox1 = screen.getByLabelText('Option 1');
await user.click(checkbox1);
expect(mockSetFilter).toHaveBeenCalledWith(['option1']);
});
it('should remove value from filter when checkbox is unchecked', async () => {
const user = userEvent.setup();
const propsWithSelectedValue = {
...defaultProps,
filterValue: ['option1', 'option2'],
};
render(<MultipleChoiceFilter {...propsWithSelectedValue} />);
await user.click(screen.getByRole('button'));
const checkbox1 = screen.getByLabelText('Option 1');
await user.click(checkbox1);
expect(mockSetFilter).toHaveBeenCalledWith(['option2']);
});
it('should show checked checkboxes for pre-selected values', async () => {
const user = userEvent.setup();
const propsWithSelectedValues = {
...defaultProps,
filterValue: ['option1', 'option3'],
};
render(<MultipleChoiceFilter {...propsWithSelectedValues} />);
await user.click(screen.getByRole('button'));
const checkbox1 = screen.getByLabelText('Option 1');
const checkbox2 = screen.getByLabelText('Option 2');
const checkbox3 = screen.getByLabelText('Option 3');
expect(checkbox1).toBeChecked();
expect(checkbox2).not.toBeChecked();
expect(checkbox3).toBeChecked();
});
it('should call setFilter with correct array when adding to existing selections', async () => {
const user = userEvent.setup();
const propsWithExistingSelection = {
...defaultProps,
filterValue: ['option2'],
};
render(<MultipleChoiceFilter {...propsWithExistingSelection} />);
await user.click(screen.getByRole('button'));
const checkbox1 = screen.getByLabelText('Option 1');
await user.click(checkbox1);
expect(mockSetFilter).toHaveBeenCalledWith(['option2', 'option1']);
});
});

View File

@@ -0,0 +1,65 @@
import { FC } from 'react';
import {
Dropdown, Form, Icon, Stack,
} from '@openedx/paragon';
import { FilterList } from '@openedx/paragon/icons';
interface MultipleChoiceFilterProps {
Header: string;
filterChoices: Array<{ name: string; number: number; value: string }>;
filterValue: string[] | undefined;
setFilter: (value: string[]) => void;
}
const MultipleChoiceFilter: FC<MultipleChoiceFilterProps> = ({
Header, filterChoices, filterValue, setFilter,
}) => {
const checkedBoxes = filterValue || [];
const changeCheckbox = (value) => {
if (checkedBoxes.includes(value)) {
const newCheckedBoxes = checkedBoxes.filter((val) => val !== value);
return setFilter(newCheckedBoxes);
}
checkedBoxes.push(value);
return setFilter(checkedBoxes);
};
return (
<Dropdown>
<Dropdown.Toggle variant="outline-primary">
<Stack direction="horizontal" gap={2}>
<Icon color="primary" src={FilterList} />
{Header}
</Stack>
</Dropdown.Toggle>
<Dropdown.Menu>
<Form.CheckboxSet
className="pgn__dropdown-filter-checkbox-group"
name={Header}
aria-label={Header}
value={checkedBoxes}
>
{filterChoices.map(({
name, number, value,
}) => (
<Form.Checkbox
className="m-2"
key={name}
value={value}
onChange={() => changeCheckbox(value)}
aria-label={name}
>
<Stack direction="horizontal" gap={2}>
{`${name} (${number || 0})`}
</Stack>
</Form.Checkbox>
))}
</Form.CheckboxSet>
</Dropdown.Menu>
</Dropdown>
);
};
export default MultipleChoiceFilter;

View File

@@ -0,0 +1,94 @@
import { useState } from 'react';
import { render, screen } from '@testing-library/react';
import userEvent from '@testing-library/user-event';
import SearchFilter from './SearchFilter';
describe('SearchFilter', () => {
beforeEach(() => {
jest.clearAllMocks();
});
const SearchFilterWrapper = ({
initFilterValue = '', customPlaceholder = 'Search placeholder',
}:{ initFilterValue?: string; customPlaceholder?:string }) => {
const [filter, setFilter] = useState(initFilterValue);
return (
<SearchFilter
filterValue={filter}
setFilter={setFilter}
placeholder={customPlaceholder}
/>
);
};
it('should render search input with correct placeholder', () => {
render(<SearchFilterWrapper />);
const input = screen.getByPlaceholderText('Search placeholder');
expect(input).toBeInTheDocument();
expect(input).toHaveAttribute('type', 'text');
});
it('should display empty value when filterValue is undefined', () => {
render(<SearchFilterWrapper initFilterValue={undefined} />);
const input = screen.getByPlaceholderText('Search placeholder');
expect(input).toHaveValue('');
});
it('should display filterValue if provided', () => {
render(<SearchFilterWrapper initFilterValue="test search" />);
const input = screen.getByPlaceholderText('Search placeholder');
expect(input).toHaveValue('test search');
});
it('should call setFilter with input value when typing', async () => {
const user = userEvent.setup();
render(<SearchFilterWrapper />);
const input = screen.getByPlaceholderText('Search placeholder');
await user.click(input);
await user.type(input, 'new search term');
expect(input).toHaveValue('new search term');
});
it('should clear the input correctly', async () => {
const user = userEvent.setup();
render(<SearchFilterWrapper initFilterValue="existing value" />);
const input = screen.getByPlaceholderText('Search placeholder');
await user.click(input);
await user.clear(input);
expect(input).toHaveValue('');
});
it('should handle multiple character input correctly', async () => {
const user = userEvent.setup();
render(<SearchFilterWrapper />);
const input = screen.getByPlaceholderText('Search placeholder');
await user.click(input);
// Type multiple characters
await user.type(input, 'a');
expect(input).toHaveValue('a');
await user.type(input, 'b');
expect(input).toHaveValue('ab');
await user.type(input, 'c');
expect(input).toHaveValue('abc');
});
it('should handle different placeholder text', () => {
const customPlaceholder = 'Enter search term here...';
render(<SearchFilterWrapper customPlaceholder={customPlaceholder} />);
const input = screen.getByPlaceholderText(customPlaceholder);
expect(input).toBeInTheDocument();
expect(input).toHaveAttribute('placeholder', customPlaceholder);
});
});

View File

@@ -0,0 +1,29 @@
import { FC } from 'react';
import {
Form,
Icon,
} from '@openedx/paragon';
import { Search } from '@openedx/paragon/icons';
interface SearchFilterProps {
filterValue: string;
setFilter: (value: string) => void;
placeholder: string;
}
const SearchFilter: FC<SearchFilterProps> = ({
filterValue, setFilter, placeholder,
}) => (
<Form.Control
className="mw-xs mr-0"
trailingElement={<Icon src={Search} />}
value={filterValue || ''}
type="text"
onChange={e => {
setFilter(e.target.value || undefined); // Set undefined to remove the filter entirely
}}
placeholder={placeholder}
/>
);
export default SearchFilter;

View File

@@ -0,0 +1,138 @@
import { screen } from '@testing-library/react';
import userEvent from '@testing-library/user-event';
import { DataTableContext } from '@openedx/paragon';
import { renderWrapper } from '@src/setupTest';
import SortDropdown from './SortDropdown';
jest.mock('@edx/frontend-platform/i18n', () => jest.requireActual('@edx/frontend-platform/i18n'));
describe('SortDropdown', () => {
const mockToggleSortBy = jest.fn();
const defaultDataTableState = {
sortBy: [],
filters: [],
pageSize: 10,
pageIndex: 0,
};
const mockDataTableContext = {
state: defaultDataTableState,
toggleSortBy: mockToggleSortBy,
};
const renderSortDropdown = (contextOverrides = {}) => {
const contextValue = {
...mockDataTableContext,
...contextOverrides,
};
return renderWrapper(
<DataTableContext.Provider value={contextValue}>
<SortDropdown />
</DataTableContext.Provider>,
);
};
beforeEach(() => {
jest.clearAllMocks();
});
it('should render the sort dropdown with default label', () => {
renderSortDropdown();
expect(screen.getByRole('button')).toBeInTheDocument();
expect(screen.getByText('Sort')).toBeInTheDocument();
});
it('should render all sort options when dropdown is opened', async () => {
const user = userEvent.setup();
renderSortDropdown();
const toggleButton = screen.getByRole('button');
await user.click(toggleButton);
expect(screen.getByText('Name A-Z')).toBeInTheDocument();
expect(screen.getByText('Name Z-A')).toBeInTheDocument();
});
it('should display current sort when a sort is active', () => {
const contextWithSort = {
state: {
...defaultDataTableState,
sortBy: [{ id: 'username', desc: false }],
},
};
renderSortDropdown(contextWithSort);
expect(screen.getByText('Name A-Z')).toBeInTheDocument();
});
it('should display descending sort correctly', () => {
const contextWithSort = {
state: {
...defaultDataTableState,
sortBy: [{ id: 'username', desc: true }],
},
};
renderSortDropdown(contextWithSort);
expect(screen.getByText('Name Z-A')).toBeInTheDocument();
});
it('should handle sort selection and call toggleSortBy', async () => {
const user = userEvent.setup();
renderSortDropdown();
const toggleButton = screen.getByRole('button');
await user.click(toggleButton);
const nameAZOption = screen.getByText('Name A-Z');
await user.click(nameAZOption);
expect(mockToggleSortBy).toHaveBeenCalledWith('username', false);
const nameZAOption = screen.getByText('Name Z-A');
await user.click(toggleButton);
await user.click(nameZAOption);
expect(mockToggleSortBy).toHaveBeenCalledWith('username', true);
});
it('should mark the active sort option as active', async () => {
const user = userEvent.setup();
const contextWithSort = {
state: {
...defaultDataTableState,
sortBy: [{ id: 'username', desc: false }],
},
};
renderSortDropdown(contextWithSort);
const toggleButton = screen.getByRole('button');
await user.click(toggleButton);
// Get all elements with "Name A-Z" text and find the dropdown item
const nameAZOptions = screen.getAllByText('Name A-Z');
const dropdownItem = nameAZOptions.find(element => element.closest('.dropdown-item'));
expect(dropdownItem?.closest('.dropdown-item')).toHaveClass('active');
});
it('should handle undefined sortBy', () => {
const contextWithUndefinedSort = {
state: {
...defaultDataTableState,
sortBy: undefined,
},
};
renderSortDropdown(contextWithUndefinedSort);
expect(screen.getByText('Sort')).toBeInTheDocument();
});
});

View File

@@ -0,0 +1,94 @@
import {
useContext, useState, useMemo, useCallback,
useEffect,
FC,
} from 'react';
import { useIntl } from '@edx/frontend-platform/i18n';
import {
DataTableContext,
Dropdown,
Icon,
Stack,
} from '@openedx/paragon';
import { SwapVert } from '@openedx/paragon/icons';
interface SortOption {
id: string;
desc: boolean;
label: string;
}
interface SortByOptions {
[key: string]: Omit<SortOption, 'label'>;
}
const SORT_BY_OPTIONS: SortByOptions = {
'name-a-z': { id: 'username', desc: false },
'name-z-a': { id: 'username', desc: true },
};
const SortDropdown: FC = () => {
const intl = useIntl();
const { toggleSortBy, state } = useContext<DataTableContext>(DataTableContext);
const [sortOrder, setSortOrder] = useState<string | undefined>(undefined);
const SORT_LABELS: Record<string, string> = useMemo(() => ({
'name-a-z': intl.formatMessage({ id: 'authz.libraries.team.table.sort.name-a-z', defaultMessage: 'Name A-Z' }),
'name-z-a': intl.formatMessage({ id: 'authz.libraries.team.table.sort.name-z-a', defaultMessage: 'Name Z-A' }),
}), [intl]);
const currentSort = useMemo(() => {
if (!state?.sortBy?.length) { return undefined; }
const activeSortBy = state.sortBy[0];
return Object.entries(SORT_BY_OPTIONS).find(
([, option]) => option.id === activeSortBy.id && option.desc === activeSortBy.desc,
)?.[0]; // return the key
}, [state?.sortBy]);
useEffect(() => {
setSortOrder(currentSort);
}, [currentSort]);
const handleChangeSortBy = useCallback((newSortOrder: string) => {
setSortOrder(newSortOrder);
const { id, desc } = SORT_BY_OPTIONS[newSortOrder];
toggleSortBy(id, desc);
}, [toggleSortBy]);
const sortOptions = useMemo(
() => Object.entries(SORT_BY_OPTIONS).map(([key, option]) => ({
key,
...option,
label: SORT_LABELS[key],
})),
[SORT_LABELS],
);
const currentSortLabel = sortOrder ? SORT_LABELS[sortOrder] : 'Sort';
return (
<Dropdown onSelect={handleChangeSortBy}>
<Dropdown.Toggle variant="outline-primary">
<Stack direction="horizontal" gap={2}>
<Icon color="primary" src={SwapVert} />
{currentSortLabel}
</Stack>
</Dropdown.Toggle>
<Dropdown.Menu>
{sortOptions.map(({ key, label }) => (
<Dropdown.Item
key={key}
active={sortOrder === key}
eventKey={key}
>
{label}
</Dropdown.Item>
))}
</Dropdown.Menu>
</Dropdown>
);
};
export default SortDropdown;

View File

@@ -0,0 +1,183 @@
import { screen } from '@testing-library/react';
import {
DataTableContext, CheckboxFilter, TextFilter,
} from '@openedx/paragon';
import { renderWrapper } from '@src/setupTest';
import userEvent from '@testing-library/user-event';
import TableControlBar from './TableControlBar';
jest.mock('./MultipleChoiceFilter', () => {
// eslint-disable-next-line react/prop-types
const MockMultipleChoiceFilter = (props) => (
// eslint-disable-next-line react/prop-types
<div data-testid="multiple-choice-filter" data-column-id={props.id || props.accessor}>
Multiple Choice Filter
</div>
);
MockMultipleChoiceFilter.displayName = 'MultipleChoiceFilter';
return MockMultipleChoiceFilter;
});
jest.mock('./SortDropdown', () => {
const MockSortDropdown = () => (
<div data-testid="sort-dropdown">
Sort Dropdown
</div>
);
MockSortDropdown.displayName = 'SortDropdown';
return MockSortDropdown;
});
jest.mock('./SearchFilter', () => {
// eslint-disable-next-line react/prop-types
const MockSearchFilter = (props) => (
<div data-testid="search-filter">
<input
// eslint-disable-next-line react/prop-types
placeholder={props.placeholder}
// eslint-disable-next-line react/prop-types
value={props.filterValue || ''}
// eslint-disable-next-line react/prop-types
onChange={(e) => props.setFilter(e.target.value)}
data-testid="search-input"
/>
</div>
);
MockSearchFilter.displayName = 'SearchFilter';
return MockSearchFilter;
});
describe('TableControlBar', () => {
const mockSetAllFilters = jest.fn();
const mockSetFilter = jest.fn();
const defaultContextValue = {
columns: [] as any[],
setAllFilters: mockSetAllFilters,
state: {
filters: [] as any[],
},
};
const renderWithContext = (contextValue = defaultContextValue) => (
renderWrapper(
<DataTableContext.Provider value={contextValue}>
<TableControlBar />
</DataTableContext.Provider>,
)
);
beforeEach(() => {
jest.clearAllMocks();
});
it('should render basic structure with SortDropdown and RowStatus', () => {
renderWithContext();
expect(screen.getByTestId('sort-dropdown')).toBeInTheDocument();
const container = screen.getByText('Sort Dropdown').closest('.pgn__data-table-status-bar');
expect(container).toHaveClass('pgn__data-table-status-bar', 'mb-3', 'flex-wrap');
});
it('should not render Clear filters button when no filters are active', () => {
renderWithContext();
expect(screen.queryByText('Clear filters')).not.toBeInTheDocument();
});
it('should render Clear filters button when filters are active', () => {
const contextWithFilters = {
...defaultContextValue,
state: {
filters: [{ id: 'username', value: 'test' }],
},
};
renderWithContext(contextWithFilters);
expect(screen.getByText('Clear filters')).toBeInTheDocument();
});
it('should call setAllFilters with empty array when Clear filters is clicked', async () => {
const user = userEvent.setup();
const contextWithFilters = {
...defaultContextValue,
state: {
filters: [{ id: 'username', value: 'test' }],
},
};
renderWithContext(contextWithFilters);
const clearButton = screen.getByText('Clear filters');
await user.click(clearButton);
expect(mockSetAllFilters).toHaveBeenCalledWith([]);
});
it('should render MultipleChoiceFilter for columns with CheckboxFilter', () => {
const contextWithCheckboxColumn = {
...defaultContextValue,
columns: [
{
id: 'roles',
Header: 'Roles',
Filter: CheckboxFilter,
canFilter: true,
accessor: 'roles',
},
],
};
renderWithContext(contextWithCheckboxColumn);
const multipleChoiceFilter = screen.getByTestId('multiple-choice-filter');
expect(multipleChoiceFilter).toBeInTheDocument();
expect(multipleChoiceFilter).toHaveAttribute('data-column-id', 'roles');
});
it('should render SearchFilter for columns with TextFilter', () => {
const contextWithTextColumn = {
...defaultContextValue,
columns: [
{
id: 'username',
Header: 'Username',
Filter: TextFilter,
canFilter: true,
filterValue: '',
setFilter: mockSetFilter,
accessor: 'username',
},
],
};
renderWithContext(contextWithTextColumn);
expect(screen.getByTestId('search-filter')).toBeInTheDocument();
expect(screen.getByTestId('search-input')).toBeInTheDocument();
});
it('should not render any filter for unsupported Filter types', () => {
const CustomFilter = () => <div>Custom Filter</div>;
const contextWithCustomFilter = {
...defaultContextValue,
columns: [
{
id: 'custom',
Header: 'Custom',
Filter: CustomFilter,
canFilter: true,
},
],
};
renderWithContext(contextWithCustomFilter);
// Only SortDropdown should be present, no filter components
expect(screen.getByTestId('sort-dropdown')).toBeInTheDocument();
expect(screen.queryByTestId('search-filter')).not.toBeInTheDocument();
expect(screen.queryByTestId('multiple-choice-filter')).not.toBeInTheDocument();
});
});

View File

@@ -0,0 +1,73 @@
import { useContext } from 'react';
import { useIntl } from '@edx/frontend-platform/i18n';
import {
DataTable, DataTableContext,
CheckboxFilter,
Stack,
TextFilter,
Button,
} from '@openedx/paragon';
import MultipleChoiceFilter from './MultipleChoiceFilter';
import SortDropdown from './SortDropdown';
import SearchFilter from './SearchFilter';
import messages from '../messages';
const TableControlBar = () => {
const intl = useIntl();
const {
columns,
setAllFilters,
state,
} = useContext<DataTableContext>(DataTableContext);
const availableFilters = columns.filter((column) => column.canFilter);
const columnTextFilterHeaders = columns
.filter((column) => column.Filter === TextFilter)
.map((column) => column.Header);
const getSearchPlaceholder = () => intl.formatMessage(messages['authz.libraries.team.table.search'], {
firstField: columnTextFilterHeaders[0] || 'field',
secondField: columnTextFilterHeaders[1] || 'field',
});
return (
<Stack className="pgn__data-table-status-bar mb-3 flex-wrap" gap={2} direction="horizontal">
{availableFilters.map((column) => {
if (column.Filter === CheckboxFilter) {
return <MultipleChoiceFilter {...column} />;
}
if (column.Filter === TextFilter) {
return (
<SearchFilter
key={column.id || column.accessor}
filterValue={column.filterValue}
setFilter={column.setFilter}
placeholder={getSearchPlaceholder()}
/>
);
}
return null;
})}
<SortDropdown />
{state.filters.length > 0 && (
<Button
variant="link"
onClick={() => setAllFilters([])}
>
{intl.formatMessage(messages['authz.libraries.team.table.clearFilters'])}
</Button>
)}
<DataTable.RowStatus className="ml-auto" />
</Stack>
);
};
export default TableControlBar;

View File

@@ -0,0 +1,447 @@
import { renderHook, act } from '@testing-library/react';
import { QuerySettings } from '@src/authz-module/data/api';
import { useQuerySettings } from './useQuerySettings';
describe('useQuerySettings', () => {
const defaultQuerySettings: QuerySettings = {
roles: null,
search: null,
pageSize: 10,
pageIndex: 0,
sortBy: null,
order: null,
};
it('should initialize with default query settings when no initial settings provided', () => {
const { result } = renderHook(() => useQuerySettings());
expect(result.current.querySettings).toEqual(defaultQuerySettings);
expect(typeof result.current.handleTableFetch).toBe('function');
});
it('should initialize with custom initial query settings', () => {
const customInitialSettings: QuerySettings = {
roles: 'admin,editor',
search: 'test-user',
pageSize: 20,
pageIndex: 2,
sortBy: 'username',
order: 'asc',
};
const { result } = renderHook(() => useQuerySettings(customInitialSettings));
expect(result.current.querySettings).toEqual(customInitialSettings);
});
it('should update query settings when handleTableFetch is called with new filters', () => {
const { result } = renderHook(() => useQuerySettings());
const tableFilters = {
pageSize: 15,
pageIndex: 1,
sortBy: [{ id: 'username', desc: false }],
filters: [
{ id: 'roles', value: ['admin', 'editor'] },
{ id: 'username', value: 'john' },
],
};
act(() => {
result.current.handleTableFetch(tableFilters);
});
expect(result.current.querySettings).toEqual({
roles: 'admin,editor',
search: 'john',
pageSize: 15,
pageIndex: 1,
sortBy: 'username',
order: 'asc',
});
});
it('should handle descending sort order by adding minus prefix', () => {
const { result } = renderHook(() => useQuerySettings());
const tableFilters = {
pageSize: 10,
pageIndex: 0,
sortBy: [{ id: 'email', desc: true }],
filters: [],
};
act(() => {
result.current.handleTableFetch(tableFilters);
});
expect(result.current.querySettings.order).toBe('desc');
});
it('should convert camelCase sort field to snake_case', () => {
const { result } = renderHook(() => useQuerySettings());
const tableFilters = {
pageSize: 10,
pageIndex: 0,
sortBy: [{ id: 'firstName', desc: false }],
filters: [],
};
act(() => {
result.current.handleTableFetch(tableFilters);
});
expect(result.current.querySettings.sortBy).toBe('first_name');
});
it('should convert camelCase sort field to snake_case with descending order', () => {
const { result } = renderHook(() => useQuerySettings());
const tableFilters = {
pageSize: 10,
pageIndex: 0,
sortBy: [{ id: 'lastName', desc: true }],
filters: [],
};
act(() => {
result.current.handleTableFetch(tableFilters);
});
expect(result.current.querySettings.order).toBe('desc');
});
it('should handle empty filters by setting values to null', () => {
const { result } = renderHook(() => useQuerySettings());
const tableFilters = {
pageSize: 10,
pageIndex: 0,
sortBy: [],
filters: [],
};
act(() => {
result.current.handleTableFetch(tableFilters);
});
expect(result.current.querySettings).toEqual({
roles: null,
search: null,
pageSize: 10,
pageIndex: 0,
order: null,
sortBy: null,
});
});
it('should handle empty roles filter array by setting roles to null', () => {
const { result } = renderHook(() => useQuerySettings());
const tableFilters = {
pageSize: 10,
pageIndex: 0,
sortBy: [],
filters: [
{ id: 'roles', value: [] },
{ id: 'username', value: '' },
],
};
act(() => {
result.current.handleTableFetch(tableFilters);
});
expect(result.current.querySettings).toEqual({
roles: null,
search: null,
pageSize: 10,
pageIndex: 0,
order: null,
sortBy: null,
});
});
it('should handle missing filters by setting default values', () => {
const { result } = renderHook(() => useQuerySettings());
const tableFilters = {
pageSize: 10,
pageIndex: 0,
sortBy: [],
filters: [
{ id: 'roles', value: undefined },
{ id: 'username', value: undefined },
],
};
act(() => {
result.current.handleTableFetch(tableFilters);
});
expect(result.current.querySettings).toEqual({
roles: null,
search: null,
pageSize: 10,
pageIndex: 0,
order: null,
sortBy: null,
});
});
it('should use default pagination values when not provided', () => {
const { result } = renderHook(() => useQuerySettings());
const tableFilters = {
sortBy: [],
filters: [],
} as any; // Missing pageSize and pageIndex
act(() => {
result.current.handleTableFetch(tableFilters);
});
expect(result.current.querySettings.pageSize).toBe(10);
expect(result.current.querySettings.pageIndex).toBe(0);
});
it('should not update state if settings have not changed', () => {
const { result } = renderHook(() => useQuerySettings());
const tableFilters = {
pageSize: 10,
pageIndex: 0,
sortBy: [],
filters: [],
};
const initialSettings = result.current.querySettings;
act(() => {
result.current.handleTableFetch(tableFilters);
});
// Should be the same object reference since no changes occurred
expect(result.current.querySettings).toBe(initialSettings);
});
it('should update state when settings have changed', () => {
const { result } = renderHook(() => useQuerySettings());
const initialSettings = result.current.querySettings;
const tableFilters = {
pageSize: 20, // Different from default
pageIndex: 0,
sortBy: [],
filters: [],
};
act(() => {
result.current.handleTableFetch(tableFilters);
});
// Should be a different object reference since pageSize changed
expect(result.current.querySettings).not.toBe(initialSettings);
expect(result.current.querySettings.pageSize).toBe(20);
});
it('should handle complex filter combinations', () => {
const { result } = renderHook(() => useQuerySettings());
const tableFilters = {
pageSize: 25,
pageIndex: 3,
sortBy: [{ id: 'userRole', desc: true }],
filters: [
{ id: 'roles', value: ['admin', 'editor', 'viewer'] },
{ id: 'username', value: 'test@example.com' },
{ id: 'otherFilter', value: 'ignored' }, // Should be ignored
],
};
act(() => {
result.current.handleTableFetch(tableFilters);
});
expect(result.current.querySettings).toEqual({
roles: 'admin,editor,viewer',
search: 'test@example.com',
pageSize: 25,
pageIndex: 3,
order: 'desc',
sortBy: 'user_role',
});
});
it('should handle multiple camelCase words in sort field', () => {
const { result } = renderHook(() => useQuerySettings());
const tableFilters = {
pageSize: 10,
pageIndex: 0,
sortBy: [{ id: 'userFirstLastName', desc: false }],
filters: [],
};
act(() => {
result.current.handleTableFetch(tableFilters);
});
expect(result.current.querySettings.sortBy).toBe('user_first_last_name');
});
it('should preserve handleTableFetch function reference across renders', () => {
const { result, rerender } = renderHook(() => useQuerySettings());
const initialHandleTableFetch = result.current.handleTableFetch;
rerender();
expect(result.current.handleTableFetch).toBe(initialHandleTableFetch);
});
it('should handle whitespace-only search values as provided', () => {
const { result } = renderHook(() => useQuerySettings());
const tableFilters = {
pageSize: 10,
pageIndex: 0,
sortBy: [],
filters: [
{ id: 'username', value: ' ' }, // Whitespace only
],
};
act(() => {
result.current.handleTableFetch(tableFilters);
});
expect(result.current.querySettings.search).toBe(' ');
});
it('should detect changes in roles filter', () => {
const { result } = renderHook(() => useQuerySettings());
// First set some roles
act(() => {
result.current.handleTableFetch({
pageSize: 10,
pageIndex: 0,
sortBy: [],
filters: [{ id: 'roles', value: ['admin'] }],
});
});
const settingsAfterFirstUpdate = result.current.querySettings;
// Then change roles
act(() => {
result.current.handleTableFetch({
pageSize: 10,
pageIndex: 0,
sortBy: [],
filters: [{ id: 'roles', value: ['editor'] }],
});
});
expect(result.current.querySettings).not.toBe(settingsAfterFirstUpdate);
expect(result.current.querySettings.roles).toBe('editor');
});
it('should detect changes in search filter', () => {
const { result } = renderHook(() => useQuerySettings());
// First set a search term
act(() => {
result.current.handleTableFetch({
pageSize: 10,
pageIndex: 0,
sortBy: [],
filters: [{ id: 'username', value: 'john' }],
});
});
const settingsAfterFirstUpdate = result.current.querySettings;
// Then change search term
act(() => {
result.current.handleTableFetch({
pageSize: 10,
pageIndex: 0,
sortBy: [],
filters: [{ id: 'username', value: 'jane' }],
});
});
expect(result.current.querySettings).not.toBe(settingsAfterFirstUpdate);
expect(result.current.querySettings.search).toBe('jane');
});
it('should detect changes in ordering', () => {
const { result } = renderHook(() => useQuerySettings());
// First set ordering
act(() => {
result.current.handleTableFetch({
pageSize: 10,
pageIndex: 0,
sortBy: [{ id: 'username', desc: false }],
filters: [],
});
});
const settingsAfterFirstUpdate = result.current.querySettings;
// Then change ordering
act(() => {
result.current.handleTableFetch({
pageSize: 10,
pageIndex: 0,
sortBy: [{ id: 'email', desc: true }],
filters: [],
});
});
expect(result.current.querySettings).not.toBe(settingsAfterFirstUpdate);
expect(result.current.querySettings.sortBy).toBe('email');
expect(result.current.querySettings.order).toBe('desc');
});
it('should detect changes in pageSize', () => {
const { result } = renderHook(() => useQuerySettings());
const initialSettings = result.current.querySettings;
act(() => {
result.current.handleTableFetch({
pageSize: 50,
pageIndex: 0,
sortBy: [],
filters: [],
});
});
expect(result.current.querySettings).not.toBe(initialSettings);
expect(result.current.querySettings.pageSize).toBe(50);
});
it('should detect changes in pageIndex', () => {
const { result } = renderHook(() => useQuerySettings());
const initialSettings = result.current.querySettings;
act(() => {
result.current.handleTableFetch({
pageSize: 10,
pageIndex: 5,
sortBy: [],
filters: [],
});
});
expect(result.current.querySettings).not.toBe(initialSettings);
expect(result.current.querySettings.pageIndex).toBe(5);
});
});

View File

@@ -0,0 +1,88 @@
import { useCallback, useState } from 'react';
import { QuerySettings } from '@src/authz-module/data/api';
interface DataTableFilters {
pageSize: number;
pageIndex: number;
sortBy: Array<{ id: string; desc: boolean }>;
filters: Array<{ id: string; value: any }>;
}
interface UseQuerySettingsReturn {
querySettings: QuerySettings;
handleTableFetch: (tableFilters: DataTableFilters) => void;
}
enum SortOrderKeys {
ASC = 'asc',
DESC = 'desc',
}
/**
* Custom hook to manage query settings for table data fetching
* Converts DataTable filter/sort/pagination settings to API query parameters
* and manages URL synchronization
*
* @param initialQuerySettings - Initial query settings
* @returns Object containing querySettings and handleTableFetch function
*/
export const useQuerySettings = (
initialQuerySettings: QuerySettings = {
roles: null,
search: null,
pageSize: 10,
pageIndex: 0,
order: null,
sortBy: null,
},
): UseQuerySettingsReturn => {
const [querySettings, setQuerySettings] = useState<QuerySettings>(initialQuerySettings);
const handleTableFetch = useCallback((tableFilters: DataTableFilters) => {
setQuerySettings((prevSettings) => {
// Extract filters
const rolesFilter = tableFilters.filters.find((filter) => filter.id === 'roles')?.value?.join(',') ?? '';
const searchFilter = tableFilters.filters.find((filter) => filter.id === 'username')?.value ?? '';
// Extract pagination
const { pageSize = 10, pageIndex = 0 } = tableFilters;
// Extract and convert sorting
let sortByOption = '';
let sortByOrder = '';
if (tableFilters.sortBy.length) {
sortByOption = tableFilters.sortBy[0].id.replace(/([A-Z])/g, '_$1').toLowerCase();
sortByOrder = tableFilters.sortBy[0].desc ? SortOrderKeys.DESC : SortOrderKeys.ASC;
}
const newQuerySettings: QuerySettings = {
roles: rolesFilter || null,
search: searchFilter || null,
sortBy: sortByOption || null,
order: sortByOrder || null,
pageSize,
pageIndex,
};
const hasChanged = (
prevSettings.roles !== newQuerySettings.roles
|| prevSettings.search !== newQuerySettings.search
|| prevSettings.pageSize !== newQuerySettings.pageSize
|| prevSettings.pageIndex !== newQuerySettings.pageIndex
|| prevSettings.sortBy !== newQuerySettings.sortBy
|| prevSettings.order !== newQuerySettings.order
);
if (!hasChanged) {
return prevSettings; // No change, prevent unnecessary update
}
return newQuerySettings;
});
}, []);
return {
querySettings,
handleTableFetch,
};
};

View File

@@ -2,8 +2,8 @@ import { screen } from '@testing-library/react';
import userEvent from '@testing-library/user-event';
import { renderWrapper } from '@src/setupTest';
import { useTeamMembers } from '@src/authz-module/data/hooks';
import TeamTable from './TeamTable';
import { useLibraryAuthZ } from '../context';
import { useLibraryAuthZ } from '@src/authz-module/libraries-manager/context';
import TeamTable from './index';
const mockNavigate = jest.fn();
jest.mock('react-router-dom', () => ({
@@ -15,23 +15,26 @@ jest.mock('@src/authz-module/data/hooks', () => ({
useTeamMembers: jest.fn(),
}));
jest.mock('../context', () => ({
jest.mock('@src/authz-module/libraries-manager/context', () => ({
useLibraryAuthZ: jest.fn(),
}));
describe('TeamTable', () => {
const mockTeamMembers = [
{
email: 'alice@example.com',
roles: ['admin', 'editor'],
username: 'alice',
},
{
email: 'bob@example.com',
roles: ['viewer'],
username: 'bob',
},
];
const mockTeamMembers = {
count: 2,
results: [
{
email: 'alice@example.com',
roles: ['admin', 'editor'],
username: 'alice',
},
{
email: 'bob@example.com',
roles: ['viewer'],
username: 'bob',
},
],
};
const mockAuthZ = {
libraryId: 'lib:123',

View File

@@ -1,12 +1,19 @@
import { useEffect, useMemo } from 'react';
import { useNavigate } from 'react-router-dom';
import debounce from 'lodash.debounce';
import { useIntl } from '@edx/frontend-platform/i18n';
import {
DataTable, Button, Chip, Skeleton,
TextFilter,
CheckboxFilter,
TableFooter,
} from '@openedx/paragon';
import { Edit } from '@openedx/paragon/icons';
import { TableCellValue, TeamMember } from '@src/types';
import { useTeamMembers } from '@src/authz-module/data/hooks';
import { useLibraryAuthZ } from '../context';
import { useLibraryAuthZ } from '@src/authz-module/libraries-manager/context';
import { useQuerySettings } from './hooks/useQuerySettings';
import TableControlBar from './components/TableControlBar';
import messages from './messages';
const SKELETON_ROWS = Array.from({ length: 10 }).map(() => ({
@@ -16,6 +23,8 @@ const SKELETON_ROWS = Array.from({ length: 10 }).map(() => ({
roles: [],
}));
const DEFAULT_PAGE_SIZE = 10;
type CellProps = TableCellValue<TeamMember>;
const EmailCell = ({ row }: CellProps) => (row.original?.username === SKELETON_ROWS[0].username ? (
@@ -49,20 +58,47 @@ const TeamTable = () => {
libraryId, canManageTeam, username, roles,
} = useLibraryAuthZ();
const roleLabels = roles.reduce((acc, role) => ({ ...acc, [role.role]: role.name }), {} as Record<string, string>);
const { querySettings, handleTableFetch } = useQuerySettings();
// TODO: Display error in the notification system
const {
data: teamMembers, isLoading, isError,
} = useTeamMembers(libraryId);
} = useTeamMembers(libraryId, querySettings);
const rows = isError ? [] : (teamMembers || SKELETON_ROWS);
const rows = isError ? [] : (teamMembers?.results || SKELETON_ROWS);
const pageCount = teamMembers?.count ? Math.ceil(teamMembers.count / DEFAULT_PAGE_SIZE) : 1;
const navigate = useNavigate();
const adaptedFilterChoices = useMemo(
() => roles.map((role) => ({
name: role.name,
number: role.userCount,
value: role.role,
})),
[roles],
);
const fetchData = useMemo(() => debounce(handleTableFetch, 500), [handleTableFetch]);
useEffect(() => () => fetchData.cancel(), [fetchData]);
return (
<DataTable
isFilterable
isPaginated
isSortable
manualFilters
manualPagination
manualSortBy
defaultColumnValues={{ Filter: TextFilter }}
numBreakoutFilters={3}
fetchData={fetchData}
data={rows}
itemCount={rows?.length}
itemCount={teamMembers?.count || 0}
pageCount={pageCount}
initialState={{ pageSize: DEFAULT_PAGE_SIZE }}
additionalColumns={[
{
id: 'action',
@@ -81,20 +117,20 @@ const TeamTable = () => {
) : null),
},
]}
initialState={{
pageSize: 10,
}}
columns={
[
{
Header: intl.formatMessage(messages['library.authz.team.table.username']),
accessor: 'username',
Cell: NameCell,
disableSortBy: true,
},
{
Header: intl.formatMessage(messages['library.authz.team.table.email']),
accessor: 'email',
Cell: EmailCell,
disableFilters: true,
disableSortBy: true,
},
{
Header: intl.formatMessage(messages['library.authz.team.table.roles']),
@@ -107,10 +143,18 @@ const TeamTable = () => {
<Chip key={`${row.original.username}-role-${role}`}>{roleLabels[role]}</Chip>
))
)),
Filter: CheckboxFilter,
filter: 'includesValue',
filterChoices: Object.values(adaptedFilterChoices),
disableSortBy: true,
},
]
}
/>
>
<TableControlBar />
<DataTable.Table />
<TableFooter />
</DataTable>
);
};

View File

@@ -0,0 +1,56 @@
import { defineMessages } from '@edx/frontend-platform/i18n';
const messages = defineMessages({
'library.authz.team.table.username': {
id: 'library.authz.team.table.username',
defaultMessage: 'Username',
description: 'Libraries team management table username column header',
},
'library.authz.team.table.username.current': {
id: 'library.authz.team.table.username.current',
defaultMessage: ' (Me)',
description: 'Libraries team management table indicative of current user',
},
'library.authz.team.table.email': {
id: 'library.team.table.email',
defaultMessage: 'Email',
description: 'Libraries team management table email column header',
},
'library.authz.team.table.roles': {
id: 'library.authz.team.table.roles',
defaultMessage: 'Roles',
description: 'Libraries team management table roles column header',
},
'library.authz.team.table.action': {
id: 'library.authz.team.table.action',
defaultMessage: 'Action',
description: 'Libraries team management table action column header',
},
'authz.libraries.team.table.edit.action': {
id: 'authz.libraries.team.table.edit.action',
defaultMessage: 'Edit',
description: 'Edit action',
},
'authz.libraries.team.table.search': {
id: 'authz.libraries.team.table.search',
defaultMessage: 'Search by {firstField} or {secondField}',
description: 'Search placeholder for two specific fields',
},
'authz.libraries.team.table.sort.name-a-z': {
id: 'authz.libraries.team.table.sort.name-a-z',
defaultMessage: 'Name A-Z',
description: 'Sort by name A-Z',
},
'authz.libraries.team.table.sort.name-z-a': {
id: 'authz.libraries.team.table.sort.name-z-a',
defaultMessage: 'Name Z-A',
description: 'Sort by name Z-A',
},
'authz.libraries.team.table.clearFilters': {
id: 'authz.libraries.team.table.clearFilters',
defaultMessage: 'Clear filters',
description: 'Button to clear all active filters in the table',
},
});
export default messages;

View File

@@ -1,41 +1,16 @@
import { defineMessages } from '@edx/frontend-platform/i18n';
const messages = defineMessages({
'library.authz.team.table.username': {
id: 'library.authz.team.table.username',
defaultMessage: 'Username',
description: 'Libraries team management table username column header',
},
'library.authz.team.table.username.current': {
id: 'library.authz.team.table.username.current',
defaultMessage: ' (Me)',
description: 'Libraries team management table indicative of current user',
},
'library.authz.team.table.email': {
id: 'library.team.table.email',
defaultMessage: 'Email',
description: 'Libraries team management table email column header',
},
'library.authz.team.table.roles': {
id: 'library.authz.team.table.roles',
defaultMessage: 'Roles',
description: 'Libraries team management table roles column header',
},
'library.authz.team.table.action': {
id: 'library.authz.team.table.action',
defaultMessage: 'Action',
description: 'Libraries team management table action column header',
},
'authz.libraries.team.table.edit.action': {
id: 'authz.libraries.team.table.edit.action',
defaultMessage: 'Edit',
description: 'Edit action',
},
'libraries.authz.manage.assign.new.role.title': {
id: 'libraries.authz.manage.assign.new.role.title',
defaultMessage: 'Add New Role',
description: 'Libraries AuthZ assign a new role to a user button title',
},
'library.authz.manage.role.select.label': {
id: 'library.authz.role.select.label',
defaultMessage: 'Roles',
description: 'Libraries team management label for roles select',
},
'libraries.authz.manage.cancel.button': {
id: 'libraries.authz.manage.cancel.button',
defaultMessage: 'Cancel',

View File

@@ -1,4 +1,5 @@
import { screen } from '@testing-library/react';
import { Component, ReactNode } from 'react';
import { screen, renderHook } from '@testing-library/react';
import { useParams } from 'react-router-dom';
import { useValidateUserPermissions } from '@src/data/hooks';
import { renderWrapper } from '@src/setupTest';
@@ -18,16 +19,31 @@ jest.mock('@src/authz-module/data/hooks', () => ({
data: [
{
role: 'library_author',
permissions: [
'view_library_team',
'edit_library',
],
permissions: ['view_library_team', 'edit_library'],
user_count: 12,
},
],
}),
}));
class ErrorBoundary extends Component<{ children: ReactNode }, { hasError: boolean; error?: Error }> {
constructor(props: { children: ReactNode }) {
super(props);
this.state = { hasError: false };
}
static getDerivedStateFromError(error: Error) {
return { hasError: true, error };
}
render() {
if (this.state.hasError && this.state.error) {
throw this.state.error;
}
return this.props.children;
}
}
const TestComponent = () => {
const context = useLibraryAuthZ();
return (
@@ -36,7 +52,9 @@ const TestComponent = () => {
<div data-testid="libraryId">{context.libraryId}</div>
<div data-testid="canManageTeam">{context.canManageTeam ? 'true' : 'false'}</div>
<div data-testid="roles">{Array.isArray(context.roles) ? context.roles.length : 'undefined'}</div>
<div data-testid="permissions">{Array.isArray(context.permissions) ? context.permissions.length : 'undefined'}</div>
<div data-testid="permissions">
{Array.isArray(context.permissions) ? context.permissions.length : 'undefined'}
</div>
<div data-testid="resources">{Array.isArray(context.resources) ? context.resources.length : 'undefined'}</div>
</div>
);
@@ -137,21 +155,18 @@ describe('LibraryAuthZProvider', () => {
expect(() => {
renderWrapper(
<LibraryAuthZProvider>
<TestComponent />
</LibraryAuthZProvider>,
<ErrorBoundary>
<LibraryAuthZProvider>
<TestComponent />
</LibraryAuthZProvider>
</ErrorBoundary>,
);
}).toThrow('MissingLibrary');
});
it('throws error when useLibraryAuthZ is used outside provider', () => {
const BrokenComponent = () => {
useLibraryAuthZ();
return null;
};
expect(() => {
renderWrapper(<BrokenComponent />);
renderHook(() => useLibraryAuthZ());
}).toThrow('useLibraryAuthZ must be used within an LibraryAuthZProvider');
});
});

View File

@@ -13,6 +13,7 @@ export interface TeamMember {
fullName: string;
email: string;
roles: string[];
createdAt: string;
}
export interface LibraryMetadata {