From 1e3749ab38ff300fa36625625c01b8bfd75a8c41 Mon Sep 17 00:00:00 2001 From: Brayan Ceron Date: Thu, 2 Oct 2025 10:13:40 -0500 Subject: [PATCH 01/15] feat: add filters and sorting functionality to the team table --- .../components/MultipleChoiceFilter.tsx | 65 +++++++++++ .../components/SearchFilter.tsx | 29 +++++ .../components/SortDropdown.tsx | 107 ++++++++++++++++++ .../components/TableControlBar.tsx | 59 ++++++++++ .../components/TeamTable.tsx | 51 ++++++++- src/types.ts | 1 + 6 files changed, 311 insertions(+), 1 deletion(-) create mode 100644 src/authz-module/libraries-manager/components/MultipleChoiceFilter.tsx create mode 100644 src/authz-module/libraries-manager/components/SearchFilter.tsx create mode 100644 src/authz-module/libraries-manager/components/SortDropdown.tsx create mode 100644 src/authz-module/libraries-manager/components/TableControlBar.tsx diff --git a/src/authz-module/libraries-manager/components/MultipleChoiceFilter.tsx b/src/authz-module/libraries-manager/components/MultipleChoiceFilter.tsx new file mode 100644 index 00000000..741a6840 --- /dev/null +++ b/src/authz-module/libraries-manager/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/SearchFilter.tsx b/src/authz-module/libraries-manager/components/SearchFilter.tsx new file mode 100644 index 00000000..43a6d146 --- /dev/null +++ b/src/authz-module/libraries-manager/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/SortDropdown.tsx b/src/authz-module/libraries-manager/components/SortDropdown.tsx new file mode 100644 index 00000000..fed1cf0f --- /dev/null +++ b/src/authz-module/libraries-manager/components/SortDropdown.tsx @@ -0,0 +1,107 @@ +import { + useContext, useState, useMemo, useCallback, + useEffect, + FC, +} from 'react'; +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 }, + newest: { id: 'createdAt', desc: true }, + oldest: { id: 'createdAt', desc: false }, +}; + +const SORT_LABELS: Record = { + 'name-a-z': 'Name A-Z', + 'name-z-a': 'Name Z-A', + newest: 'Newest', + oldest: 'Oldest', +}; + +const SortDropdown: FC = () => { + const { toggleSortBy, state } = useContext(DataTableContext); + const [sortOrder, setSortOrder] = useState(undefined); + + // Get current sort state from DataTable context + 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]; + }, [state?.sortBy]); + + // Update local state when external sort changes + useEffect(() => { + setSortOrder(currentSort); + }, [currentSort]); + + const handleChangeSortBy = useCallback((newSortOrder: string) => { + if (!SORT_BY_OPTIONS[newSortOrder]) { + console.warn(`Invalid sort option: ${newSortOrder}`); + return; + } + + 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], + })), + [], + ); + + 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/TableControlBar.tsx b/src/authz-module/libraries-manager/components/TableControlBar.tsx new file mode 100644 index 00000000..cf3d01f2 --- /dev/null +++ b/src/authz-module/libraries-manager/components/TableControlBar.tsx @@ -0,0 +1,59 @@ +import { useContext } from 'react'; +import { + breakpoints, + DataTable, DataTableContext, + CheckboxFilter, + Stack, + TextFilter, + useWindowSize, +} from '@openedx/paragon'; + +import MultipleChoiceFilter from './MultipleChoiceFilter'; +import SortDropdown from './SortDropdown'; +import SearchFilter from './SearchFilter'; + +const TableControlBar = () => { + const { + columns, + } = useContext(DataTableContext); + + const availableFilters = columns.filter((column) => column.canFilter); + + const columnTextFilterHeaders = columns + .filter((column) => column.Filter === TextFilter) + .map((column) => column.Header); + + const isSmallScreen = useWindowSize().width! < breakpoints.medium.minWidth!; + + return ( +
+ + + {availableFilters.map((column) => { + if (column.Filter === CheckboxFilter) { + return ; + } + + if (column.Filter === TextFilter) { + return ( + header).join(' or ')}`} + /> + ); + } + + return null; + })} + + + + + + +
+ ); +}; + +export default TableControlBar; diff --git a/src/authz-module/libraries-manager/components/TeamTable.tsx b/src/authz-module/libraries-manager/components/TeamTable.tsx index 6ebf3fbc..33c07d67 100644 --- a/src/authz-module/libraries-manager/components/TeamTable.tsx +++ b/src/authz-module/libraries-manager/components/TeamTable.tsx @@ -2,12 +2,15 @@ import { useNavigate } from 'react-router-dom'; import { useIntl } from '@edx/frontend-platform/i18n'; import { DataTable, Button, Chip, Skeleton, + TextFilter, + CheckboxFilter, } 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 messages from './messages'; +import TableControlBar from './TableControlBar'; const SKELETON_ROWS = Array.from({ length: 10 }).map(() => ({ username: 'skeleton', @@ -58,9 +61,38 @@ const TeamTable = () => { const navigate = useNavigate(); + const reducedChoices = teamMembers?.reduce((acc, currentObject) => { + const { roles } = currentObject; + roles.forEach((role) => { + if (role in acc) { + acc[role].number += 1; + } else { + acc[role] = { + name: role, + number: 1, + value: role, + }; + } + }); + return acc; + }, {}) ?? {}; + + const handleFetchData = (querySettings) => { + console.log('Filters', querySettings.filters); + console.log('Sorting', querySettings.sortBy); + }; + return ( { ]} initialState={{ pageSize: 10, + hiddenColumns: ['createdAt'], }} columns={ [ @@ -90,11 +123,14 @@ const TeamTable = () => { 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,23 @@ const TeamTable = () => { {roleLabels[role]} )) )), + Filter: CheckboxFilter, + filter: 'includesValue', + filterChoices: Object.values(reducedChoices), + disableSortBy: true, + }, + { + accessor: 'createdAt', + Filter: false, + disableFilters: true, + disableSortBy: true, }, ] } - /> + > + + + ); }; 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 { From 85d0eff84bd4149f16e59f05390ae3aec886ee88 Mon Sep 17 00:00:00 2001 From: Brayan Ceron Date: Thu, 2 Oct 2025 10:27:11 -0500 Subject: [PATCH 02/15] feat: add lodash.debounce for improved fetchData performance in TeamTable --- package-lock.json | 2 +- package.json | 1 + src/authz-module/libraries-manager/components/TeamTable.tsx | 3 ++- 3 files changed, 4 insertions(+), 2 deletions(-) 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/libraries-manager/components/TeamTable.tsx b/src/authz-module/libraries-manager/components/TeamTable.tsx index 33c07d67..e0579b42 100644 --- a/src/authz-module/libraries-manager/components/TeamTable.tsx +++ b/src/authz-module/libraries-manager/components/TeamTable.tsx @@ -1,4 +1,5 @@ import { useNavigate } from 'react-router-dom'; +import debounce from 'lodash.debounce'; import { useIntl } from '@edx/frontend-platform/i18n'; import { DataTable, Button, Chip, Skeleton, @@ -92,7 +93,7 @@ const TeamTable = () => { manualPagination isSortable manualSortBy - fetchData={handleFetchData} + fetchData={debounce(handleFetchData, 1000)} data={rows} itemCount={rows?.length} additionalColumns={[ From f7a35d97d55d2b7a09e513d3af927684c947f8ec Mon Sep 17 00:00:00 2001 From: Diana Olarte Date: Tue, 14 Oct 2025 09:55:05 +1100 Subject: [PATCH 03/15] feat: implement query settings management for team members table with filtering and pagination --- src/authz-module/data/api.ts | 27 +++++- src/authz-module/data/hooks.ts | 32 +++++-- .../components/MultipleChoiceFilter.tsx | 2 +- .../components/TeamTable.tsx | 41 ++++----- .../libraries-manager/hooks/index.ts | 1 + .../hooks/useQuerySettings.ts | 84 +++++++++++++++++++ 6 files changed, 153 insertions(+), 34 deletions(-) create mode 100644 src/authz-module/libraries-manager/hooks/index.ts create mode 100644 src/authz-module/libraries-manager/hooks/useQuerySettings.ts diff --git a/src/authz-module/data/api.ts b/src/authz-module/data/api.ts index d22b2140..5bd20ab8 100644 --- a/src/authz-module/data/api.ts +++ b/src/authz-module/data/api.ts @@ -3,6 +3,14 @@ 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; + ordering: string | null; + pageSize: number; + pageIndex: number; +} + export interface GetTeamMembersResponse { members: TeamMember[]; totalCount: number; @@ -24,8 +32,23 @@ 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}`)); +// TODO: replece api path once is created +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.ordering) { + url.searchParams.set('ordering', querySettings.ordering); + } + 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.results); }; diff --git a/src/authz-module/data/hooks.ts b/src/authz-module/data/hooks.ts index 195b5149..dde81f7a 100644 --- a/src/authz-module/data/hooks.ts +++ b/src/authz-module/data/hooks.ts @@ -6,12 +6,21 @@ import { LibraryMetadata, TeamMember } from '@src/types'; import { assignTeamMembersRole, AssignTeamMembersRoleRequest, - getLibrary, getPermissionsByRole, getTeamMembers, PermissionsByRole, + getLibrary, getPermissionsByRole, getTeamMembers, PermissionsByRole, QuerySettings, } from './api'; const authzQueryKeys = { all: [appId, 'authz'] as const, - teamMembers: (object: string) => [...authzQueryKeys.all, 'teamMembers', object] as const, + teamMembers: (object: string, querySettings: QuerySettings) => [ + ...authzQueryKeys.all, + 'teamMembers', + object, + querySettings.roles, + querySettings.search, + querySettings.ordering, + querySettings.pageSize, + querySettings.pageIndex, + ] as const, permissionsByRole: (scope: string) => [...authzQueryKeys.all, 'permissionsByRole', scope] as const, library: (libraryId: string) => [...authzQueryKeys.all, 'library', libraryId] as const, }; @@ -21,17 +30,24 @@ const authzQueryKeys = { * It retrieves the full list of members who have access to the given scope. * * @param object - 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), - staleTime: 1000 * 60 * 30, // refetch after 30 minutes -}); +export const useTeamMembers = (object: string, querySettings: QuerySettings) => { + const queryKey = authzQueryKeys.teamMembers(object, querySettings); + + return useQuery({ + queryKey, + queryFn: () => getTeamMembers(object, querySettings), + staleTime: 1000 * 60 * 5, + enabled: !!object, + refetchOnWindowFocus: false, + }); +}; /** * React Query hook to fetch all the roles for the specific object/scope. diff --git a/src/authz-module/libraries-manager/components/MultipleChoiceFilter.tsx b/src/authz-module/libraries-manager/components/MultipleChoiceFilter.tsx index 741a6840..5c319310 100644 --- a/src/authz-module/libraries-manager/components/MultipleChoiceFilter.tsx +++ b/src/authz-module/libraries-manager/components/MultipleChoiceFilter.tsx @@ -47,7 +47,7 @@ const MultipleChoiceFilter: FC = ({ changeCheckbox(value)} aria-label={name} > diff --git a/src/authz-module/libraries-manager/components/TeamTable.tsx b/src/authz-module/libraries-manager/components/TeamTable.tsx index e0579b42..92800754 100644 --- a/src/authz-module/libraries-manager/components/TeamTable.tsx +++ b/src/authz-module/libraries-manager/components/TeamTable.tsx @@ -9,7 +9,11 @@ import { import { Edit } from '@openedx/paragon/icons'; import { TableCellValue, TeamMember } from '@src/types'; import { useTeamMembers } from '@src/authz-module/data/hooks'; +import { + useMemo, +} from 'react'; import { useLibraryAuthZ } from '../context'; +import { useQuerySettings } from '../hooks'; import messages from './messages'; import TableControlBar from './TableControlBar'; @@ -53,35 +57,26 @@ 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 navigate = useNavigate(); - const reducedChoices = teamMembers?.reduce((acc, currentObject) => { - const { roles } = currentObject; - roles.forEach((role) => { - if (role in acc) { - acc[role].number += 1; - } else { - acc[role] = { - name: role, - number: 1, - value: role, - }; - } - }); - return acc; - }, {}) ?? {}; - - const handleFetchData = (querySettings) => { - console.log('Filters', querySettings.filters); - console.log('Sorting', querySettings.sortBy); - }; + const adaptedFilterChoices = useMemo( + () => roles.map((role) => ({ + name: role.name, + number: role.userCount, + value: role.role, + })), + [roles], + ); return ( { manualPagination isSortable manualSortBy - fetchData={debounce(handleFetchData, 1000)} + fetchData={debounce(handleTableFetch, 1000)} data={rows} itemCount={rows?.length} additionalColumns={[ @@ -146,7 +141,7 @@ const TeamTable = () => { )), Filter: CheckboxFilter, filter: 'includesValue', - filterChoices: Object.values(reducedChoices), + filterChoices: Object.values(adaptedFilterChoices), disableSortBy: true, }, { diff --git a/src/authz-module/libraries-manager/hooks/index.ts b/src/authz-module/libraries-manager/hooks/index.ts new file mode 100644 index 00000000..19d06c8f --- /dev/null +++ b/src/authz-module/libraries-manager/hooks/index.ts @@ -0,0 +1 @@ +export { useQuerySettings } from './useQuerySettings'; diff --git a/src/authz-module/libraries-manager/hooks/useQuerySettings.ts b/src/authz-module/libraries-manager/hooks/useQuerySettings.ts new file mode 100644 index 00000000..a532c3da --- /dev/null +++ b/src/authz-module/libraries-manager/hooks/useQuerySettings.ts @@ -0,0 +1,84 @@ +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; +} + +/** + * 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, + ordering: 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 ordering = ''; + if (tableFilters.sortBy.length) { + const snakeCaseId = tableFilters.sortBy[0].id.replace(/([A-Z])/g, '_$1').toLowerCase(); + + if (tableFilters.sortBy[0].desc) { + ordering = `-${snakeCaseId}`; + } else { + ordering = snakeCaseId; + } + } + + const newQuerySettings: QuerySettings = { + roles: rolesFilter || null, + search: searchFilter || null, + ordering: ordering || null, + pageSize, + pageIndex, + }; + + const hasChanged = ( + prevSettings.roles !== newQuerySettings.roles + || prevSettings.search !== newQuerySettings.search + || prevSettings.pageSize !== newQuerySettings.pageSize + || prevSettings.pageIndex !== newQuerySettings.pageIndex + || prevSettings.ordering !== newQuerySettings.ordering + ); + + if (!hasChanged) { + return prevSettings; // No change, prevent unnecessary update + } + + return newQuerySettings; + }); + }, []); + + return { + querySettings, + handleTableFetch, + }; +}; From 0fcaafe3a3fffc017516a2f8a472888a8f5de84f Mon Sep 17 00:00:00 2001 From: Brayan Ceron Date: Fri, 3 Oct 2025 09:25:44 -0500 Subject: [PATCH 04/15] fix: increase staleTime for useTeamMembers hook to 30 minutes --- src/authz-module/data/hooks.ts | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/src/authz-module/data/hooks.ts b/src/authz-module/data/hooks.ts index dde81f7a..54150bf7 100644 --- a/src/authz-module/data/hooks.ts +++ b/src/authz-module/data/hooks.ts @@ -43,8 +43,7 @@ export const useTeamMembers = (object: string, querySettings: QuerySettings) => return useQuery({ queryKey, queryFn: () => getTeamMembers(object, querySettings), - staleTime: 1000 * 60 * 5, - enabled: !!object, + staleTime: 1000 * 60 * 30, // refetch after 30 minutes refetchOnWindowFocus: false, }); }; From 1e05f3809e861f26d8ae45b344021aabc6e6df9f Mon Sep 17 00:00:00 2001 From: Brayan Ceron Date: Fri, 3 Oct 2025 10:14:43 -0500 Subject: [PATCH 05/15] refactor: simplify TableControlBar layout and restore Clear filters button functionality --- .../components/TableControlBar.tsx | 67 ++++++++++--------- 1 file changed, 36 insertions(+), 31 deletions(-) diff --git a/src/authz-module/libraries-manager/components/TableControlBar.tsx b/src/authz-module/libraries-manager/components/TableControlBar.tsx index cf3d01f2..5c4df280 100644 --- a/src/authz-module/libraries-manager/components/TableControlBar.tsx +++ b/src/authz-module/libraries-manager/components/TableControlBar.tsx @@ -1,11 +1,10 @@ import { useContext } from 'react'; import { - breakpoints, DataTable, DataTableContext, CheckboxFilter, Stack, TextFilter, - useWindowSize, + Button, } from '@openedx/paragon'; import MultipleChoiceFilter from './MultipleChoiceFilter'; @@ -15,6 +14,8 @@ import SearchFilter from './SearchFilter'; const TableControlBar = () => { const { columns, + setAllFilters, + state, } = useContext(DataTableContext); const availableFilters = columns.filter((column) => column.canFilter); @@ -23,36 +24,40 @@ const TableControlBar = () => { .filter((column) => column.Filter === TextFilter) .map((column) => column.Header); - const isSmallScreen = useWindowSize().width! < breakpoints.medium.minWidth!; - return ( -
- - - {availableFilters.map((column) => { - if (column.Filter === CheckboxFilter) { - return ; - } - - if (column.Filter === TextFilter) { - return ( - header).join(' or ')}`} - /> - ); - } - - return null; - })} - - - - - - -
+ + + {availableFilters.map((column) => { + if (column.Filter === CheckboxFilter) { + return ; + } + + if (column.Filter === TextFilter) { + return ( + header).join(' or ')}`} + /> + ); + } + + return null; + })} + + + + {state.filters.length > 0 && ( + + )} + + + ); }; From a1984eb5b8ed166d48bcd42cc9c04f60be8d9ee1 Mon Sep 17 00:00:00 2001 From: Brayan Ceron Date: Fri, 3 Oct 2025 10:37:36 -0500 Subject: [PATCH 06/15] feat: add internationalization support for sorting and search placeholders --- .../components/SortDropdown.tsx | 33 ++++++++----------- .../components/TableControlBar.tsx | 11 ++++++- .../libraries-manager/components/messages.ts | 25 ++++++++++++++ 3 files changed, 48 insertions(+), 21 deletions(-) diff --git a/src/authz-module/libraries-manager/components/SortDropdown.tsx b/src/authz-module/libraries-manager/components/SortDropdown.tsx index fed1cf0f..ab7c6751 100644 --- a/src/authz-module/libraries-manager/components/SortDropdown.tsx +++ b/src/authz-module/libraries-manager/components/SortDropdown.tsx @@ -3,6 +3,7 @@ import { useEffect, FC, } from 'react'; +import { useIntl } from '@edx/frontend-platform/i18n'; import { DataTableContext, Dropdown, @@ -28,38 +29,33 @@ const SORT_BY_OPTIONS: SortByOptions = { oldest: { id: 'createdAt', desc: false }, }; -const SORT_LABELS: Record = { - 'name-a-z': 'Name A-Z', - 'name-z-a': 'Name Z-A', - newest: 'Newest', - oldest: 'Oldest', -}; - const SortDropdown: FC = () => { + const intl = useIntl(); const { toggleSortBy, state } = useContext(DataTableContext); const [sortOrder, setSortOrder] = useState(undefined); - // Get current sort state from DataTable context + 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' }), + newest: intl.formatMessage({ id: 'authz.libraries.team.table.sort.newest', defaultMessage: 'Newest' }), + oldest: intl.formatMessage({ id: 'authz.libraries.team.table.sort.oldest', defaultMessage: 'Oldest' }), + // eslint-disable-next-line react-hooks/exhaustive-deps + }), []); + 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]; + )?.[0]; // return the key }, [state?.sortBy]); - // Update local state when external sort changes useEffect(() => { setSortOrder(currentSort); }, [currentSort]); const handleChangeSortBy = useCallback((newSortOrder: string) => { - if (!SORT_BY_OPTIONS[newSortOrder]) { - console.warn(`Invalid sort option: ${newSortOrder}`); - return; - } - setSortOrder(newSortOrder); const { id, desc } = SORT_BY_OPTIONS[newSortOrder]; toggleSortBy(id, desc); @@ -71,6 +67,7 @@ const SortDropdown: FC = () => { ...option, label: SORT_LABELS[key], })), + // eslint-disable-next-line react-hooks/exhaustive-deps [], ); @@ -78,10 +75,7 @@ const SortDropdown: FC = () => { return ( - + {currentSortLabel} @@ -94,7 +88,6 @@ const SortDropdown: FC = () => { key={key} active={sortOrder === key} eventKey={key} - aria-label={`Sort by ${label}`} > {label} diff --git a/src/authz-module/libraries-manager/components/TableControlBar.tsx b/src/authz-module/libraries-manager/components/TableControlBar.tsx index 5c4df280..ec895767 100644 --- a/src/authz-module/libraries-manager/components/TableControlBar.tsx +++ b/src/authz-module/libraries-manager/components/TableControlBar.tsx @@ -1,4 +1,5 @@ import { useContext } from 'react'; +import { useIntl } from '@edx/frontend-platform/i18n'; import { DataTable, DataTableContext, CheckboxFilter, @@ -10,8 +11,10 @@ import { import MultipleChoiceFilter from './MultipleChoiceFilter'; import SortDropdown from './SortDropdown'; import SearchFilter from './SearchFilter'; +import messages from './messages'; const TableControlBar = () => { + const intl = useIntl(); const { columns, setAllFilters, @@ -24,6 +27,11 @@ const TableControlBar = () => { .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 ( @@ -35,9 +43,10 @@ const TableControlBar = () => { if (column.Filter === TextFilter) { return ( header).join(' or ')}`} + placeholder={getSearchPlaceholder()} /> ); } diff --git a/src/authz-module/libraries-manager/components/messages.ts b/src/authz-module/libraries-manager/components/messages.ts index d36537e7..21052dca 100644 --- a/src/authz-module/libraries-manager/components/messages.ts +++ b/src/authz-module/libraries-manager/components/messages.ts @@ -56,6 +56,31 @@ const messages = defineMessages({ defaultMessage: 'Role added successfully.', description: 'Libraries AuthZ assign role success message', }, + '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.sort.newest': { + id: 'authz.libraries.team.table.sort.newest', + defaultMessage: 'Newest', + description: 'Sort by newest', + }, + 'authz.libraries.team.table.sort.oldest': { + id: 'authz.libraries.team.table.sort.oldest', + defaultMessage: 'Oldest', + description: 'Sort by oldest', + }, }); export default messages; From 857f82e16d7656dde258e716dba665f1b08ba95a Mon Sep 17 00:00:00 2001 From: Brayan Ceron Date: Mon, 6 Oct 2025 14:17:56 -0500 Subject: [PATCH 07/15] test: fix issues with failing tests --- src/authz-module/data/hooks.test.tsx | 20 ++++++++- src/authz-module/data/hooks.ts | 12 ++--- .../libraries-manager/context.test.tsx | 45 ++++++++++++------- 3 files changed, 54 insertions(+), 23 deletions(-) diff --git a/src/authz-module/data/hooks.test.tsx b/src/authz-module/data/hooks.test.tsx index 327eb9c6..c106a0dd 100644 --- a/src/authz-module/data/hooks.test.tsx +++ b/src/authz-module/data/hooks.test.tsx @@ -61,7 +61,15 @@ describe('useTeamMembers', () => { get: jest.fn().mockResolvedValue({ data: { results: mockMembers } }), }); - const { result } = renderHook(() => useTeamMembers('lib:123'), { + const mockQuerySettings = { + roles: null, + search: null, + ordering: null, + pageSize: 10, + pageIndex: 0, + }; + + const { result } = renderHook(() => useTeamMembers('lib:123', mockQuerySettings), { wrapper: createWrapper(), }); @@ -76,7 +84,15 @@ describe('useTeamMembers', () => { get: jest.fn().mockRejectedValue(new Error('API failure')), }); - const { result } = renderHook(() => useTeamMembers('lib:123'), { + const mockQuerySettings = { + roles: null, + search: null, + ordering: null, + pageSize: 10, + pageIndex: 0, + }; + + 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 54150bf7..8a7f9fed 100644 --- a/src/authz-module/data/hooks.ts +++ b/src/authz-module/data/hooks.ts @@ -11,15 +11,15 @@ import { const authzQueryKeys = { all: [appId, 'authz'] as const, - teamMembers: (object: string, querySettings: QuerySettings) => [ + teamMembers: (object: string, querySettings?: QuerySettings) => [ ...authzQueryKeys.all, 'teamMembers', object, - querySettings.roles, - querySettings.search, - querySettings.ordering, - querySettings.pageSize, - querySettings.pageIndex, + querySettings?.roles ?? null, + querySettings?.search ?? null, + querySettings?.ordering ?? null, + querySettings?.pageSize ?? 10, + querySettings?.pageIndex ?? 0, ] as const, permissionsByRole: (scope: string) => [...authzQueryKeys.all, 'permissionsByRole', scope] as const, library: (libraryId: string) => [...authzQueryKeys.all, 'library', libraryId] as const, 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'); }); }); From b11b75735dc683058758c0592ce9beae778b722c Mon Sep 17 00:00:00 2001 From: Brayan Ceron Date: Mon, 6 Oct 2025 16:03:24 -0500 Subject: [PATCH 08/15] refactor: update SearchFilter to use string & localize Clear filters button text --- .../libraries-manager/components/SearchFilter.tsx | 4 ++-- .../libraries-manager/components/TableControlBar.tsx | 2 +- src/authz-module/libraries-manager/components/messages.ts | 5 +++++ 3 files changed, 8 insertions(+), 3 deletions(-) diff --git a/src/authz-module/libraries-manager/components/SearchFilter.tsx b/src/authz-module/libraries-manager/components/SearchFilter.tsx index 43a6d146..85bd6698 100644 --- a/src/authz-module/libraries-manager/components/SearchFilter.tsx +++ b/src/authz-module/libraries-manager/components/SearchFilter.tsx @@ -6,8 +6,8 @@ import { import { Search } from '@openedx/paragon/icons'; interface SearchFilterProps { - filterValue: string[]; - setFilter: (value: string[]) => void; + filterValue: string; + setFilter: (value: string) => void; placeholder: string; } diff --git a/src/authz-module/libraries-manager/components/TableControlBar.tsx b/src/authz-module/libraries-manager/components/TableControlBar.tsx index ec895767..2406bf4c 100644 --- a/src/authz-module/libraries-manager/components/TableControlBar.tsx +++ b/src/authz-module/libraries-manager/components/TableControlBar.tsx @@ -61,7 +61,7 @@ const TableControlBar = () => { variant="link" onClick={() => setAllFilters([])} > - Clear filters + {intl.formatMessage(messages['authz.libraries.team.table.clearFilters'])} )} diff --git a/src/authz-module/libraries-manager/components/messages.ts b/src/authz-module/libraries-manager/components/messages.ts index 21052dca..00bee06a 100644 --- a/src/authz-module/libraries-manager/components/messages.ts +++ b/src/authz-module/libraries-manager/components/messages.ts @@ -81,6 +81,11 @@ const messages = defineMessages({ defaultMessage: 'Oldest', description: 'Sort by oldest', }, + '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; From 2b0980e924bb95ebdd28ae0b982e4f5fc0d70e9b Mon Sep 17 00:00:00 2001 From: Brayan Ceron Date: Mon, 6 Oct 2025 16:03:37 -0500 Subject: [PATCH 09/15] test: add missing comprehensive tests --- .../components/MultipleChoiceFilter.test.tsx | 111 +++++ .../components/SearchFilter.test.tsx | 87 ++++ .../components/SortDropdown.test.tsx | 177 +++++++ .../components/TableControlBar.test.tsx | 185 ++++++++ .../hooks/useQuerySettings.test.ts | 439 ++++++++++++++++++ 5 files changed, 999 insertions(+) create mode 100644 src/authz-module/libraries-manager/components/MultipleChoiceFilter.test.tsx create mode 100644 src/authz-module/libraries-manager/components/SearchFilter.test.tsx create mode 100644 src/authz-module/libraries-manager/components/SortDropdown.test.tsx create mode 100644 src/authz-module/libraries-manager/components/TableControlBar.test.tsx create mode 100644 src/authz-module/libraries-manager/hooks/useQuerySettings.test.ts diff --git a/src/authz-module/libraries-manager/components/MultipleChoiceFilter.test.tsx b/src/authz-module/libraries-manager/components/MultipleChoiceFilter.test.tsx new file mode 100644 index 00000000..6e7f011d --- /dev/null +++ b/src/authz-module/libraries-manager/components/MultipleChoiceFilter.test.tsx @@ -0,0 +1,111 @@ +import React from 'react'; +import { + render, screen, fireEvent, +} from '@testing-library/react'; +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', () => { + render(); + + fireEvent.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', () => { + render(); + + fireEvent.click(screen.getByRole('button')); + + const checkbox1 = screen.getByLabelText('Option 1'); + fireEvent.click(checkbox1); + + expect(mockSetFilter).toHaveBeenCalledWith(['option1']); + }); + + it('should remove value from filter when checkbox is unchecked', () => { + const propsWithSelectedValue = { + ...defaultProps, + filterValue: ['option1', 'option2'], + }; + + render(); + + fireEvent.click(screen.getByRole('button')); + + const checkbox1 = screen.getByLabelText('Option 1'); + fireEvent.click(checkbox1); + + expect(mockSetFilter).toHaveBeenCalledWith(['option2']); + }); + + it('should show checked checkboxes for pre-selected values', () => { + const propsWithSelectedValues = { + ...defaultProps, + filterValue: ['option1', 'option3'], + }; + + render(); + + fireEvent.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', () => { + const propsWithExistingSelection = { + ...defaultProps, + filterValue: ['option2'], + }; + + render(); + + fireEvent.click(screen.getByRole('button')); + + const checkbox1 = screen.getByLabelText('Option 1'); + fireEvent.click(checkbox1); + + expect(mockSetFilter).toHaveBeenCalledWith(['option2', 'option1']); + }); +}); diff --git a/src/authz-module/libraries-manager/components/SearchFilter.test.tsx b/src/authz-module/libraries-manager/components/SearchFilter.test.tsx new file mode 100644 index 00000000..f7b2f425 --- /dev/null +++ b/src/authz-module/libraries-manager/components/SearchFilter.test.tsx @@ -0,0 +1,87 @@ +import React from 'react'; +import { + render, screen, fireEvent, +} from '@testing-library/react'; +import SearchFilter from './SearchFilter'; + +describe('SearchFilter', () => { + const mockSetFilter = jest.fn(); + + const defaultProps = { + filterValue: '', + setFilter: mockSetFilter, + placeholder: 'Search placeholder', + }; + + beforeEach(() => { + jest.clearAllMocks(); + }); + + 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', () => { + const propsWithUndefined = { ...defaultProps, filterValue: undefined as any }; + render(); + + const input = screen.getByPlaceholderText('Search placeholder'); + expect(input).toHaveValue(''); + }); + + it('should display filterValue if provided', () => { + const propsWithString = { ...defaultProps, filterValue: 'test search' }; + render(); + + const input = screen.getByPlaceholderText('Search placeholder'); + expect(input).toHaveValue('test search'); + }); + + it('should call setFilter with input value when typing', () => { + render(); + + const input = screen.getByPlaceholderText('Search placeholder'); + fireEvent.change(input, { target: { value: 'new search term' } }); + + expect(mockSetFilter).toHaveBeenCalledWith('new search term'); + }); + + it('should call setFilter with undefined when input is cleared', () => { + const propsWithValue = { ...defaultProps, filterValue: 'existing value' as any }; + render(); + + const input = screen.getByPlaceholderText('Search placeholder'); + fireEvent.change(input, { target: { value: '' } }); + + expect(mockSetFilter).toHaveBeenCalledWith(undefined); + }); + + it('should handle multiple character input correctly', () => { + render(); + + const input = screen.getByPlaceholderText('Search placeholder'); + + // Type multiple characters + fireEvent.change(input, { target: { value: 'a' } }); + expect(mockSetFilter).toHaveBeenCalledWith('a'); + + fireEvent.change(input, { target: { value: 'ab' } }); + expect(mockSetFilter).toHaveBeenCalledWith('ab'); + + fireEvent.change(input, { target: { value: 'abc' } }); + expect(mockSetFilter).toHaveBeenCalledWith('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/SortDropdown.test.tsx b/src/authz-module/libraries-manager/components/SortDropdown.test.tsx new file mode 100644 index 00000000..ca3ef0b9 --- /dev/null +++ b/src/authz-module/libraries-manager/components/SortDropdown.test.tsx @@ -0,0 +1,177 @@ +import { + render, screen, fireEvent, act, +} from '@testing-library/react'; +import { IntlProvider } from '@edx/frontend-platform/i18n'; +import { DataTableContext } from '@openedx/paragon'; +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 render( + + + + + , + ); + }; + + 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', () => { + renderSortDropdown(); + + const toggleButton = screen.getByRole('button'); + fireEvent.click(toggleButton); + + expect(screen.getByText('Name A-Z')).toBeInTheDocument(); + expect(screen.getByText('Name Z-A')).toBeInTheDocument(); + expect(screen.getByText('Newest')).toBeInTheDocument(); + expect(screen.getByText('Oldest')).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 display newest sort correctly', () => { + const contextWithSort = { + state: { + ...defaultDataTableState, + sortBy: [{ id: 'createdAt', desc: true }], + }, + }; + + renderSortDropdown(contextWithSort); + + expect(screen.getByText('Newest')).toBeInTheDocument(); + }); + + it('should display oldest sort correctly', () => { + const contextWithSort = { + state: { + ...defaultDataTableState, + sortBy: [{ id: 'createdAt', desc: false }], + }, + }; + + renderSortDropdown(contextWithSort); + + expect(screen.getByText('Oldest')).toBeInTheDocument(); + }); + + it('should handle sort selection and call toggleSortBy', () => { + renderSortDropdown(); + + const toggleButton = screen.getByRole('button'); + fireEvent.click(toggleButton); + + const nameAZOption = screen.getByText('Name A-Z'); + + act(() => { + fireEvent.click(nameAZOption); + }); + + expect(mockToggleSortBy).toHaveBeenCalledWith('username', false); + + const nameZAOption = screen.getByText('Name Z-A'); + + act(() => { + fireEvent.click(nameZAOption); + }); + + expect(mockToggleSortBy).toHaveBeenCalledWith('username', true); + + const newestOption = screen.getByText('Newest'); + + act(() => { + fireEvent.click(newestOption); + }); + + expect(mockToggleSortBy).toHaveBeenCalledWith('createdAt', true); + }); + + it('should mark the active sort option as active', () => { + const contextWithSort = { + state: { + ...defaultDataTableState, + sortBy: [{ id: 'username', desc: false }], + }, + }; + + renderSortDropdown(contextWithSort); + + const toggleButton = screen.getByRole('button'); + fireEvent.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/TableControlBar.test.tsx b/src/authz-module/libraries-manager/components/TableControlBar.test.tsx new file mode 100644 index 00000000..f51f4665 --- /dev/null +++ b/src/authz-module/libraries-manager/components/TableControlBar.test.tsx @@ -0,0 +1,185 @@ +import { + render, screen, fireEvent, +} from '@testing-library/react'; +import { IntlProvider } from '@edx/frontend-platform/i18n'; +import { + DataTableContext, CheckboxFilter, TextFilter, +} from '@openedx/paragon'; +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) => ( + render( + + + + + , + ) + ); + + 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', () => { + const contextWithFilters = { + ...defaultContextValue, + state: { + filters: [{ id: 'username', value: 'test' }], + }, + }; + + renderWithContext(contextWithFilters); + + const clearButton = screen.getByText('Clear filters'); + fireEvent.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/hooks/useQuerySettings.test.ts b/src/authz-module/libraries-manager/hooks/useQuerySettings.test.ts new file mode 100644 index 00000000..9887d7cb --- /dev/null +++ b/src/authz-module/libraries-manager/hooks/useQuerySettings.test.ts @@ -0,0 +1,439 @@ +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, + ordering: 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, + ordering: 'username', + }; + + 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, + ordering: 'username', + }); + }); + + 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.ordering).toBe('-email'); + }); + + 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.ordering).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.ordering).toBe('-last_name'); + }); + + 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, + ordering: 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, + ordering: 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, + ordering: 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, + ordering: '-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.ordering).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.ordering).toBe('-email'); + }); + + 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); + }); +}); From 85ca129d2981933eb3fa1ccb3a0e403c8ed8fa15 Mon Sep 17 00:00:00 2001 From: Diana Olarte Date: Tue, 14 Oct 2025 12:48:35 +1100 Subject: [PATCH 10/15] refactor: update sorting and group all in a Table --- src/authz-module/data/api.ts | 16 ++--- src/authz-module/data/hooks.test.tsx | 58 +++++++++---------- src/authz-module/data/hooks.ts | 38 +++++------- .../components}/MultipleChoiceFilter.test.tsx | 0 .../components}/MultipleChoiceFilter.tsx | 0 .../components}/SearchFilter.test.tsx | 0 .../components}/SearchFilter.tsx | 0 .../components}/SortDropdown.test.tsx | 36 ------------ .../components}/SortDropdown.tsx | 4 -- .../components}/TableControlBar.test.tsx | 0 .../components}/TableControlBar.tsx | 2 +- .../TeamTable}/hooks/useQuerySettings.test.ts | 32 ++++++---- .../TeamTable}/hooks/useQuerySettings.ts | 26 +++++---- .../index.test.tsx} | 33 ++++++----- .../{TeamTable.tsx => TeamTable/index.tsx} | 39 ++++++------- .../components/{ => TeamTable}/messages.ts | 0 .../libraries-manager/hooks/index.ts | 1 - 17 files changed, 120 insertions(+), 165 deletions(-) rename src/authz-module/libraries-manager/components/{ => TeamTable/components}/MultipleChoiceFilter.test.tsx (100%) rename src/authz-module/libraries-manager/components/{ => TeamTable/components}/MultipleChoiceFilter.tsx (100%) rename src/authz-module/libraries-manager/components/{ => TeamTable/components}/SearchFilter.test.tsx (100%) rename src/authz-module/libraries-manager/components/{ => TeamTable/components}/SearchFilter.tsx (100%) rename src/authz-module/libraries-manager/components/{ => TeamTable/components}/SortDropdown.test.tsx (80%) rename src/authz-module/libraries-manager/components/{ => TeamTable/components}/SortDropdown.tsx (89%) rename src/authz-module/libraries-manager/components/{ => TeamTable/components}/TableControlBar.test.tsx (100%) rename src/authz-module/libraries-manager/components/{ => TeamTable/components}/TableControlBar.tsx (98%) rename src/authz-module/libraries-manager/{ => components/TeamTable}/hooks/useQuerySettings.test.ts (94%) rename src/authz-module/libraries-manager/{ => components/TeamTable}/hooks/useQuerySettings.ts (80%) rename src/authz-module/libraries-manager/components/{TeamTable.test.tsx => TeamTable/index.test.tsx} (89%) rename src/authz-module/libraries-manager/components/{TeamTable.tsx => TeamTable/index.tsx} (87%) rename src/authz-module/libraries-manager/components/{ => TeamTable}/messages.ts (100%) delete mode 100644 src/authz-module/libraries-manager/hooks/index.ts diff --git a/src/authz-module/data/api.ts b/src/authz-module/data/api.ts index 5bd20ab8..772b5658 100644 --- a/src/authz-module/data/api.ts +++ b/src/authz-module/data/api.ts @@ -6,14 +6,15 @@ import { getApiUrl, getStudioApiUrl } from '@src/data/utils'; export interface QuerySettings { roles: string | null; search: string | null; - ordering: 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 = { @@ -33,7 +34,7 @@ export interface AssignTeamMembersRoleRequest { } // TODO: replece api path once is created -export const getTeamMembers = async (object: string, querySettings: QuerySettings): Promise => { +export const getTeamMembers = async (object: string, querySettings: QuerySettings): Promise => { const url = new URL(getApiUrl(`/api/authz/v1/roles/users/?scope=${object}`)); if (querySettings.roles) { @@ -42,14 +43,15 @@ export const getTeamMembers = async (object: string, querySettings: QuerySetting if (querySettings.search) { url.searchParams.set('search', querySettings.search); } - if (querySettings.ordering) { - url.searchParams.set('ordering', querySettings.ordering); + 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.results); + 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 c106a0dd..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,17 +70,9 @@ 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 mockQuerySettings = { - roles: null, - search: null, - ordering: null, - pageSize: 10, - pageIndex: 0, - }; - const { result } = renderHook(() => useTeamMembers('lib:123', mockQuerySettings), { wrapper: createWrapper(), }); @@ -84,14 +88,6 @@ describe('useTeamMembers', () => { get: jest.fn().mockRejectedValue(new Error('API failure')), }); - const mockQuerySettings = { - roles: null, - search: null, - ordering: null, - pageSize: 10, - pageIndex: 0, - }; - 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 8a7f9fed..b07b7da7 100644 --- a/src/authz-module/data/hooks.ts +++ b/src/authz-module/data/hooks.ts @@ -2,25 +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, QuerySettings, + assignTeamMembersRole, AssignTeamMembersRoleRequest, getLibrary, getPermissionsByRole, getTeamMembers, + GetTeamMembersResponse, PermissionsByRole, QuerySettings, } from './api'; const authzQueryKeys = { all: [appId, 'authz'] as const, - teamMembers: (object: string, querySettings?: QuerySettings) => [ - ...authzQueryKeys.all, - 'teamMembers', - object, - querySettings?.roles ?? null, - querySettings?.search ?? null, - querySettings?.ordering ?? null, - querySettings?.pageSize ?? 10, - querySettings?.pageIndex ?? 0, - ] 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, }; @@ -29,7 +21,7 @@ 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 @@ -37,16 +29,12 @@ const authzQueryKeys = { * const { data: teamMembers, isLoading, isError } = useTeamMembers('lib:123', querySettings); * ``` */ -export const useTeamMembers = (object: string, querySettings: QuerySettings) => { - const queryKey = authzQueryKeys.teamMembers(object, querySettings); - - return useQuery({ - queryKey, - queryFn: () => getTeamMembers(object, querySettings), - staleTime: 1000 * 60 * 30, // refetch after 30 minutes - refetchOnWindowFocus: false, - }); -}; +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, +}); /** * React Query hook to fetch all the roles for the specific object/scope. diff --git a/src/authz-module/libraries-manager/components/MultipleChoiceFilter.test.tsx b/src/authz-module/libraries-manager/components/TeamTable/components/MultipleChoiceFilter.test.tsx similarity index 100% rename from src/authz-module/libraries-manager/components/MultipleChoiceFilter.test.tsx rename to src/authz-module/libraries-manager/components/TeamTable/components/MultipleChoiceFilter.test.tsx diff --git a/src/authz-module/libraries-manager/components/MultipleChoiceFilter.tsx b/src/authz-module/libraries-manager/components/TeamTable/components/MultipleChoiceFilter.tsx similarity index 100% rename from src/authz-module/libraries-manager/components/MultipleChoiceFilter.tsx rename to src/authz-module/libraries-manager/components/TeamTable/components/MultipleChoiceFilter.tsx diff --git a/src/authz-module/libraries-manager/components/SearchFilter.test.tsx b/src/authz-module/libraries-manager/components/TeamTable/components/SearchFilter.test.tsx similarity index 100% rename from src/authz-module/libraries-manager/components/SearchFilter.test.tsx rename to src/authz-module/libraries-manager/components/TeamTable/components/SearchFilter.test.tsx diff --git a/src/authz-module/libraries-manager/components/SearchFilter.tsx b/src/authz-module/libraries-manager/components/TeamTable/components/SearchFilter.tsx similarity index 100% rename from src/authz-module/libraries-manager/components/SearchFilter.tsx rename to src/authz-module/libraries-manager/components/TeamTable/components/SearchFilter.tsx diff --git a/src/authz-module/libraries-manager/components/SortDropdown.test.tsx b/src/authz-module/libraries-manager/components/TeamTable/components/SortDropdown.test.tsx similarity index 80% rename from src/authz-module/libraries-manager/components/SortDropdown.test.tsx rename to src/authz-module/libraries-manager/components/TeamTable/components/SortDropdown.test.tsx index ca3ef0b9..fc436197 100644 --- a/src/authz-module/libraries-manager/components/SortDropdown.test.tsx +++ b/src/authz-module/libraries-manager/components/TeamTable/components/SortDropdown.test.tsx @@ -56,8 +56,6 @@ describe('SortDropdown', () => { expect(screen.getByText('Name A-Z')).toBeInTheDocument(); expect(screen.getByText('Name Z-A')).toBeInTheDocument(); - expect(screen.getByText('Newest')).toBeInTheDocument(); - expect(screen.getByText('Oldest')).toBeInTheDocument(); }); it('should display current sort when a sort is active', () => { @@ -86,32 +84,6 @@ describe('SortDropdown', () => { expect(screen.getByText('Name Z-A')).toBeInTheDocument(); }); - it('should display newest sort correctly', () => { - const contextWithSort = { - state: { - ...defaultDataTableState, - sortBy: [{ id: 'createdAt', desc: true }], - }, - }; - - renderSortDropdown(contextWithSort); - - expect(screen.getByText('Newest')).toBeInTheDocument(); - }); - - it('should display oldest sort correctly', () => { - const contextWithSort = { - state: { - ...defaultDataTableState, - sortBy: [{ id: 'createdAt', desc: false }], - }, - }; - - renderSortDropdown(contextWithSort); - - expect(screen.getByText('Oldest')).toBeInTheDocument(); - }); - it('should handle sort selection and call toggleSortBy', () => { renderSortDropdown(); @@ -133,14 +105,6 @@ describe('SortDropdown', () => { }); expect(mockToggleSortBy).toHaveBeenCalledWith('username', true); - - const newestOption = screen.getByText('Newest'); - - act(() => { - fireEvent.click(newestOption); - }); - - expect(mockToggleSortBy).toHaveBeenCalledWith('createdAt', true); }); it('should mark the active sort option as active', () => { diff --git a/src/authz-module/libraries-manager/components/SortDropdown.tsx b/src/authz-module/libraries-manager/components/TeamTable/components/SortDropdown.tsx similarity index 89% rename from src/authz-module/libraries-manager/components/SortDropdown.tsx rename to src/authz-module/libraries-manager/components/TeamTable/components/SortDropdown.tsx index ab7c6751..e46d9523 100644 --- a/src/authz-module/libraries-manager/components/SortDropdown.tsx +++ b/src/authz-module/libraries-manager/components/TeamTable/components/SortDropdown.tsx @@ -25,8 +25,6 @@ interface SortByOptions { const SORT_BY_OPTIONS: SortByOptions = { 'name-a-z': { id: 'username', desc: false }, 'name-z-a': { id: 'username', desc: true }, - newest: { id: 'createdAt', desc: true }, - oldest: { id: 'createdAt', desc: false }, }; const SortDropdown: FC = () => { @@ -37,8 +35,6 @@ const SortDropdown: FC = () => { 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' }), - newest: intl.formatMessage({ id: 'authz.libraries.team.table.sort.newest', defaultMessage: 'Newest' }), - oldest: intl.formatMessage({ id: 'authz.libraries.team.table.sort.oldest', defaultMessage: 'Oldest' }), // eslint-disable-next-line react-hooks/exhaustive-deps }), []); diff --git a/src/authz-module/libraries-manager/components/TableControlBar.test.tsx b/src/authz-module/libraries-manager/components/TeamTable/components/TableControlBar.test.tsx similarity index 100% rename from src/authz-module/libraries-manager/components/TableControlBar.test.tsx rename to src/authz-module/libraries-manager/components/TeamTable/components/TableControlBar.test.tsx diff --git a/src/authz-module/libraries-manager/components/TableControlBar.tsx b/src/authz-module/libraries-manager/components/TeamTable/components/TableControlBar.tsx similarity index 98% rename from src/authz-module/libraries-manager/components/TableControlBar.tsx rename to src/authz-module/libraries-manager/components/TeamTable/components/TableControlBar.tsx index 2406bf4c..0d02a5f6 100644 --- a/src/authz-module/libraries-manager/components/TableControlBar.tsx +++ b/src/authz-module/libraries-manager/components/TeamTable/components/TableControlBar.tsx @@ -11,7 +11,7 @@ import { import MultipleChoiceFilter from './MultipleChoiceFilter'; import SortDropdown from './SortDropdown'; import SearchFilter from './SearchFilter'; -import messages from './messages'; +import messages from '../messages'; const TableControlBar = () => { const intl = useIntl(); diff --git a/src/authz-module/libraries-manager/hooks/useQuerySettings.test.ts b/src/authz-module/libraries-manager/components/TeamTable/hooks/useQuerySettings.test.ts similarity index 94% rename from src/authz-module/libraries-manager/hooks/useQuerySettings.test.ts rename to src/authz-module/libraries-manager/components/TeamTable/hooks/useQuerySettings.test.ts index 9887d7cb..6a9dc765 100644 --- a/src/authz-module/libraries-manager/hooks/useQuerySettings.test.ts +++ b/src/authz-module/libraries-manager/components/TeamTable/hooks/useQuerySettings.test.ts @@ -8,7 +8,8 @@ describe('useQuerySettings', () => { search: null, pageSize: 10, pageIndex: 0, - ordering: null, + sortBy: null, + order: null, }; it('should initialize with default query settings when no initial settings provided', () => { @@ -24,7 +25,8 @@ describe('useQuerySettings', () => { search: 'test-user', pageSize: 20, pageIndex: 2, - ordering: 'username', + sortBy: 'username', + order: 'asc', }; const { result } = renderHook(() => useQuerySettings(customInitialSettings)); @@ -54,7 +56,8 @@ describe('useQuerySettings', () => { search: 'john', pageSize: 15, pageIndex: 1, - ordering: 'username', + sortBy: 'username', + order: 'asc', }); }); @@ -72,7 +75,7 @@ describe('useQuerySettings', () => { result.current.handleTableFetch(tableFilters); }); - expect(result.current.querySettings.ordering).toBe('-email'); + expect(result.current.querySettings.order).toBe('desc'); }); it('should convert camelCase sort field to snake_case', () => { @@ -89,7 +92,7 @@ describe('useQuerySettings', () => { result.current.handleTableFetch(tableFilters); }); - expect(result.current.querySettings.ordering).toBe('first_name'); + expect(result.current.querySettings.sortBy).toBe('first_name'); }); it('should convert camelCase sort field to snake_case with descending order', () => { @@ -106,7 +109,7 @@ describe('useQuerySettings', () => { result.current.handleTableFetch(tableFilters); }); - expect(result.current.querySettings.ordering).toBe('-last_name'); + expect(result.current.querySettings.order).toBe('desc'); }); it('should handle empty filters by setting values to null', () => { @@ -128,7 +131,8 @@ describe('useQuerySettings', () => { search: null, pageSize: 10, pageIndex: 0, - ordering: null, + order: null, + sortBy: null, }); }); @@ -154,7 +158,8 @@ describe('useQuerySettings', () => { search: null, pageSize: 10, pageIndex: 0, - ordering: null, + order: null, + sortBy: null, }); }); @@ -180,7 +185,8 @@ describe('useQuerySettings', () => { search: null, pageSize: 10, pageIndex: 0, - ordering: null, + order: null, + sortBy: null, }); }); @@ -264,7 +270,8 @@ describe('useQuerySettings', () => { search: 'test@example.com', pageSize: 25, pageIndex: 3, - ordering: '-user_role', + order: 'desc', + sortBy: 'user_role', }); }); @@ -282,7 +289,7 @@ describe('useQuerySettings', () => { result.current.handleTableFetch(tableFilters); }); - expect(result.current.querySettings.ordering).toBe('user_first_last_name'); + expect(result.current.querySettings.sortBy).toBe('user_first_last_name'); }); it('should preserve handleTableFetch function reference across renders', () => { @@ -398,7 +405,8 @@ describe('useQuerySettings', () => { }); expect(result.current.querySettings).not.toBe(settingsAfterFirstUpdate); - expect(result.current.querySettings.ordering).toBe('-email'); + expect(result.current.querySettings.sortBy).toBe('email'); + expect(result.current.querySettings.order).toBe('desc'); }); it('should detect changes in pageSize', () => { diff --git a/src/authz-module/libraries-manager/hooks/useQuerySettings.ts b/src/authz-module/libraries-manager/components/TeamTable/hooks/useQuerySettings.ts similarity index 80% rename from src/authz-module/libraries-manager/hooks/useQuerySettings.ts rename to src/authz-module/libraries-manager/components/TeamTable/hooks/useQuerySettings.ts index a532c3da..3c7d879f 100644 --- a/src/authz-module/libraries-manager/hooks/useQuerySettings.ts +++ b/src/authz-module/libraries-manager/components/TeamTable/hooks/useQuerySettings.ts @@ -13,6 +13,11 @@ interface UseQuerySettingsReturn { 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 @@ -27,7 +32,8 @@ export const useQuerySettings = ( search: null, pageSize: 10, pageIndex: 0, - ordering: null, + order: null, + sortBy: null, }, ): UseQuerySettingsReturn => { const [querySettings, setQuerySettings] = useState(initialQuerySettings); @@ -42,21 +48,18 @@ export const useQuerySettings = ( const { pageSize = 10, pageIndex = 0 } = tableFilters; // Extract and convert sorting - let ordering = ''; + let sortByOption = ''; + let sortByOrder = ''; if (tableFilters.sortBy.length) { - const snakeCaseId = tableFilters.sortBy[0].id.replace(/([A-Z])/g, '_$1').toLowerCase(); - - if (tableFilters.sortBy[0].desc) { - ordering = `-${snakeCaseId}`; - } else { - ordering = snakeCaseId; - } + 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, - ordering: ordering || null, + sortBy: sortByOption || null, + order: sortByOrder || null, pageSize, pageIndex, }; @@ -66,7 +69,8 @@ export const useQuerySettings = ( || prevSettings.search !== newQuerySettings.search || prevSettings.pageSize !== newQuerySettings.pageSize || prevSettings.pageIndex !== newQuerySettings.pageIndex - || prevSettings.ordering !== newQuerySettings.ordering + || prevSettings.sortBy !== newQuerySettings.sortBy + || prevSettings.order !== newQuerySettings.order ); if (!hasChanged) { 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..8c5e60fb 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 87% rename from src/authz-module/libraries-manager/components/TeamTable.tsx rename to src/authz-module/libraries-manager/components/TeamTable/index.tsx index 92800754..228157ec 100644 --- a/src/authz-module/libraries-manager/components/TeamTable.tsx +++ b/src/authz-module/libraries-manager/components/TeamTable/index.tsx @@ -1,3 +1,4 @@ +import { useMemo } from 'react'; import { useNavigate } from 'react-router-dom'; import debounce from 'lodash.debounce'; import { useIntl } from '@edx/frontend-platform/i18n'; @@ -5,17 +6,15 @@ 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 { - useMemo, -} from 'react'; -import { useLibraryAuthZ } from '../context'; -import { useQuerySettings } from '../hooks'; +import { useLibraryAuthZ } from '@src/authz-module/libraries-manager/context'; +import { useQuerySettings } from './hooks/useQuerySettings'; +import TableControlBar from './components/TableControlBar'; import messages from './messages'; -import TableControlBar from './TableControlBar'; const SKELETON_ROWS = Array.from({ length: 10 }).map(() => ({ username: 'skeleton', @@ -24,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 ? ( @@ -65,7 +66,8 @@ const TeamTable = () => { data: teamMembers, isLoading, isError, } = 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(); @@ -80,17 +82,19 @@ const TeamTable = () => { return ( { ) : null), }, ]} - initialState={{ - pageSize: 10, - hiddenColumns: ['createdAt'], - }} columns={ [ { @@ -144,17 +144,12 @@ const TeamTable = () => { filterChoices: Object.values(adaptedFilterChoices), disableSortBy: true, }, - { - accessor: 'createdAt', - Filter: false, - disableFilters: true, - disableSortBy: true, - }, ] } > + ); }; diff --git a/src/authz-module/libraries-manager/components/messages.ts b/src/authz-module/libraries-manager/components/TeamTable/messages.ts similarity index 100% rename from src/authz-module/libraries-manager/components/messages.ts rename to src/authz-module/libraries-manager/components/TeamTable/messages.ts diff --git a/src/authz-module/libraries-manager/hooks/index.ts b/src/authz-module/libraries-manager/hooks/index.ts deleted file mode 100644 index 19d06c8f..00000000 --- a/src/authz-module/libraries-manager/hooks/index.ts +++ /dev/null @@ -1 +0,0 @@ -export { useQuerySettings } from './useQuerySettings'; From 7e9a40416f5cc84a171b69a61c441f539b6e17b0 Mon Sep 17 00:00:00 2001 From: Diana Olarte Date: Mon, 20 Oct 2025 18:14:15 +1100 Subject: [PATCH 11/15] test: update to use useEvent intead of fireEvent --- .../components/AuthZTitle.test.tsx | 8 +- .../components/MultipleChoiceFilter.test.tsx | 38 +++++---- .../components/SearchFilter.test.tsx | 79 ++++++++++--------- .../components/SortDropdown.test.tsx | 41 +++++----- .../components/TableControlBar.test.tsx | 22 +++--- 5 files changed, 98 insertions(+), 90 deletions(-) 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/libraries-manager/components/TeamTable/components/MultipleChoiceFilter.test.tsx b/src/authz-module/libraries-manager/components/TeamTable/components/MultipleChoiceFilter.test.tsx index 6e7f011d..c0c9e30a 100644 --- a/src/authz-module/libraries-manager/components/TeamTable/components/MultipleChoiceFilter.test.tsx +++ b/src/authz-module/libraries-manager/components/TeamTable/components/MultipleChoiceFilter.test.tsx @@ -1,7 +1,5 @@ -import React 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 MultipleChoiceFilter from './MultipleChoiceFilter'; describe('MultipleChoiceFilter', () => { @@ -37,28 +35,32 @@ describe('MultipleChoiceFilter', () => { expect(icon).toBeInTheDocument(); }); - it('should show all filter choices when dropdown is opened', () => { + it('should show all filter choices when dropdown is opened', async () => { + const user = userEvent.setup(); + render(); - fireEvent.click(screen.getByRole('button')); + 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', () => { + it('should add value to filter when checkbox is checked', async () => { + const user = userEvent.setup(); render(); - fireEvent.click(screen.getByRole('button')); + await user.click(screen.getByRole('button')); const checkbox1 = screen.getByLabelText('Option 1'); - fireEvent.click(checkbox1); + await user.click(checkbox1); expect(mockSetFilter).toHaveBeenCalledWith(['option1']); }); - it('should remove value from filter when checkbox is unchecked', () => { + it('should remove value from filter when checkbox is unchecked', async () => { + const user = userEvent.setup(); const propsWithSelectedValue = { ...defaultProps, filterValue: ['option1', 'option2'], @@ -66,15 +68,16 @@ describe('MultipleChoiceFilter', () => { render(); - fireEvent.click(screen.getByRole('button')); + await user.click(screen.getByRole('button')); const checkbox1 = screen.getByLabelText('Option 1'); - fireEvent.click(checkbox1); + await user.click(checkbox1); expect(mockSetFilter).toHaveBeenCalledWith(['option2']); }); - it('should show checked checkboxes for pre-selected values', () => { + it('should show checked checkboxes for pre-selected values', async () => { + const user = userEvent.setup(); const propsWithSelectedValues = { ...defaultProps, filterValue: ['option1', 'option3'], @@ -82,7 +85,7 @@ describe('MultipleChoiceFilter', () => { render(); - fireEvent.click(screen.getByRole('button')); + await user.click(screen.getByRole('button')); const checkbox1 = screen.getByLabelText('Option 1'); const checkbox2 = screen.getByLabelText('Option 2'); @@ -93,7 +96,8 @@ describe('MultipleChoiceFilter', () => { expect(checkbox3).toBeChecked(); }); - it('should call setFilter with correct array when adding to existing selections', () => { + it('should call setFilter with correct array when adding to existing selections', async () => { + const user = userEvent.setup(); const propsWithExistingSelection = { ...defaultProps, filterValue: ['option2'], @@ -101,10 +105,10 @@ describe('MultipleChoiceFilter', () => { render(); - fireEvent.click(screen.getByRole('button')); + await user.click(screen.getByRole('button')); const checkbox1 = screen.getByLabelText('Option 1'); - fireEvent.click(checkbox1); + await user.click(checkbox1); expect(mockSetFilter).toHaveBeenCalledWith(['option2', 'option1']); }); 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 index f7b2f425..f216f649 100644 --- a/src/authz-module/libraries-manager/components/TeamTable/components/SearchFilter.test.tsx +++ b/src/authz-module/libraries-manager/components/TeamTable/components/SearchFilter.test.tsx @@ -1,24 +1,28 @@ -import React from 'react'; -import { - render, screen, fireEvent, -} from '@testing-library/react'; +import { useState } from 'react'; +import { render, screen } from '@testing-library/react'; +import userEvent from '@testing-library/user-event'; import SearchFilter from './SearchFilter'; describe('SearchFilter', () => { - const mockSetFilter = jest.fn(); - - const defaultProps = { - filterValue: '', - setFilter: mockSetFilter, - placeholder: 'Search placeholder', - }; - 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(); + render(); const input = screen.getByPlaceholderText('Search placeholder'); expect(input).toBeInTheDocument(); @@ -26,59 +30,62 @@ describe('SearchFilter', () => { }); it('should display empty value when filterValue is undefined', () => { - const propsWithUndefined = { ...defaultProps, filterValue: undefined as any }; - render(); + render(); const input = screen.getByPlaceholderText('Search placeholder'); expect(input).toHaveValue(''); }); it('should display filterValue if provided', () => { - const propsWithString = { ...defaultProps, filterValue: 'test search' }; - render(); + render(); const input = screen.getByPlaceholderText('Search placeholder'); expect(input).toHaveValue('test search'); }); - it('should call setFilter with input value when typing', () => { - render(); + it('should call setFilter with input value when typing', async () => { + const user = userEvent.setup(); + render(); const input = screen.getByPlaceholderText('Search placeholder'); - fireEvent.change(input, { target: { value: 'new search term' } }); + await user.click(input); + await user.type(input, 'new search term'); - expect(mockSetFilter).toHaveBeenCalledWith('new search term'); + expect(input).toHaveValue('new search term'); }); - it('should call setFilter with undefined when input is cleared', () => { - const propsWithValue = { ...defaultProps, filterValue: 'existing value' as any }; - render(); + it('should clear the input correctly', async () => { + const user = userEvent.setup(); + render(); const input = screen.getByPlaceholderText('Search placeholder'); - fireEvent.change(input, { target: { value: '' } }); - - expect(mockSetFilter).toHaveBeenCalledWith(undefined); + await user.click(input); + await user.clear(input); + expect(input).toHaveValue(''); }); - it('should handle multiple character input correctly', () => { - render(); + 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 - fireEvent.change(input, { target: { value: 'a' } }); - expect(mockSetFilter).toHaveBeenCalledWith('a'); + await user.type(input, 'a'); + expect(input).toHaveValue('a'); - fireEvent.change(input, { target: { value: 'ab' } }); - expect(mockSetFilter).toHaveBeenCalledWith('ab'); + await user.type(input, 'b'); + expect(input).toHaveValue('ab'); - fireEvent.change(input, { target: { value: 'abc' } }); - expect(mockSetFilter).toHaveBeenCalledWith('abc'); + await user.type(input, 'c'); + expect(input).toHaveValue('abc'); }); it('should handle different placeholder text', () => { const customPlaceholder = 'Enter search term here...'; - render(); + render(); const input = screen.getByPlaceholderText(customPlaceholder); expect(input).toBeInTheDocument(); 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 index fc436197..298a2ed0 100644 --- a/src/authz-module/libraries-manager/components/TeamTable/components/SortDropdown.test.tsx +++ b/src/authz-module/libraries-manager/components/TeamTable/components/SortDropdown.test.tsx @@ -1,8 +1,7 @@ -import { - render, screen, fireEvent, act, -} from '@testing-library/react'; -import { IntlProvider } from '@edx/frontend-platform/i18n'; +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')); @@ -28,12 +27,10 @@ describe('SortDropdown', () => { ...contextOverrides, }; - return render( - - - - - , + return renderWrapper( + + + , ); }; @@ -48,11 +45,12 @@ describe('SortDropdown', () => { expect(screen.getByText('Sort')).toBeInTheDocument(); }); - it('should render all sort options when dropdown is opened', () => { + it('should render all sort options when dropdown is opened', async () => { + const user = userEvent.setup(); renderSortDropdown(); const toggleButton = screen.getByRole('button'); - fireEvent.click(toggleButton); + await user.click(toggleButton); expect(screen.getByText('Name A-Z')).toBeInTheDocument(); expect(screen.getByText('Name Z-A')).toBeInTheDocument(); @@ -84,30 +82,29 @@ describe('SortDropdown', () => { expect(screen.getByText('Name Z-A')).toBeInTheDocument(); }); - it('should handle sort selection and call toggleSortBy', () => { + it('should handle sort selection and call toggleSortBy', async () => { + const user = userEvent.setup(); renderSortDropdown(); const toggleButton = screen.getByRole('button'); - fireEvent.click(toggleButton); + await user.click(toggleButton); const nameAZOption = screen.getByText('Name A-Z'); - act(() => { - fireEvent.click(nameAZOption); - }); + await user.click(nameAZOption); expect(mockToggleSortBy).toHaveBeenCalledWith('username', false); const nameZAOption = screen.getByText('Name Z-A'); - act(() => { - fireEvent.click(nameZAOption); - }); + await user.click(toggleButton); + await user.click(nameZAOption); expect(mockToggleSortBy).toHaveBeenCalledWith('username', true); }); - it('should mark the active sort option as active', () => { + it('should mark the active sort option as active', async () => { + const user = userEvent.setup(); const contextWithSort = { state: { ...defaultDataTableState, @@ -118,7 +115,7 @@ describe('SortDropdown', () => { renderSortDropdown(contextWithSort); const toggleButton = screen.getByRole('button'); - fireEvent.click(toggleButton); + await user.click(toggleButton); // Get all elements with "Name A-Z" text and find the dropdown item const nameAZOptions = screen.getAllByText('Name A-Z'); 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 index f51f4665..cde0dc62 100644 --- a/src/authz-module/libraries-manager/components/TeamTable/components/TableControlBar.test.tsx +++ b/src/authz-module/libraries-manager/components/TeamTable/components/TableControlBar.test.tsx @@ -1,10 +1,9 @@ -import { - render, screen, fireEvent, -} from '@testing-library/react'; -import { IntlProvider } from '@edx/frontend-platform/i18n'; +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', () => { @@ -61,12 +60,10 @@ describe('TableControlBar', () => { }; const renderWithContext = (contextValue = defaultContextValue) => ( - render( - - - - - , + renderWrapper( + + + , ) ); @@ -101,7 +98,8 @@ describe('TableControlBar', () => { expect(screen.getByText('Clear filters')).toBeInTheDocument(); }); - it('should call setAllFilters with empty array when Clear filters is clicked', () => { + it('should call setAllFilters with empty array when Clear filters is clicked', async () => { + const user = userEvent.setup(); const contextWithFilters = { ...defaultContextValue, state: { @@ -112,7 +110,7 @@ describe('TableControlBar', () => { renderWithContext(contextWithFilters); const clearButton = screen.getByText('Clear filters'); - fireEvent.click(clearButton); + await user.click(clearButton); expect(mockSetAllFilters).toHaveBeenCalledWith([]); }); From ce7e65b15cc688a8674eccf15f8c52400cdc322c Mon Sep 17 00:00:00 2001 From: Diana Olarte Date: Wed, 22 Oct 2025 10:08:59 +1100 Subject: [PATCH 12/15] refactor: user retrival for paginated query in user detail view --- src/authz-module/data/hooks.ts | 4 ++-- .../LibrariesUserManager.test.tsx | 16 +++++++++------- .../libraries-manager/LibrariesUserManager.tsx | 17 +++++++++++++---- 3 files changed, 24 insertions(+), 13 deletions(-) diff --git a/src/authz-module/data/hooks.ts b/src/authz-module/data/hooks.ts index b07b7da7..e4ced661 100644 --- a/src/authz-module/data/hooks.ts +++ b/src/authz-module/data/hooks.ts @@ -4,7 +4,7 @@ import { import { appId } from '@src/constants'; import { LibraryMetadata } from '@src/types'; import { - assignTeamMembersRole, AssignTeamMembersRoleRequest, getLibrary, getPermissionsByRole, getTeamMembers, + assignTeamMembersRole, AssignTeamMembersRoleRequest, getLibrary, getPermissionsByRole, getTeamMembers, GetTeamMembersResponse, PermissionsByRole, QuerySettings, } from './api'; @@ -83,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 => ( Date: Wed, 22 Oct 2025 16:05:02 +1100 Subject: [PATCH 13/15] refactor: separation of i18n messages --- .../AssignNewRoleModal/AssignNewRoleModal.tsx | 2 +- .../components/TeamTable/index.test.tsx | 4 +-- .../components/TeamTable/messages.ts | 35 ------------------ .../libraries-manager/components/messages.ts | 36 +++++++++++++++++++ 4 files changed, 39 insertions(+), 38 deletions(-) create mode 100644 src/authz-module/libraries-manager/components/messages.ts diff --git a/src/authz-module/libraries-manager/components/AssignNewRoleModal/AssignNewRoleModal.tsx b/src/authz-module/libraries-manager/components/AssignNewRoleModal/AssignNewRoleModal.tsx index 0fca938f..c7a3ef08 100644 --- a/src/authz-module/libraries-manager/components/AssignNewRoleModal/AssignNewRoleModal.tsx +++ b/src/authz-module/libraries-manager/components/AssignNewRoleModal/AssignNewRoleModal.tsx @@ -39,7 +39,7 @@ const AssignNewRoleModal: FC = ({ - {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/index.test.tsx b/src/authz-module/libraries-manager/components/TeamTable/index.test.tsx index 8c5e60fb..0278d564 100644 --- a/src/authz-module/libraries-manager/components/TeamTable/index.test.tsx +++ b/src/authz-module/libraries-manager/components/TeamTable/index.test.tsx @@ -25,12 +25,12 @@ describe('TeamTable', () => { results: [ { email: 'alice@example.com', - roles: ['Admin', 'Editor'], + roles: ['admin', 'editor'], username: 'alice', }, { email: 'bob@example.com', - roles: ['Viewer'], + roles: ['viewer'], username: 'bob', }, ], diff --git a/src/authz-module/libraries-manager/components/TeamTable/messages.ts b/src/authz-module/libraries-manager/components/TeamTable/messages.ts index 00bee06a..1bc30a2a 100644 --- a/src/authz-module/libraries-manager/components/TeamTable/messages.ts +++ b/src/authz-module/libraries-manager/components/TeamTable/messages.ts @@ -31,31 +31,6 @@ const messages = defineMessages({ 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', - }, - 'libraries.authz.manage.cancel.button': { - id: 'libraries.authz.manage.cancel.button', - defaultMessage: 'Cancel', - description: 'Libraries AuthZ cancel button title', - }, - 'libraries.authz.manage.saving.button': { - id: 'libraries.authz.manage.saving.button', - defaultMessage: 'Saving...', - description: 'Libraries AuthZ saving button title', - }, - 'libraries.authz.manage.save.button': { - id: 'libraries.authz.manage.save.button', - defaultMessage: 'Save', - description: 'Libraries AuthZ save button title', - }, - 'libraries.authz.manage.assign.role.success': { - id: 'libraries.authz.manage.assign.role.success', - defaultMessage: 'Role added successfully.', - description: 'Libraries AuthZ assign role success message', - }, 'authz.libraries.team.table.search': { id: 'authz.libraries.team.table.search', defaultMessage: 'Search by {firstField} or {secondField}', @@ -71,16 +46,6 @@ const messages = defineMessages({ defaultMessage: 'Name Z-A', description: 'Sort by name Z-A', }, - 'authz.libraries.team.table.sort.newest': { - id: 'authz.libraries.team.table.sort.newest', - defaultMessage: 'Newest', - description: 'Sort by newest', - }, - 'authz.libraries.team.table.sort.oldest': { - id: 'authz.libraries.team.table.sort.oldest', - defaultMessage: 'Oldest', - description: 'Sort by oldest', - }, 'authz.libraries.team.table.clearFilters': { id: 'authz.libraries.team.table.clearFilters', defaultMessage: 'Clear filters', diff --git a/src/authz-module/libraries-manager/components/messages.ts b/src/authz-module/libraries-manager/components/messages.ts new file mode 100644 index 00000000..d73643a0 --- /dev/null +++ b/src/authz-module/libraries-manager/components/messages.ts @@ -0,0 +1,36 @@ +import { defineMessages } from '@edx/frontend-platform/i18n'; + +const messages = defineMessages({ + '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', + description: 'Libraries AuthZ cancel button title', + }, + 'libraries.authz.manage.saving.button': { + id: 'libraries.authz.manage.saving.button', + defaultMessage: 'Saving...', + description: 'Libraries AuthZ saving button title', + }, + 'libraries.authz.manage.save.button': { + id: 'libraries.authz.manage.save.button', + defaultMessage: 'Save', + description: 'Libraries AuthZ save button title', + }, + 'libraries.authz.manage.assign.role.success': { + id: 'libraries.authz.manage.assign.role.success', + defaultMessage: 'Role added successfully.', + description: 'Libraries AuthZ assign role success message', + }, +}); + +export default messages; From ce5cabe5c7b198904c3188978a563c2907db12b7 Mon Sep 17 00:00:00 2001 From: Diana Olarte Date: Thu, 23 Oct 2025 14:58:36 +1100 Subject: [PATCH 14/15] style: remove comment in API --- src/authz-module/data/api.ts | 1 - 1 file changed, 1 deletion(-) diff --git a/src/authz-module/data/api.ts b/src/authz-module/data/api.ts index 772b5658..0c59453b 100644 --- a/src/authz-module/data/api.ts +++ b/src/authz-module/data/api.ts @@ -33,7 +33,6 @@ export interface AssignTeamMembersRoleRequest { scope: string; } -// TODO: replece api path once is created export const getTeamMembers = async (object: string, querySettings: QuerySettings): Promise => { const url = new URL(getApiUrl(`/api/authz/v1/roles/users/?scope=${object}`)); From 0962368b6c94284aa7e1303893cc6a3cfa05f7dc Mon Sep 17 00:00:00 2001 From: Diana Olarte Date: Fri, 24 Oct 2025 01:27:31 +1100 Subject: [PATCH 15/15] fix: adress debaunce time --- .../components/TeamTable/components/SortDropdown.tsx | 6 ++---- .../libraries-manager/components/TeamTable/index.tsx | 8 ++++++-- 2 files changed, 8 insertions(+), 6 deletions(-) diff --git a/src/authz-module/libraries-manager/components/TeamTable/components/SortDropdown.tsx b/src/authz-module/libraries-manager/components/TeamTable/components/SortDropdown.tsx index e46d9523..7f2bef04 100644 --- a/src/authz-module/libraries-manager/components/TeamTable/components/SortDropdown.tsx +++ b/src/authz-module/libraries-manager/components/TeamTable/components/SortDropdown.tsx @@ -35,8 +35,7 @@ const SortDropdown: FC = () => { 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' }), - // eslint-disable-next-line react-hooks/exhaustive-deps - }), []); + }), [intl]); const currentSort = useMemo(() => { if (!state?.sortBy?.length) { return undefined; } @@ -63,8 +62,7 @@ const SortDropdown: FC = () => { ...option, label: SORT_LABELS[key], })), - // eslint-disable-next-line react-hooks/exhaustive-deps - [], + [SORT_LABELS], ); const currentSortLabel = sortOrder ? SORT_LABELS[sortOrder] : 'Sort'; diff --git a/src/authz-module/libraries-manager/components/TeamTable/index.tsx b/src/authz-module/libraries-manager/components/TeamTable/index.tsx index 228157ec..8ee94633 100644 --- a/src/authz-module/libraries-manager/components/TeamTable/index.tsx +++ b/src/authz-module/libraries-manager/components/TeamTable/index.tsx @@ -1,4 +1,4 @@ -import { useMemo } from 'react'; +import { useEffect, useMemo } from 'react'; import { useNavigate } from 'react-router-dom'; import debounce from 'lodash.debounce'; import { useIntl } from '@edx/frontend-platform/i18n'; @@ -80,6 +80,10 @@ const TeamTable = () => { [roles], ); + const fetchData = useMemo(() => debounce(handleTableFetch, 500), [handleTableFetch]); + + useEffect(() => () => fetchData.cancel(), [fetchData]); + return ( { manualSortBy defaultColumnValues={{ Filter: TextFilter }} numBreakoutFilters={3} - fetchData={debounce(handleTableFetch, 1000)} + fetchData={fetchData} data={rows} itemCount={teamMembers?.count || 0} pageCount={pageCount}