diff --git a/package-lock.json b/package-lock.json index 32b73312..d49ddd90 100644 --- a/package-lock.json +++ b/package-lock.json @@ -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": { diff --git a/package.json b/package.json index e0e23aac..944d7bed 100644 --- a/package.json +++ b/package.json @@ -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" diff --git a/src/authz-module/components/AuthZTitle.test.tsx b/src/authz-module/components/AuthZTitle.test.tsx index 3f801fa4..f7cc9c1e 100644 --- a/src/authz-module/components/AuthZTitle.test.tsx +++ b/src/authz-module/components/AuthZTitle.test.tsx @@ -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(); - 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(); }); }); diff --git a/src/authz-module/data/api.ts b/src/authz-module/data/api.ts index d22b2140..0c59453b 100644 --- a/src/authz-module/data/api.ts +++ b/src/authz-module/data/api.ts @@ -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 => { - 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 => { + 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 ( diff --git a/src/authz-module/data/hooks.test.tsx b/src/authz-module/data/hooks.test.tsx index 327eb9c6..ee2643f8 100644 --- a/src/authz-module/data/hooks.test.tsx +++ b/src/authz-module/data/hooks.test.tsx @@ -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(), }); diff --git a/src/authz-module/data/hooks.ts b/src/authz-module/data/hooks.ts index 195b5149..e4ced661 100644 --- a/src/authz-module/data/hooks.ts +++ b/src/authz-module/data/hooks.ts @@ -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({ - queryKey: authzQueryKeys.teamMembers(object), - queryFn: () => getTeamMembers(object), +export const useTeamMembers = (scope: string, querySettings: QuerySettings) => useQuery({ + 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) }); }, }); }; diff --git a/src/authz-module/libraries-manager/LibrariesUserManager.test.tsx b/src/authz-module/libraries-manager/LibrariesUserManager.test.tsx index bbd08530..80c679e4 100644 --- a/src/authz-module/libraries-manager/LibrariesUserManager.test.tsx +++ b/src/authz-module/libraries-manager/LibrariesUserManager.test.tsx @@ -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'], + }, + ], + }, }); }); diff --git a/src/authz-module/libraries-manager/LibrariesUserManager.tsx b/src/authz-module/libraries-manager/LibrariesUserManager.tsx index 6883e818..83ae9b13 100644 --- a/src/authz-module/libraries-manager/LibrariesUserManager.tsx +++ b/src/authz-module/libraries-manager/LibrariesUserManager.tsx @@ -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 = () => { : []} > - {isLoading ? : null} + {isLoadingTeamMember ? : null} {userRoles && userRoles.map(role => ( = ({ - {intl.formatMessage(messages['library.authz.team.table.roles'])} + {intl.formatMessage(messages['library.authz.manage.role.select.label'])} {roleOptions.map((role) => )} diff --git a/src/authz-module/libraries-manager/components/TeamTable/components/MultipleChoiceFilter.test.tsx b/src/authz-module/libraries-manager/components/TeamTable/components/MultipleChoiceFilter.test.tsx new file mode 100644 index 00000000..c0c9e30a --- /dev/null +++ b/src/authz-module/libraries-manager/components/TeamTable/components/MultipleChoiceFilter.test.tsx @@ -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(); + + expect(screen.getByRole('button')).toBeInTheDocument(); + expect(screen.getByText('Test Filter')).toBeInTheDocument(); + }); + + it('should render FilterList icon', () => { + render(); + + 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(); + + 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(); + + 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(); + + 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(); + + 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(); + + await user.click(screen.getByRole('button')); + + const checkbox1 = screen.getByLabelText('Option 1'); + await user.click(checkbox1); + + expect(mockSetFilter).toHaveBeenCalledWith(['option2', 'option1']); + }); +}); diff --git a/src/authz-module/libraries-manager/components/TeamTable/components/MultipleChoiceFilter.tsx b/src/authz-module/libraries-manager/components/TeamTable/components/MultipleChoiceFilter.tsx new file mode 100644 index 00000000..5c319310 --- /dev/null +++ b/src/authz-module/libraries-manager/components/TeamTable/components/MultipleChoiceFilter.tsx @@ -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 = ({ + 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 ( + + + + + {Header} + + + + + + {filterChoices.map(({ + name, number, value, + }) => ( + changeCheckbox(value)} + aria-label={name} + > + + {`${name} (${number || 0})`} + + + ))} + + + + ); +}; + +export default MultipleChoiceFilter; diff --git a/src/authz-module/libraries-manager/components/TeamTable/components/SearchFilter.test.tsx b/src/authz-module/libraries-manager/components/TeamTable/components/SearchFilter.test.tsx new file mode 100644 index 00000000..f216f649 --- /dev/null +++ b/src/authz-module/libraries-manager/components/TeamTable/components/SearchFilter.test.tsx @@ -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 ( + + ); + }; + + it('should render search input with correct placeholder', () => { + render(); + + const input = screen.getByPlaceholderText('Search placeholder'); + expect(input).toBeInTheDocument(); + expect(input).toHaveAttribute('type', 'text'); + }); + + it('should display empty value when filterValue is undefined', () => { + render(); + + const input = screen.getByPlaceholderText('Search placeholder'); + expect(input).toHaveValue(''); + }); + + it('should display filterValue if provided', () => { + render(); + + 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(); + + 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(); + + 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(); + + 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(); + + const input = screen.getByPlaceholderText(customPlaceholder); + expect(input).toBeInTheDocument(); + expect(input).toHaveAttribute('placeholder', customPlaceholder); + }); +}); diff --git a/src/authz-module/libraries-manager/components/TeamTable/components/SearchFilter.tsx b/src/authz-module/libraries-manager/components/TeamTable/components/SearchFilter.tsx new file mode 100644 index 00000000..85bd6698 --- /dev/null +++ b/src/authz-module/libraries-manager/components/TeamTable/components/SearchFilter.tsx @@ -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 = ({ + filterValue, setFilter, placeholder, +}) => ( + } + value={filterValue || ''} + type="text" + onChange={e => { + setFilter(e.target.value || undefined); // Set undefined to remove the filter entirely + }} + placeholder={placeholder} + /> +); + +export default SearchFilter; diff --git a/src/authz-module/libraries-manager/components/TeamTable/components/SortDropdown.test.tsx b/src/authz-module/libraries-manager/components/TeamTable/components/SortDropdown.test.tsx new file mode 100644 index 00000000..298a2ed0 --- /dev/null +++ b/src/authz-module/libraries-manager/components/TeamTable/components/SortDropdown.test.tsx @@ -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( + + + , + ); + }; + + 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(); + }); +}); diff --git a/src/authz-module/libraries-manager/components/TeamTable/components/SortDropdown.tsx b/src/authz-module/libraries-manager/components/TeamTable/components/SortDropdown.tsx new file mode 100644 index 00000000..7f2bef04 --- /dev/null +++ b/src/authz-module/libraries-manager/components/TeamTable/components/SortDropdown.tsx @@ -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; +} + +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); + const [sortOrder, setSortOrder] = useState(undefined); + + const SORT_LABELS: Record = 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 ( + + + + + {currentSortLabel} + + + + + {sortOptions.map(({ key, label }) => ( + + {label} + + ))} + + + ); +}; + +export default SortDropdown; diff --git a/src/authz-module/libraries-manager/components/TeamTable/components/TableControlBar.test.tsx b/src/authz-module/libraries-manager/components/TeamTable/components/TableControlBar.test.tsx new file mode 100644 index 00000000..cde0dc62 --- /dev/null +++ b/src/authz-module/libraries-manager/components/TeamTable/components/TableControlBar.test.tsx @@ -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 +
+ Multiple Choice Filter +
+ ); + MockMultipleChoiceFilter.displayName = 'MultipleChoiceFilter'; + return MockMultipleChoiceFilter; +}); + +jest.mock('./SortDropdown', () => { + const MockSortDropdown = () => ( +
+ Sort Dropdown +
+ ); + MockSortDropdown.displayName = 'SortDropdown'; + return MockSortDropdown; +}); + +jest.mock('./SearchFilter', () => { + // eslint-disable-next-line react/prop-types + const MockSearchFilter = (props) => ( +
+ props.setFilter(e.target.value)} + data-testid="search-input" + /> +
+ ); + 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( + + + , + ) + ); + + 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 = () =>
Custom Filter
; + + 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(); + }); +}); diff --git a/src/authz-module/libraries-manager/components/TeamTable/components/TableControlBar.tsx b/src/authz-module/libraries-manager/components/TeamTable/components/TableControlBar.tsx new file mode 100644 index 00000000..0d02a5f6 --- /dev/null +++ b/src/authz-module/libraries-manager/components/TeamTable/components/TableControlBar.tsx @@ -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); + + 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 ( + + + {availableFilters.map((column) => { + if (column.Filter === CheckboxFilter) { + return ; + } + + if (column.Filter === TextFilter) { + return ( + + ); + } + + return null; + })} + + + + {state.filters.length > 0 && ( + + )} + + + + ); +}; + +export default TableControlBar; diff --git a/src/authz-module/libraries-manager/components/TeamTable/hooks/useQuerySettings.test.ts b/src/authz-module/libraries-manager/components/TeamTable/hooks/useQuerySettings.test.ts new file mode 100644 index 00000000..6a9dc765 --- /dev/null +++ b/src/authz-module/libraries-manager/components/TeamTable/hooks/useQuerySettings.test.ts @@ -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); + }); +}); diff --git a/src/authz-module/libraries-manager/components/TeamTable/hooks/useQuerySettings.ts b/src/authz-module/libraries-manager/components/TeamTable/hooks/useQuerySettings.ts new file mode 100644 index 00000000..3c7d879f --- /dev/null +++ b/src/authz-module/libraries-manager/components/TeamTable/hooks/useQuerySettings.ts @@ -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(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, + }; +}; diff --git a/src/authz-module/libraries-manager/components/TeamTable.test.tsx b/src/authz-module/libraries-manager/components/TeamTable/index.test.tsx similarity index 89% rename from src/authz-module/libraries-manager/components/TeamTable.test.tsx rename to src/authz-module/libraries-manager/components/TeamTable/index.test.tsx index 1721e332..0278d564 100644 --- a/src/authz-module/libraries-manager/components/TeamTable.test.tsx +++ b/src/authz-module/libraries-manager/components/TeamTable/index.test.tsx @@ -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', diff --git a/src/authz-module/libraries-manager/components/TeamTable.tsx b/src/authz-module/libraries-manager/components/TeamTable/index.tsx similarity index 66% rename from src/authz-module/libraries-manager/components/TeamTable.tsx rename to src/authz-module/libraries-manager/components/TeamTable/index.tsx index 6ebf3fbc..8ee94633 100644 --- a/src/authz-module/libraries-manager/components/TeamTable.tsx +++ b/src/authz-module/libraries-manager/components/TeamTable/index.tsx @@ -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; 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); + + 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 ( { ) : 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 = () => { {roleLabels[role]} )) )), + Filter: CheckboxFilter, + filter: 'includesValue', + filterChoices: Object.values(adaptedFilterChoices), + disableSortBy: true, }, ] } - /> + > + + + + ); }; diff --git a/src/authz-module/libraries-manager/components/TeamTable/messages.ts b/src/authz-module/libraries-manager/components/TeamTable/messages.ts new file mode 100644 index 00000000..1bc30a2a --- /dev/null +++ b/src/authz-module/libraries-manager/components/TeamTable/messages.ts @@ -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; diff --git a/src/authz-module/libraries-manager/components/messages.ts b/src/authz-module/libraries-manager/components/messages.ts index d36537e7..d73643a0 100644 --- a/src/authz-module/libraries-manager/components/messages.ts +++ b/src/authz-module/libraries-manager/components/messages.ts @@ -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', diff --git a/src/authz-module/libraries-manager/context.test.tsx b/src/authz-module/libraries-manager/context.test.tsx index 28f039d9..68199c9a 100644 --- a/src/authz-module/libraries-manager/context.test.tsx +++ b/src/authz-module/libraries-manager/context.test.tsx @@ -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 = () => {
{context.libraryId}
{context.canManageTeam ? 'true' : 'false'}
{Array.isArray(context.roles) ? context.roles.length : 'undefined'}
-
{Array.isArray(context.permissions) ? context.permissions.length : 'undefined'}
+
+ {Array.isArray(context.permissions) ? context.permissions.length : 'undefined'} +
{Array.isArray(context.resources) ? context.resources.length : 'undefined'}
); @@ -137,21 +155,18 @@ describe('LibraryAuthZProvider', () => { expect(() => { renderWrapper( - - - , + + + + + , ); }).toThrow('MissingLibrary'); }); it('throws error when useLibraryAuthZ is used outside provider', () => { - const BrokenComponent = () => { - useLibraryAuthZ(); - return null; - }; - expect(() => { - renderWrapper(); + renderHook(() => useLibraryAuthZ()); }).toThrow('useLibraryAuthZ must be used within an LibraryAuthZProvider'); }); }); diff --git a/src/types.ts b/src/types.ts index 3a7ebdd9..6b5e86b8 100644 --- a/src/types.ts +++ b/src/types.ts @@ -13,6 +13,7 @@ export interface TeamMember { fullName: string; email: string; roles: string[]; + createdAt: string; } export interface LibraryMetadata {