diff --git a/src/components/Admin/Admin.test.jsx b/src/components/Admin/Admin.test.jsx index f03ffcdb1c..32ac2967bc 100644 --- a/src/components/Admin/Admin.test.jsx +++ b/src/components/Admin/Admin.test.jsx @@ -480,38 +480,6 @@ describe('', () => { csvFetchMethod: 'fetchCourseEnrollments', csvFetchParams: [enterpriseId, {}, { csv: true }], }, - 'registered-unenrolled-learners': { - csvFetchMethod: 'fetchUnenrolledRegisteredLearners', - csvFetchParams: [enterpriseId, {}, { csv: true }], - }, - 'enrolled-learners': { - csvFetchMethod: 'fetchEnrolledLearners', - csvFetchParams: [enterpriseId, {}, { csv: true }], - }, - 'enrolled-learners-inactive-courses': { - csvFetchMethod: 'fetchEnrolledLearnersForInactiveCourses', - csvFetchParams: [enterpriseId, {}, { csv: true }], - }, - 'learners-active-week': { - csvFetchMethod: 'fetchCourseEnrollments', - csvFetchParams: [enterpriseId, { learnerActivity: 'active_past_week' }, { csv: true }], - }, - 'learners-inactive-week': { - csvFetchMethod: 'fetchCourseEnrollments', - csvFetchParams: [enterpriseId, { learnerActivity: 'inactive_past_week' }, { csv: true }], - }, - 'learners-inactive-month': { - csvFetchMethod: 'fetchCourseEnrollments', - csvFetchParams: [enterpriseId, { learnerActivity: 'inactive_past_month' }, { csv: true }], - }, - 'completed-learners': { - csvFetchMethod: 'fetchCompletedLearners', - csvFetchParams: [enterpriseId, {}, { csv: true }], - }, - 'completed-learners-week': { - csvFetchMethod: 'fetchCourseEnrollments', - csvFetchParams: [enterpriseId, { passedDate: 'last_week' }, { csv: true }], - }, }; afterEach(() => { diff --git a/src/components/Admin/DownloadButtonWrapper.jsx b/src/components/Admin/DownloadButtonWrapper.jsx new file mode 100644 index 0000000000..8b1014acb3 --- /dev/null +++ b/src/components/Admin/DownloadButtonWrapper.jsx @@ -0,0 +1,50 @@ +import PropTypes from 'prop-types'; +import DownloadCsvButton from '../../containers/DownloadCsvButton'; +import { useTableData } from './TableDataContext'; + +const DownloadButtonWrapper = ({ + tableMetadata, + actionSlug, + downloadButtonLabel, + isTableDataMissing, +}) => { + const { tablesWithData } = useTableData(); + + // Identify tables that rely on the useTableData hook and have been migrated to the new DataTable component. + const isTableUsingDataState = [ + 'learners-active-week', + 'learners-inactive-week', + 'learners-inactive-month', + 'registered-unenrolled-learners', + 'enrolled-learners-inactive-courses', + 'enrolled-learners', + 'completed-learners', + 'completed-learners-week', + ].includes(actionSlug); + + return ( + + ); +}; + +DownloadButtonWrapper.propTypes = { + tableMetadata: PropTypes.shape({ + csvButtonId: PropTypes.string, + csvFetchMethod: PropTypes.func, + }).isRequired, + actionSlug: PropTypes.string, + downloadButtonLabel: PropTypes.string, + isTableDataMissing: PropTypes.func.isRequired, +}; + +DownloadButtonWrapper.defaultProps = { + actionSlug: undefined, + downloadButtonLabel: undefined, +}; + +export default DownloadButtonWrapper; diff --git a/src/components/Admin/TableDataContext.jsx b/src/components/Admin/TableDataContext.jsx new file mode 100644 index 0000000000..eaba6a2455 --- /dev/null +++ b/src/components/Admin/TableDataContext.jsx @@ -0,0 +1,37 @@ +import { + createContext, useContext, useState, useMemo, +} from 'react'; +import PropTypes from 'prop-types'; + +const TableDataContext = createContext({ + tablesWithData: {}, + setTableHasData: () => {}, +}); + +export const TableDataProvider = ({ children }) => { + const [tablesWithData, setTablesWithData] = useState({}); + + const setTableHasData = (tableId, hasData) => { + setTablesWithData(prev => ({ + ...prev, + [tableId]: hasData, + })); + }; + + const value = useMemo(() => ({ + tablesWithData, + setTableHasData, + }), [tablesWithData]); + + return ( + + {children} + + ); +}; + +TableDataProvider.propTypes = { + children: PropTypes.node.isRequired, +}; + +export const useTableData = () => useContext(TableDataContext); diff --git a/src/components/Admin/index.jsx b/src/components/Admin/index.jsx index 6b9420a36e..c6e112e6d8 100644 --- a/src/components/Admin/index.jsx +++ b/src/components/Admin/index.jsx @@ -18,7 +18,6 @@ import EnrolledLearnersForInactiveCoursesTable from '../EnrolledLearnersForInact import CompletedLearnersTable from '../CompletedLearnersTable'; import PastWeekPassedLearnersTable from '../PastWeekPassedLearnersTable'; import LearnerActivityTable from '../LearnerActivityTable'; -import DownloadCsvButton from '../../containers/DownloadCsvButton'; import AdminCards from '../../containers/AdminCards'; import AdminSearchForm from './AdminSearchForm'; import EnterpriseAppSkeleton from '../EnterpriseApp/EnterpriseAppSkeleton'; @@ -34,6 +33,8 @@ import AIAnalyticsSummary from './AIAnalyticsSummary'; import AIAnalyticsSummarySkeleton from './AIAnalyticsSummarySkeleton'; import BudgetExpiryAlertAndModal from '../BudgetExpiryAlertAndModal'; import ModuleActivityReport from './tabs/ModuleActivityReport'; +import { TableDataProvider } from './TableDataContext'; +import DownloadButtonWrapper from './DownloadButtonWrapper'; class Admin extends React.Component { constructor(props) { @@ -108,7 +109,7 @@ class Admin extends React.Component { defaultMessage: 'Registered Learners Not Yet Enrolled in a Course', description: 'Report title for registered learners not yet enrolled in a course', }), - component: , + component: , csvFetchMethod: () => ( EnterpriseDataApiService.fetchUnenrolledRegisteredLearners( enterpriseId, @@ -124,7 +125,7 @@ class Admin extends React.Component { defaultMessage: 'Number of Courses Enrolled by Learners', description: 'Report title for number of courses enrolled by learners', }), - component: , + component: , csvFetchMethod: () => ( EnterpriseDataApiService.fetchEnrolledLearners(enterpriseId, {}, { csv: true }) ), @@ -141,7 +142,7 @@ class Admin extends React.Component { defaultMessage: 'Learners who have completed all of their courses and/or courses have ended.', description: 'Report description for learners not enrolled in an active course', }), - component: , + component: , csvFetchMethod: () => ( EnterpriseDataApiService.fetchEnrolledLearnersForInactiveCourses( enterpriseId, @@ -220,7 +221,7 @@ class Admin extends React.Component { defaultMessage: 'Number of Courses Completed by Learner', description: 'Report title for number of courses completed by learners', }), - component: , + component: , csvFetchMethod: () => ( EnterpriseDataApiService.fetchCompletedLearners(enterpriseId, {}, { csv: true }) ), @@ -237,7 +238,7 @@ class Admin extends React.Component { defaultMessage: 'Past Week', description: 'Report title for number of courses completed by learners in past week', }), - component: , + component: , csvFetchMethod: () => ( EnterpriseDataApiService.fetchCourseEnrollments( enterpriseId, @@ -264,11 +265,7 @@ class Admin extends React.Component { return tableData && tableData.data; } - displaySearchBar() { - return !this.props.actionSlug; - } - - isTableDataMissing(id) { + isTableDataMissing = (id) => { const tableData = this.getTableData(id); if (!tableData) { return true; @@ -276,6 +273,10 @@ class Admin extends React.Component { const isTableLoading = tableData.loading; const isTableEmpty = tableData.results && !tableData.results.length; return isTableLoading || isTableEmpty; + }; + + displaySearchBar() { + return !this.props.actionSlug; } hasAnalyticsData() { @@ -313,11 +314,11 @@ class Admin extends React.Component { } return ( - ); } @@ -528,24 +529,25 @@ class Admin extends React.Component { /> )} - { - this.setState({ activeTab: tab }); - }} - > - + { + this.setState({ activeTab: tab }); + }} > -
-
- {!error && !loading && !this.hasEmptyData() && ( + +
+
+ {!error && !loading && !this.hasEmptyData() && ( <>
@@ -563,27 +565,28 @@ class Admin extends React.Component { /> )} - )} - {csvErrorMessage && this.renderCsvErrorMessage(csvErrorMessage)} -
- {enterpriseId && tableMetadata.component} + )} + {csvErrorMessage && this.renderCsvErrorMessage(csvErrorMessage)} +
+ {enterpriseId && tableMetadata.component} +
-
- - -
- -
-
- + + +
+ +
+
+ +
diff --git a/src/components/CompletedLearnersTable/CompletedLearnersTable.test.jsx b/src/components/CompletedLearnersTable/CompletedLearnersTable.test.jsx index ece4178180..a0b8396911 100644 --- a/src/components/CompletedLearnersTable/CompletedLearnersTable.test.jsx +++ b/src/components/CompletedLearnersTable/CompletedLearnersTable.test.jsx @@ -1,52 +1,232 @@ import React from 'react'; -import { MemoryRouter } from 'react-router-dom'; -import renderer from 'react-test-renderer'; +import { createMemoryHistory } from 'history'; +import { Router } from 'react-router-dom'; import { IntlProvider } from '@edx/frontend-platform/i18n'; import configureMockStore from 'redux-mock-store'; import thunk from 'redux-thunk'; import { Provider } from 'react-redux'; +import { act } from '@testing-library/react'; +import { mount } from 'enzyme'; import CompletedLearnersTable from '.'; +import useCompletedLearners from './data/hooks/useCompletedLearners'; +import { PAGE_SIZE } from '../../data/constants/table'; +import { mockCompletedLearners, mockEmptyLearners } from './data/tests/constants'; + +// Mock the hooks +jest.mock('./data/hooks/useCompletedLearners', () => jest.fn()); +jest.mock('../Admin/TableDataContext', () => ({ + useTableData: () => ({ + setTableHasData: jest.fn(), + }), +})); const mockStore = configureMockStore([thunk]); -const enterpriseId = 'test-enterprise'; -const store = mockStore({ - portalConfiguration: { - enterpriseId, - }, - table: { - 'completed-learners': { - data: { - results: [], - current_page: 1, - num_pages: 1, - }, - ordering: null, - loading: false, - error: null, - }, - }, -}); -const CompletedLearnersWrapper = props => ( - - - - - - - -); +// Mock implementations +const mockFetchData = jest.fn().mockResolvedValue({}); +const mockFetchDataImmediate = jest.fn(); describe('CompletedLearnersTable', () => { - it('renders empty state correctly', () => { - const tree = renderer - .create(( - - )) - .toJSON(); - expect(tree).toMatchSnapshot(); + const enterpriseId = 'test-enterprise-id'; + const tableId = 'completed-learners'; + + const store = mockStore({ + portalConfiguration: { + enterpriseId, + }, + }); + + const defaultProps = { + id: tableId, + }; + + beforeEach(() => { + // Setup default mock implementation + useCompletedLearners.mockReturnValue({ + isLoading: false, + data: mockCompletedLearners, + fetchData: mockFetchData, + fetchDataImmediate: mockFetchDataImmediate, + hasData: true, + }); + }); + + afterEach(() => { + jest.clearAllMocks(); + }); + + const CompletedLearnersTableWrapper = (props = {}) => { + const history = createMemoryHistory(); + return ( + + + + + + + + ); + }; + + it('renders the table with learner data', () => { + const wrapper = mount(); + + // Check if table exists + const table = wrapper.find('DataTable'); + expect(table.exists()).toBe(true); + + // Verify DataTable props + expect(table.prop('id')).toBe(tableId); + expect(table.prop('data')).toEqual(mockCompletedLearners.results); + expect(table.prop('itemCount')).toBe(mockCompletedLearners.itemCount); + expect(table.prop('pageCount')).toBe(mockCompletedLearners.pageCount); + expect(table.prop('isLoading')).toBe(false); + + // Verify columns are correctly configured + expect(table.prop('columns').length).toBe(2); + expect(table.prop('columns')[0].accessor).toBe('userEmail'); + expect(table.prop('columns')[1].accessor).toBe('completedCourses'); + }); + + it('renders empty table when no data is available', () => { + useCompletedLearners.mockReturnValue({ + isLoading: false, + data: mockEmptyLearners, + fetchData: mockFetchData, + fetchDataImmediate: mockFetchDataImmediate, + hasData: false, + }); + + const wrapper = mount(); + + const table = wrapper.find('DataTable'); + expect(table.exists()).toBe(true); + expect(table.prop('data')).toEqual([]); + expect(table.prop('itemCount')).toBe(0); + expect(table.prop('pageCount')).toBe(0); + }); + + it('shows loading state when data is being fetched', () => { + useCompletedLearners.mockReturnValue({ + isLoading: true, + data: mockEmptyLearners, + fetchData: mockFetchData, + fetchDataImmediate: mockFetchDataImmediate, + hasData: false, + }); + + const wrapper = mount(); + + const table = wrapper.find('DataTable'); + expect(table.prop('isLoading')).toBe(true); + }); + + it('fetches data immediately on mount', () => { + mount(); + + expect(mockFetchDataImmediate).toHaveBeenCalledTimes(1); + expect(mockFetchDataImmediate).toHaveBeenCalledWith( + { + pageIndex: 0, + pageSize: PAGE_SIZE, + sortBy: [], + }, + true, + ); + }); + + it('uses URL query parameters for initial page', () => { + const history = createMemoryHistory(); + history.push(`?${tableId}-page=3`); // Set page 3 in URL + + const wrapper = mount( + + + + + + + , + ); + + const table = wrapper.find('DataTable'); + // Check that initialState has pageIndex set to 2 (0-based index for page 3) + expect(table.prop('initialState').pageIndex).toBe(2); + + // Check that fetchDataImmediate was called with pageIndex 2 + expect(mockFetchDataImmediate).toHaveBeenCalledWith( + expect.objectContaining({ + pageIndex: 2, + }), + true, + ); + }); + + it('updates URL when page changes', async () => { + const history = createMemoryHistory(); + jest.spyOn(history, 'push'); + + const wrapper = mount( + + + + + + + , + ); + + // Simulate page change by calling fetchData prop with new table state + const table = wrapper.find('DataTable'); + await act(async () => { + await table.prop('fetchData')({ + pageIndex: 2, // Navigate to page 3 (0-indexed) + pageSize: PAGE_SIZE, + sortBy: [], + }); + }); + + // Check that fetchData was called + expect(mockFetchData).toHaveBeenCalledWith({ + pageIndex: 2, + pageSize: PAGE_SIZE, + sortBy: [], + }); + }); + + it('renders UserEmail component correctly', () => { + const wrapper = mount(); + + // Find columns in the DataTable props + const columns = wrapper.find('DataTable').prop('columns'); + const emailColumn = columns.find(col => col.accessor === 'userEmail'); + + // Test the Cell renderer with a sample row + const testRow = { + original: { + userEmail: 'test@example.com', + }, + }; + + const emailCell = mount( + + {emailColumn.Cell({ row: testRow })} + , + ); + + expect(emailCell.find('[data-hj-suppress]').text()).toBe('test@example.com'); + }); + + it('renders completedCourses column correctly', () => { + const wrapper = mount(); + + // Find columns in the DataTable props + const columns = wrapper.find('DataTable').prop('columns'); + const completedCoursesColumn = columns.find(col => col.accessor === 'completedCourses'); + expect(completedCoursesColumn).toBeDefined(); + + // Verify column header text + expect(completedCoursesColumn.Header).toBe('Total Course Completed Count'); }); }); diff --git a/src/components/CompletedLearnersTable/__snapshots__/CompletedLearnersTable.test.jsx.snap b/src/components/CompletedLearnersTable/__snapshots__/CompletedLearnersTable.test.jsx.snap deleted file mode 100644 index 722c7b349f..0000000000 --- a/src/components/CompletedLearnersTable/__snapshots__/CompletedLearnersTable.test.jsx.snap +++ /dev/null @@ -1,37 +0,0 @@ -// Jest Snapshot v1, https://goo.gl/fbAQLP - -exports[`CompletedLearnersTable renders empty state correctly 1`] = ` -
- - - - - -
-
- There are no results. -
-
-
-`; diff --git a/src/components/CompletedLearnersTable/data/hooks/useCompletedLearners.js b/src/components/CompletedLearnersTable/data/hooks/useCompletedLearners.js new file mode 100644 index 0000000000..a3df738e59 --- /dev/null +++ b/src/components/CompletedLearnersTable/data/hooks/useCompletedLearners.js @@ -0,0 +1,11 @@ +import usePaginatedTableData from '../../../../hooks/usePaginatedTableData'; +import EnterpriseDataApiService from '../../../../data/services/EnterpriseDataApiService'; + +const useCompletedLearners = (enterpriseId, tableId, apiFieldsForColumnAccessor) => usePaginatedTableData({ + enterpriseId, + tableId, + apiFieldsForColumnAccessor, + fetchFunction: EnterpriseDataApiService.fetchCompletedLearners, +}); + +export default useCompletedLearners; diff --git a/src/components/CompletedLearnersTable/data/hooks/useCompletedLearners.test.js b/src/components/CompletedLearnersTable/data/hooks/useCompletedLearners.test.js new file mode 100644 index 0000000000..381c49bf14 --- /dev/null +++ b/src/components/CompletedLearnersTable/data/hooks/useCompletedLearners.test.js @@ -0,0 +1,175 @@ +import { renderHook, act } from '@testing-library/react-hooks'; +import useCompletedLearners from './useCompletedLearners'; +import EnterpriseDataApiService from '../../../../data/services/EnterpriseDataApiService'; + +jest.mock('../../../../data/services/EnterpriseDataApiService'); + +const mockApiFields = { + userEmail: { key: 'user_email' }, + completedCourses: { key: 'completed_courses' }, +}; + +const enterpriseId = 'enterprise-123'; +const tableId = 'completed-learners'; + +describe('useCompletedLearners', () => { + const mockResponse = { + data: { + count: 2, + results: [ + { + user_email: 'student1@example.com', + completed_courses: 5, + }, + { + user_email: 'student2@example.com', + completed_courses: 3, + }, + ], + }, + }; + + const emptyResponse = { + data: { + count: 0, + results: [], + }, + }; + + beforeEach(() => { + jest.clearAllMocks(); + }); + + it('fetches and returns completed learners data successfully', async () => { + EnterpriseDataApiService.fetchCompletedLearners.mockResolvedValueOnce(mockResponse); + + const { result } = renderHook(() => useCompletedLearners(enterpriseId, tableId, mockApiFields)); + + await act(async () => { + await result.current.fetchDataImmediate({ + pageIndex: 0, + pageSize: 10, + sortBy: [], + }); + }); + + expect(EnterpriseDataApiService.fetchCompletedLearners).toHaveBeenCalledWith(enterpriseId, { + page: 1, + pageSize: 10, + }); + + expect(result.current.data.results).toHaveLength(2); + expect(result.current.data.itemCount).toBe(2); + expect(result.current.hasData).toBe(true); + expect(result.current.isLoading).toBe(false); + }); + + it('handles empty data response', async () => { + EnterpriseDataApiService.fetchCompletedLearners.mockResolvedValueOnce(emptyResponse); + + const { result } = renderHook(() => useCompletedLearners(enterpriseId, tableId, mockApiFields)); + + await act(async () => { + await result.current.fetchDataImmediate({ + pageIndex: 0, + pageSize: 10, + sortBy: [], + }); + }); + + expect(result.current.data.results).toHaveLength(0); + expect(result.current.hasData).toBe(false); + }); + + it('sets loading state correctly during fetch', async () => { + let resolvePromise; + EnterpriseDataApiService.fetchCompletedLearners.mockReturnValueOnce( + new Promise((resolve) => { + resolvePromise = resolve; + }), + ); + + const { result } = renderHook(() => useCompletedLearners(enterpriseId, tableId, mockApiFields)); + + act(() => { + result.current.fetchDataImmediate({ + pageIndex: 0, + pageSize: 5, + sortBy: [], + }); + }); + + expect(result.current.isLoading).toBe(true); + + await act(async () => { + resolvePromise(mockResponse); + }); + + expect(result.current.isLoading).toBe(false); + }); + + it('logs error when fetch fails', async () => { + const error = new Error('API failure'); + EnterpriseDataApiService.fetchCompletedLearners.mockRejectedValueOnce(error); + + const { result } = renderHook(() => useCompletedLearners(enterpriseId, tableId, mockApiFields)); + + await act(async () => { + await result.current.fetchDataImmediate({ + pageIndex: 1, + pageSize: 5, + sortBy: [], + filters: {}, + }); + }); + + expect(result.current.data.results).toHaveLength(0); + expect(result.current.isLoading).toBe(false); + expect(result.current.hasData).toBe(false); + }); + + it('applies sorting when sortBy is provided', async () => { + EnterpriseDataApiService.fetchCompletedLearners.mockResolvedValueOnce(mockResponse); + + const { result } = renderHook(() => useCompletedLearners(enterpriseId, tableId, mockApiFields)); + + await act(async () => { + await result.current.fetchDataImmediate({ + pageIndex: 0, + pageSize: 10, + sortBy: [{ id: 'completedCourses', desc: true }], + }); + }); + + expect(EnterpriseDataApiService.fetchCompletedLearners).toHaveBeenCalledWith( + enterpriseId, + expect.objectContaining({ + ordering: '-completed_courses', + }), + ); + }); + + it('properly transforms API data to component format', async () => { + EnterpriseDataApiService.fetchCompletedLearners.mockResolvedValueOnce(mockResponse); + + const { result } = renderHook(() => useCompletedLearners(enterpriseId, tableId, mockApiFields)); + + await act(async () => { + await result.current.fetchDataImmediate({ + pageIndex: 0, + pageSize: 10, + sortBy: [], + }); + }); + + expect(result.current.data.results[0]).toEqual({ + userEmail: 'student1@example.com', + completedCourses: 5, + }); + + expect(result.current.data.results[1]).toEqual({ + userEmail: 'student2@example.com', + completedCourses: 3, + }); + }); +}); diff --git a/src/components/CompletedLearnersTable/data/tests/constants.js b/src/components/CompletedLearnersTable/data/tests/constants.js new file mode 100644 index 0000000000..eaa6abd177 --- /dev/null +++ b/src/components/CompletedLearnersTable/data/tests/constants.js @@ -0,0 +1,15 @@ +// Mock data +export const mockCompletedLearners = { + results: [ + { userEmail: 'learner1@example.com', completedCourses: 5 }, + { userEmail: 'learner2@example.com', completedCourses: 3 }, + ], + itemCount: 2, + pageCount: 1, +}; + +export const mockEmptyLearners = { + results: [], + itemCount: 0, + pageCount: 0, +}; diff --git a/src/components/CompletedLearnersTable/index.jsx b/src/components/CompletedLearnersTable/index.jsx index 87bcda6bdf..a4efb1b790 100644 --- a/src/components/CompletedLearnersTable/index.jsx +++ b/src/components/CompletedLearnersTable/index.jsx @@ -1,49 +1,127 @@ -import React from 'react'; - +/* eslint-disable react-hooks/exhaustive-deps */ +import { useMemo, useCallback, useEffect } from 'react'; +import PropTypes from 'prop-types'; +import { connect } from 'react-redux'; import { useIntl } from '@edx/frontend-platform/i18n'; +import { DataTable, TextFilter } from '@openedx/paragon'; +import { useLocation, useNavigate } from 'react-router-dom'; + +import { updateUrlWithPageNumber } from '../../utils'; +import { PAGE_SIZE } from '../../data/constants/table'; +import useCompletedLearners from './data/hooks/useCompletedLearners'; +import { useTableData } from '../Admin/TableDataContext'; -import TableContainer from '../../containers/TableContainer'; -import EnterpriseDataApiService from '../../data/services/EnterpriseDataApiService'; +const FilterStatus = (rest) => ; -const CompletedLearnersTable = () => { +const UserEmail = ({ row }) => ( + {row.original.userEmail} +); + +UserEmail.propTypes = { + row: PropTypes.shape({ + original: PropTypes.shape({ + userEmail: PropTypes.string, + }).isRequired, + }).isRequired, +}; + +const CompletedLearnersTable = ({ id, enterpriseId }) => { const intl = useIntl(); + const location = useLocation(); + const navigate = useNavigate(); + const { setTableHasData } = useTableData(); + + // Parse the current page from URL query parameters - adjust for zero-based indexing + const queryParams = useMemo(() => new URLSearchParams(location.search), [location.search]); + const pageFromUrl = parseInt(queryParams.get(`${id}-page`), 10) || 1; // Default to page 1 in URL + const currentPageFromUrl = pageFromUrl - 1; // Convert to zero-based for DataTable + + const apiFieldsForColumnAccessor = useMemo(() => ({ + userEmail: { key: 'user_email' }, + completedCourses: { key: 'completed_courses' }, + }), []); - const tableColumns = [ + const columns = [ { - label: intl.formatMessage({ + Header: intl.formatMessage({ id: 'admin.portal.lpr.completed.learners.table.user_email.column.heading', defaultMessage: 'Email', description: 'Column heading for the user email column in the completed learners table', }), - key: 'user_email', - columnSortable: true, + accessor: 'userEmail', + Cell: UserEmail, }, { - label: intl.formatMessage({ + Header: intl.formatMessage({ id: 'admin.portal.lpr.completed.learned.table.completed_courses.column.heading', defaultMessage: 'Total Course Completed Count', description: 'Column heading for the completed courses column in the completed learners table', }), - key: 'completed_courses', - columnSortable: true, + accessor: 'completedCourses', }, ]; - const formatLearnerData = learners => learners.map(learner => ({ - ...learner, - user_email: {learner.user_email}, - })); + const { + isLoading, + data: completedLearners, + fetchData: fetchCompletedLearners, + fetchDataImmediate, + hasData, + } = useCompletedLearners(enterpriseId, id, apiFieldsForColumnAccessor); + + useEffect(() => { + fetchDataImmediate({ + pageIndex: currentPageFromUrl, + pageSize: PAGE_SIZE, + sortBy: [], + }, true); + }, []); + + // Update context when data status changes + useEffect(() => { + setTableHasData(id, hasData); + }, [id, hasData]); + + const fetchTableData = useCallback((tableState) => { + const newPageForUrl = tableState.pageIndex + 1; // Convert zero-based index to one-based for URL + updateUrlWithPageNumber(id, newPageForUrl, location, navigate); + + return fetchCompletedLearners(tableState); + }, [fetchCompletedLearners]); return ( - ); }; -export default CompletedLearnersTable; +CompletedLearnersTable.propTypes = { + id: PropTypes.string.isRequired, + enterpriseId: PropTypes.string.isRequired, +}; + +const mapStateToProps = state => ({ + enterpriseId: state.portalConfiguration.enterpriseId, +}); + +export default connect(mapStateToProps)(CompletedLearnersTable); diff --git a/src/components/EnrolledLearnersForInactiveCoursesTable/EnrolledLearnersForInactiveCoursesTable.test.jsx b/src/components/EnrolledLearnersForInactiveCoursesTable/EnrolledLearnersForInactiveCoursesTable.test.jsx index 5274ad4087..851331b427 100644 --- a/src/components/EnrolledLearnersForInactiveCoursesTable/EnrolledLearnersForInactiveCoursesTable.test.jsx +++ b/src/components/EnrolledLearnersForInactiveCoursesTable/EnrolledLearnersForInactiveCoursesTable.test.jsx @@ -1,187 +1,70 @@ import React from 'react'; import { MemoryRouter } from 'react-router-dom'; -import renderer from 'react-test-renderer'; -import configureMockStore from 'redux-mock-store'; import { IntlProvider } from '@edx/frontend-platform/i18n'; +import configureMockStore from 'redux-mock-store'; import thunk from 'redux-thunk'; import { Provider } from 'react-redux'; import { mount } from 'enzyme'; -import EnrolledLearnersForInactiveCoursesTable from '.'; +import EnrolledLearnersForInactiveCoursesTable from './index'; +import useCourseUsers from './data/hooks/useCourseUsers'; +import { mockEnrolledLearnersData, mockEmptyEnrolledLearnersData } from './data/tests/constants'; const enterpriseId = 'test-enterprise'; const mockStore = configureMockStore([thunk]); -const enrolledLearnersForInactiveCoursesEmptyStore = mockStore({ - portalConfiguration: { - enterpriseId, - }, - table: { - 'enrolled-learners-inactive-courses': { - data: { - results: [], - current_page: 1, - num_pages: 1, - }, - ordering: null, - loading: false, - error: null, - }, - }, -}); -const enrolledLearnersForInactiveCoursesStore = mockStore({ + +jest.mock('./data/hooks/useCourseUsers', () => jest.fn()); + +const store = mockStore({ portalConfiguration: { enterpriseId, }, - table: { - 'enrolled-learners-inactive-courses': { - data: { - count: 3, - num_pages: 1, - current_page: 1, - results: [ - { - id: 1, - enterprise_id: '72416e52-8c77-4860-9584-15e5b06220fb', - lms_user_id: 11, - enterprise_user_id: 222, - enterprise_sso_uid: 'harry', - user_account_creation_timestamp: '2015-02-12T23:14:35Z', - user_email: 'test_user_1@example.com', - user_username: 'test_user_1', - user_country_code: 'US', - last_activity_date: '2017-06-23', - enrollment_count: 2, - course_completion_count: 1, - }, - { - id: 1, - enterprise_id: '72416e52-8c77-4860-9584-15e5b06220fb', - lms_user_id: 22, - enterprise_user_id: 333, - enterprise_sso_uid: 'harry', - user_account_creation_timestamp: '2016-05-12T22:14:36Z', - user_email: 'test_user_2@example.com', - user_username: 'test_user_2', - user_country_code: 'US', - last_activity_date: '2018-01-15', - enrollment_count: 5, - course_completion_count: 5, - }, - { - id: 1, - enterprise_id: '72416e52-8c77-4860-9584-15e5b06220fb', - lms_user_id: 33, - enterprise_user_id: 444, - enterprise_sso_uid: 'harry', - user_account_creation_timestamp: '2017-12-12T18:10:15Z', - user_email: 'test_user_3@example.com', - user_username: 'test_user_3', - user_country_code: 'US', - last_activity_date: '2017-11-18', - enrollment_count: 6, - course_completion_count: 4, - }, - ], - next: null, - start: 0, - previous: null, - }, - ordering: null, - loading: false, - error: null, - }, - }, }); -const EnrolledLearnersForInactiveCoursesEmptyTableWrapper = props => ( +const EnrolledLearnersWrapper = props => ( - - - - - -); - -const EnrolledLearnersForInactiveCoursesWrapper = props => ( - - - - + + ); describe('EnrolledLearnersForInactiveCoursesTable', () => { - it('renders empty state correctly', () => { - const tree = renderer - .create(( - - )) - .toJSON(); - expect(tree).toMatchSnapshot(); + beforeEach(() => { + useCourseUsers.mockReturnValue(mockEnrolledLearnersData); }); - it('renders enrolled learners for inactive courses table correctly', () => { - const tree = renderer - .create(( - - )) - .toJSON(); - expect(tree).toMatchSnapshot(); + afterEach(() => { + jest.clearAllMocks(); }); - it('renders enrolled learners for inactive courses table with correct data', () => { - const tableId = 'enrolled-learners-inactive-courses'; - const columnTitles = [ - 'Email', 'Total Course Enrollment Count', 'Total Completed Courses Count', 'Last Activity Date', - ]; - const rowsData = [ - [ - 'test_user_1@example.com', - '2', - '1', - 'June 23, 2017', - ], - [ - 'test_user_2@example.com', - '5', - '5', - 'January 15, 2018', - ], - [ - 'test_user_3@example.com', - '6', - '4', - 'November 18, 2017', - ], - ]; - - const wrapper = mount(( - - )); + it('renders table with correct columns', () => { + const wrapper = mount(); + const table = wrapper.find('[role="table"]'); - // Verify that table has correct number of columns - expect(wrapper.find(`.${tableId} thead th`).length).toEqual(columnTitles.length); + expect(table.exists()).toBe(true); - // Verify only expected columns are shown - wrapper.find(`.${tableId} thead th`).forEach((column, index) => { - expect(column.text()).toContain(columnTitles[index]); - }); + const columnHeaders = table.find('thead th').map(th => th.text()); + expect(columnHeaders).toEqual([ + 'Email', + 'Total Course Enrollment Count', + 'Total Completed Courses Count', + 'Last Activity Date', + ]); + }); - // Verify that table has correct number of rows - expect(wrapper.find(`.${tableId} tbody tr`).length).toEqual(rowsData.length); + it('renders correct number of rows with data', () => { + const wrapper = mount(); + const rows = wrapper.find('tbody tr'); + expect(rows.length).toBe(mockEnrolledLearnersData.data.results.length); + }); - // Verify each row in table has correct data - wrapper.find(`.${tableId} tbody tr`).forEach((row, rowIndex) => { - row.find('td').forEach((cell, colIndex) => { - expect(cell.text()).toEqual(rowsData[rowIndex][colIndex]); - }); - }); + it('renders empty table correctly', () => { + useCourseUsers.mockReturnValue(mockEmptyEnrolledLearnersData); + const wrapper = mount(); + expect(wrapper.find('[role="table"]').exists()).toBe(true); + expect(wrapper.find('tbody tr').length).toBe(0); }); }); diff --git a/src/components/EnrolledLearnersForInactiveCoursesTable/__snapshots__/EnrolledLearnersForInactiveCoursesTable.test.jsx.snap b/src/components/EnrolledLearnersForInactiveCoursesTable/__snapshots__/EnrolledLearnersForInactiveCoursesTable.test.jsx.snap deleted file mode 100644 index 665fd1efa3..0000000000 --- a/src/components/EnrolledLearnersForInactiveCoursesTable/__snapshots__/EnrolledLearnersForInactiveCoursesTable.test.jsx.snap +++ /dev/null @@ -1,380 +0,0 @@ -// Jest Snapshot v1, https://goo.gl/fbAQLP - -exports[`EnrolledLearnersForInactiveCoursesTable renders empty state correctly 1`] = ` -
- - - - - -
-
- There are no results. -
-
-
-`; - -exports[`EnrolledLearnersForInactiveCoursesTable renders enrolled learners for inactive courses table correctly 1`] = ` -
-
-
-
- - - - - - - - - - - - - - - - - - - - - - - - - - - - - -
- - - - - - - -
- - test_user_1@example.com - - - 2 - - 1 - - June 23, 2017 -
- - test_user_2@example.com - - - 5 - - 5 - - January 15, 2018 -
- - test_user_3@example.com - - - 6 - - 4 - - November 18, 2017 -
-
-
-
-
-
- -
-
-
-`; diff --git a/src/components/EnrolledLearnersForInactiveCoursesTable/data/hooks/useCourseUsers.js b/src/components/EnrolledLearnersForInactiveCoursesTable/data/hooks/useCourseUsers.js new file mode 100644 index 0000000000..53ab32e35d --- /dev/null +++ b/src/components/EnrolledLearnersForInactiveCoursesTable/data/hooks/useCourseUsers.js @@ -0,0 +1,11 @@ +import usePaginatedTableData from '../../../../hooks/usePaginatedTableData'; +import EnterpriseDataApiService from '../../../../data/services/EnterpriseDataApiService'; + +const useCourseUsers = (enterpriseId, tableId, apiFieldsForColumnAccessor) => usePaginatedTableData({ + enterpriseId, + tableId, + apiFieldsForColumnAccessor, + fetchFunction: EnterpriseDataApiService.fetchEnrolledLearnersForInactiveCourses, +}); + +export default useCourseUsers; diff --git a/src/components/EnrolledLearnersForInactiveCoursesTable/data/hooks/useCourseUsers.test.js b/src/components/EnrolledLearnersForInactiveCoursesTable/data/hooks/useCourseUsers.test.js new file mode 100644 index 0000000000..defec62bac --- /dev/null +++ b/src/components/EnrolledLearnersForInactiveCoursesTable/data/hooks/useCourseUsers.test.js @@ -0,0 +1,130 @@ +import { renderHook, act } from '@testing-library/react-hooks'; +import useCourseUsers from './useCourseUsers'; +import EnterpriseDataApiService from '../../../../data/services/EnterpriseDataApiService'; + +jest.mock('../../../../data/services/EnterpriseDataApiService'); + +const mockApiFields = { + userEmail: { key: 'user_email' }, + courseTitle: { key: 'course_title' }, +}; + +const enterpriseId = 'enterprise-123'; +const tableId = 'test-table'; + +describe('useCourseUsers', () => { + const mockResponse = { + data: { + count: 2, + results: [ + { + user_email: 'alice@example.com', + course_title: 'Intro to Python', + }, + { + user_email: 'bob@example.com', + course_title: 'Advanced JavaScript', + }, + ], + }, + }; + + const emptyResponse = { + data: { + count: 0, + results: [], + }, + }; + + beforeEach(() => { + jest.clearAllMocks(); + }); + + it('fetches and returns user course data successfully', async () => { + EnterpriseDataApiService.fetchEnrolledLearnersForInactiveCourses.mockResolvedValueOnce(mockResponse); + + const { result } = renderHook(() => useCourseUsers(enterpriseId, tableId, mockApiFields)); + + await act(async () => { + await result.current.fetchDataImmediate({ + pageIndex: 0, + pageSize: 10, + sortBy: [], + }); + }); + + expect(EnterpriseDataApiService.fetchEnrolledLearnersForInactiveCourses).toHaveBeenCalledWith(enterpriseId, { + page: 1, + pageSize: 10, + }); + + expect(result.current.data.results).toHaveLength(2); + expect(result.current.data.itemCount).toBe(2); + expect(result.current.hasData).toBe(true); + expect(result.current.isLoading).toBe(false); + }); + + it('handles empty data response', async () => { + EnterpriseDataApiService.fetchEnrolledLearnersForInactiveCourses.mockResolvedValueOnce(emptyResponse); + + const { result } = renderHook(() => useCourseUsers(enterpriseId, tableId, mockApiFields)); + + await act(async () => { + await result.current.fetchDataImmediate({ + pageIndex: 0, + pageSize: 10, + sortBy: [], + }); + }); + + expect(result.current.data.results).toHaveLength(0); + expect(result.current.hasData).toBe(false); + }); + + it('sets loading state correctly during fetch', async () => { + let resolvePromise; + EnterpriseDataApiService.fetchEnrolledLearnersForInactiveCourses.mockReturnValueOnce( + new Promise((resolve) => { + resolvePromise = resolve; + }), + ); + + const { result } = renderHook(() => useCourseUsers(enterpriseId, tableId, mockApiFields)); + + act(() => { + result.current.fetchDataImmediate({ + pageIndex: 0, + pageSize: 5, + sortBy: [], + }); + }); + + expect(result.current.isLoading).toBe(true); + + await act(async () => { + resolvePromise(mockResponse); + }); + + expect(result.current.isLoading).toBe(false); + }); + + it('logs error when fetch fails', async () => { + const error = new Error('API failure'); + EnterpriseDataApiService.fetchEnrolledLearnersForInactiveCourses.mockRejectedValueOnce(error); + + const { result } = renderHook(() => useCourseUsers(enterpriseId, tableId, mockApiFields)); + + await act(async () => { + await result.current.fetchDataImmediate({ + pageIndex: 1, + pageSize: 5, + sortBy: [], + filters: {}, + }); + }); + + expect(result.current.data.results).toHaveLength(0); + expect(result.current.isLoading).toBe(false); + expect(result.current.hasData).toBe(false); + }); +}); diff --git a/src/components/EnrolledLearnersForInactiveCoursesTable/data/tests/constants.js b/src/components/EnrolledLearnersForInactiveCoursesTable/data/tests/constants.js new file mode 100644 index 0000000000..6bca8f9aaf --- /dev/null +++ b/src/components/EnrolledLearnersForInactiveCoursesTable/data/tests/constants.js @@ -0,0 +1,36 @@ +export const mockEnrolledLearnersData = { + isLoading: false, + hasData: true, + fetchData: jest.fn(), + fetchDataImmediate: jest.fn(), + data: { + results: [ + { + userEmail: 'learner1@example.com', + enrollmentCount: 3, + courseCompletionCount: 2, + lastActivityDate: '2025-01-01T00:00:00Z', + }, + { + userEmail: 'learner2@example.com', + enrollmentCount: 5, + courseCompletionCount: 5, + lastActivityDate: '2025-02-01T00:00:00Z', + }, + ], + itemCount: 2, + pageCount: 1, + }, +}; + +export const mockEmptyEnrolledLearnersData = { + isLoading: false, + hasData: false, + fetchData: jest.fn(), + fetchDataImmediate: jest.fn(), + data: { + results: [], + itemCount: 0, + pageCount: 0, + }, +}; diff --git a/src/components/EnrolledLearnersForInactiveCoursesTable/index.jsx b/src/components/EnrolledLearnersForInactiveCoursesTable/index.jsx index 9a8145e43d..bafee89daf 100644 --- a/src/components/EnrolledLearnersForInactiveCoursesTable/index.jsx +++ b/src/components/EnrolledLearnersForInactiveCoursesTable/index.jsx @@ -1,71 +1,147 @@ -import React from 'react'; - +/* eslint-disable react-hooks/exhaustive-deps */ +import { useMemo, useCallback, useEffect } from 'react'; +import PropTypes from 'prop-types'; import { useIntl } from '@edx/frontend-platform/i18n'; +import { DataTable, TextFilter } from '@openedx/paragon'; +import { connect } from 'react-redux'; +import { useLocation, useNavigate } from 'react-router-dom'; +import useCourseUsers from './data/hooks/useCourseUsers'; +import { PAGE_SIZE } from '../../data/constants/table'; +import { i18nFormatTimestamp, updateUrlWithPageNumber } from '../../utils'; +import { useTableData } from '../Admin/TableDataContext'; + +const FilterStatus = (rest) => ; -import TableContainer from '../../containers/TableContainer'; -import { i18nFormatTimestamp } from '../../utils'; -import EnterpriseDataApiService from '../../data/services/EnterpriseDataApiService'; +const UserEmail = ({ row }) => ( + {row.original.userEmail} +); + +UserEmail.propTypes = { + row: PropTypes.shape({ + original: PropTypes.shape({ + userEmail: PropTypes.string, + }).isRequired, + }).isRequired, +}; -const EnrolledLearnersForInactiveCoursesTable = () => { +const EnrolledLearnersForInactiveCoursesTable = ({ id, enterpriseId }) => { const intl = useIntl(); + const location = useLocation(); + const navigate = useNavigate(); + const { setTableHasData } = useTableData(); + + // Parse the current page from URL query parameters - adjust for zero-based indexing + const queryParams = useMemo(() => new URLSearchParams(location.search), [location.search]); + const pageFromUrl = parseInt(queryParams.get(`${id}-page`), 10) || 1; // Default to page 1 in URL + const currentPageFromUrl = pageFromUrl - 1; // Convert to zero-based for DataTable + + const apiFieldsForColumnAccessor = useMemo(() => ({ + userEmail: { key: 'user_email' }, + enrollmentCount: { key: 'enrollment_count' }, + courseCompletionCount: { key: 'course_completion_count' }, + lastActivityDate: { key: 'last_activity_date' }, + }), []); + + const { + isLoading, + data: courseUsers, + fetchData: fetchCourseUsers, + fetchDataImmediate, + hasData, + } = useCourseUsers(enterpriseId, id, apiFieldsForColumnAccessor); + + // To load data correctly the first time, we use the non-debounced `fetchDataImmediate` + // on initial load to ensure the data is fetched immediately without any delay. + useEffect(() => { + fetchDataImmediate({ + pageIndex: currentPageFromUrl, + pageSize: PAGE_SIZE, + sortBy: [], + }, true); + }, []); - const tableColumns = [ + // Update context when data status changes + useEffect(() => { + setTableHasData(id, hasData); + }, [id, hasData]); + + // Wrap fetchCourseUsers to update the URL when pagination changes + const fetchTableData = useCallback((tableState) => { + const newPageForUrl = tableState.pageIndex + 1; // Convert zero-based index to one-based for URL + updateUrlWithPageNumber(id, newPageForUrl, location, navigate); + + return fetchCourseUsers(tableState); + }, [fetchCourseUsers]); + + const columns = [ { - label: intl.formatMessage({ + Header: intl.formatMessage({ id: 'admin.portal.lpr.enrolled.learners.inactive.courses.table.user_email.column.heading', defaultMessage: 'Email', description: 'Column heading for the user email column in the enrolled learners table for inactive courses', }), - key: 'user_email', - columnSortable: true, + accessor: 'userEmail', + Cell: UserEmail, }, { - label: intl.formatMessage({ + Header: intl.formatMessage({ id: 'admin.portal.lpr.enrolled.learners.inactive.courses.table.enrollment_count.column.heading', defaultMessage: 'Total Course Enrollment Count', description: 'Column heading for the course enrollment count column in the enrolled learners table for inactive courses', }), - key: 'enrollment_count', - columnSortable: true, + accessor: 'enrollmentCount', }, { - label: intl.formatMessage({ + Header: intl.formatMessage({ id: 'admin.portal.lpr.enrolled.learners.inactive.courses.table.course_completion_count.column.heading', defaultMessage: 'Total Completed Courses Count', description: 'Column heading for the completed courses count column in the enrolled learners table for inactive courses', }), - key: 'course_completion_count', - columnSortable: true, + accessor: 'courseCompletionCount', }, { - label: intl.formatMessage({ + Header: intl.formatMessage({ id: 'admin.portal.lpr.enrolled.learners.inactive.courses.table.last_activity_date.column.heading', defaultMessage: 'Last Activity Date', description: 'Column heading for the last activity date column in the enrolled learners table for inactive courses', }), - key: 'last_activity_date', - columnSortable: true, + accessor: 'lastActivityDate', + Cell: ({ row }) => i18nFormatTimestamp({ intl, timestamp: row.values.lastActivityDate }), }, ]; - const formatLearnerData = learners => learners.map(learner => ({ - ...learner, - user_email: {learner.user_email}, - last_activity_date: i18nFormatTimestamp({ - intl, timestamp: learner.last_activity_date, - }), - })); - return ( - ); }; -export default EnrolledLearnersForInactiveCoursesTable; +const mapStateToProps = state => ({ + enterpriseId: state.portalConfiguration.enterpriseId, +}); + +EnrolledLearnersForInactiveCoursesTable.propTypes = { + id: PropTypes.string.isRequired, + enterpriseId: PropTypes.string.isRequired, +}; + +export default connect(mapStateToProps)(EnrolledLearnersForInactiveCoursesTable); diff --git a/src/components/EnrolledLearnersTable/EnrolledLearnersTable.test.jsx b/src/components/EnrolledLearnersTable/EnrolledLearnersTable.test.jsx index 98aab861a4..c6e44a54ad 100644 --- a/src/components/EnrolledLearnersTable/EnrolledLearnersTable.test.jsx +++ b/src/components/EnrolledLearnersTable/EnrolledLearnersTable.test.jsx @@ -1,52 +1,69 @@ import React from 'react'; import { MemoryRouter } from 'react-router-dom'; -import renderer from 'react-test-renderer'; import { IntlProvider } from '@edx/frontend-platform/i18n'; import configureMockStore from 'redux-mock-store'; import thunk from 'redux-thunk'; import { Provider } from 'react-redux'; +import { mount } from 'enzyme'; -import EnrolledLearnersTable from '.'; +import EnrolledLearnersTable from './index'; +import useCourseUsers from './data/hooks/useCourseUsers'; +import { mockEnrolledLearnersData, mockEmptyEnrolledLearnersData } from './data/tests/constants'; -const mockStore = configureMockStore([thunk]); const enterpriseId = 'test-enterprise'; +const mockStore = configureMockStore([thunk]); + +jest.mock('./data/hooks/useCourseUsers', () => jest.fn()); + const store = mockStore({ portalConfiguration: { enterpriseId, }, - table: { - 'enrolled-learners': { - data: { - results: [], - current_page: 1, - num_pages: 1, - }, - ordering: null, - loading: false, - error: null, - }, - }, }); const EnrolledLearnersWrapper = props => ( - + ); describe('EnrolledLearnersTable', () => { - it('renders empty state correctly', () => { - const tree = renderer - .create(( - - )) - .toJSON(); - expect(tree).toMatchSnapshot(); + beforeEach(() => { + useCourseUsers.mockReturnValue(mockEnrolledLearnersData); + }); + + afterEach(() => { + jest.clearAllMocks(); + }); + + it('renders table with correct columns', () => { + const wrapper = mount(); + const table = wrapper.find('[role="table"]'); + + expect(table.exists()).toBe(true); + + const columnHeaders = table.find('thead th').map(th => th.text()); + expect(columnHeaders).toEqual([ + 'Email', + 'Account Created', + 'Total Course Enrollment Count', + ]); + }); + + it('renders correct number of rows with data', () => { + const wrapper = mount(); + const rows = wrapper.find('tbody tr'); + expect(rows.length).toBe(mockEnrolledLearnersData.data.results.length); + }); + + it('renders empty table correctly', () => { + useCourseUsers.mockReturnValue(mockEmptyEnrolledLearnersData); + const wrapper = mount(); + expect(wrapper.find('[role="table"]').exists()).toBe(true); + expect(wrapper.find('tbody tr').length).toBe(0); }); }); diff --git a/src/components/EnrolledLearnersTable/__snapshots__/EnrolledLearnersTable.test.jsx.snap b/src/components/EnrolledLearnersTable/__snapshots__/EnrolledLearnersTable.test.jsx.snap deleted file mode 100644 index 829f004573..0000000000 --- a/src/components/EnrolledLearnersTable/__snapshots__/EnrolledLearnersTable.test.jsx.snap +++ /dev/null @@ -1,37 +0,0 @@ -// Jest Snapshot v1, https://goo.gl/fbAQLP - -exports[`EnrolledLearnersTable renders empty state correctly 1`] = ` -
- - - - - -
-
- There are no results. -
-
-
-`; diff --git a/src/components/EnrolledLearnersTable/data/hooks/useCourseUsers.js b/src/components/EnrolledLearnersTable/data/hooks/useCourseUsers.js new file mode 100644 index 0000000000..e190d279a3 --- /dev/null +++ b/src/components/EnrolledLearnersTable/data/hooks/useCourseUsers.js @@ -0,0 +1,11 @@ +import usePaginatedTableData from '../../../../hooks/usePaginatedTableData'; +import EnterpriseDataApiService from '../../../../data/services/EnterpriseDataApiService'; + +const useCourseUsers = (enterpriseId, tableId, apiFieldsForColumnAccessor) => usePaginatedTableData({ + enterpriseId, + tableId, + apiFieldsForColumnAccessor, + fetchFunction: EnterpriseDataApiService.fetchEnrolledLearners, +}); + +export default useCourseUsers; diff --git a/src/components/EnrolledLearnersTable/data/hooks/useCourseUsers.test.js b/src/components/EnrolledLearnersTable/data/hooks/useCourseUsers.test.js new file mode 100644 index 0000000000..0ab6c30ad1 --- /dev/null +++ b/src/components/EnrolledLearnersTable/data/hooks/useCourseUsers.test.js @@ -0,0 +1,130 @@ +import { renderHook, act } from '@testing-library/react-hooks'; +import useCourseUsers from './useCourseUsers'; +import EnterpriseDataApiService from '../../../../data/services/EnterpriseDataApiService'; + +jest.mock('../../../../data/services/EnterpriseDataApiService'); + +const mockApiFields = { + userEmail: { key: 'user_email' }, + courseTitle: { key: 'course_title' }, +}; + +const enterpriseId = 'enterprise-123'; +const tableId = 'test-table'; + +describe('useCourseUsers', () => { + const mockResponse = { + data: { + count: 2, + results: [ + { + user_email: 'alice@example.com', + enrollment_count: 23, + }, + { + user_email: 'bob@example.com', + enrollment_count: 15, + }, + ], + }, + }; + + const emptyResponse = { + data: { + count: 0, + results: [], + }, + }; + + beforeEach(() => { + jest.clearAllMocks(); + }); + + it('fetches and returns user course data successfully', async () => { + EnterpriseDataApiService.fetchEnrolledLearners.mockResolvedValueOnce(mockResponse); + + const { result } = renderHook(() => useCourseUsers(enterpriseId, tableId, mockApiFields)); + + await act(async () => { + await result.current.fetchDataImmediate({ + pageIndex: 0, + pageSize: 10, + sortBy: [], + }); + }); + + expect(EnterpriseDataApiService.fetchEnrolledLearners).toHaveBeenCalledWith(enterpriseId, { + page: 1, + pageSize: 10, + }); + + expect(result.current.data.results).toHaveLength(2); + expect(result.current.data.itemCount).toBe(2); + expect(result.current.hasData).toBe(true); + expect(result.current.isLoading).toBe(false); + }); + + it('handles empty data response', async () => { + EnterpriseDataApiService.fetchEnrolledLearners.mockResolvedValueOnce(emptyResponse); + + const { result } = renderHook(() => useCourseUsers(enterpriseId, tableId, mockApiFields)); + + await act(async () => { + await result.current.fetchDataImmediate({ + pageIndex: 0, + pageSize: 10, + sortBy: [], + }); + }); + + expect(result.current.data.results).toHaveLength(0); + expect(result.current.hasData).toBe(false); + }); + + it('sets loading state correctly during fetch', async () => { + let resolvePromise; + EnterpriseDataApiService.fetchEnrolledLearners.mockReturnValueOnce( + new Promise((resolve) => { + resolvePromise = resolve; + }), + ); + + const { result } = renderHook(() => useCourseUsers(enterpriseId, tableId, mockApiFields)); + + act(() => { + result.current.fetchDataImmediate({ + pageIndex: 0, + pageSize: 5, + sortBy: [], + }); + }); + + expect(result.current.isLoading).toBe(true); + + await act(async () => { + resolvePromise(mockResponse); + }); + + expect(result.current.isLoading).toBe(false); + }); + + it('logs error when fetch fails', async () => { + const error = new Error('API failure'); + EnterpriseDataApiService.fetchEnrolledLearners.mockRejectedValueOnce(error); + + const { result } = renderHook(() => useCourseUsers(enterpriseId, tableId, mockApiFields)); + + await act(async () => { + await result.current.fetchDataImmediate({ + pageIndex: 1, + pageSize: 5, + sortBy: [], + filters: {}, + }); + }); + + expect(result.current.data.results).toHaveLength(0); + expect(result.current.isLoading).toBe(false); + expect(result.current.hasData).toBe(false); + }); +}); diff --git a/src/components/EnrolledLearnersTable/data/tests/constants.js b/src/components/EnrolledLearnersTable/data/tests/constants.js new file mode 100644 index 0000000000..bbfeac2871 --- /dev/null +++ b/src/components/EnrolledLearnersTable/data/tests/constants.js @@ -0,0 +1,34 @@ +export const mockEnrolledLearnersData = { + isLoading: false, + hasData: true, + fetchData: jest.fn(), + fetchDataImmediate: jest.fn(), + data: { + results: [ + { + userEmail: 'learner1@example.com', + lmsUserCreated: '2024-01-01T00:00:00Z', + enrollmentCount: 3, + }, + { + userEmail: 'learner2@example.com', + lmsUserCreated: '2024-01-02T00:00:00Z', + enrollmentCount: 5, + }, + ], + itemCount: 2, + pageCount: 1, + }, +}; + +export const mockEmptyEnrolledLearnersData = { + isLoading: false, + hasData: false, + fetchData: jest.fn(), + fetchDataImmediate: jest.fn(), + data: { + results: [], + itemCount: 0, + pageCount: 0, + }, +}; diff --git a/src/components/EnrolledLearnersTable/index.jsx b/src/components/EnrolledLearnersTable/index.jsx index e4e40e9103..55c59155ed 100644 --- a/src/components/EnrolledLearnersTable/index.jsx +++ b/src/components/EnrolledLearnersTable/index.jsx @@ -1,62 +1,139 @@ -import React from 'react'; - +/* eslint-disable react-hooks/exhaustive-deps */ +import { useMemo, useCallback, useEffect } from 'react'; +import PropTypes from 'prop-types'; import { useIntl } from '@edx/frontend-platform/i18n'; +import { DataTable, TextFilter } from '@openedx/paragon'; +import { connect } from 'react-redux'; +import { useLocation, useNavigate } from 'react-router-dom'; +import useCourseUsers from './data/hooks/useCourseUsers'; +import { PAGE_SIZE } from '../../data/constants/table'; +import { i18nFormatTimestamp, updateUrlWithPageNumber } from '../../utils'; +import { useTableData } from '../Admin/TableDataContext'; + +const FilterStatus = (rest) => ; -import TableContainer from '../../containers/TableContainer'; -import { i18nFormatTimestamp } from '../../utils'; -import EnterpriseDataApiService from '../../data/services/EnterpriseDataApiService'; +const UserEmail = ({ row }) => ( + {row.original.userEmail} +); + +UserEmail.propTypes = { + row: PropTypes.shape({ + original: PropTypes.shape({ + userEmail: PropTypes.string, + }).isRequired, + }).isRequired, +}; -const EnrolledLearnersTable = () => { +const EnrolledLearnersTable = ({ id, enterpriseId }) => { const intl = useIntl(); + const location = useLocation(); + const navigate = useNavigate(); + const { setTableHasData } = useTableData(); + + // Parse the current page from URL query parameters - adjust for zero-based indexing + const queryParams = useMemo(() => new URLSearchParams(location.search), [location.search]); + const pageFromUrl = parseInt(queryParams.get(`${id}-page`), 10) || 1; // Default to page 1 in URL + const currentPageFromUrl = pageFromUrl - 1; // Convert to zero-based for DataTable + + const apiFieldsForColumnAccessor = useMemo(() => ({ + userEmail: { key: 'user_email' }, + enrollmentCount: { key: 'enrollment_count' }, + courseCompletionCount: { key: 'course_completion_count' }, + lastActivityDate: { key: 'last_activity_date' }, + }), []); + + const { + isLoading, + data: courseUsers, + fetchData: fetchCourseUsers, + fetchDataImmediate, + hasData, + } = useCourseUsers(enterpriseId, id, apiFieldsForColumnAccessor); + + // To load data correctly the first time, we use the non-debounced `fetchDataImmediate` + // on initial load to ensure the data is fetched immediately without any delay. + useEffect(() => { + fetchDataImmediate({ + pageIndex: currentPageFromUrl, + pageSize: PAGE_SIZE, + sortBy: [], + }, true); + }, []); - const tableColumns = [ + // Update context when data status changes + useEffect(() => { + setTableHasData(id, hasData); + }, [id, hasData]); + + // Wrap fetchCourseUsers to update the URL when pagination changes + const fetchTableData = useCallback((tableState) => { + const newPageForUrl = tableState.pageIndex + 1; // Convert zero-based index to one-based for URL + updateUrlWithPageNumber(id, newPageForUrl, location, navigate); + + return fetchCourseUsers(tableState); + }, [fetchCourseUsers]); + + const columns = [ { - label: intl.formatMessage({ + Header: intl.formatMessage({ id: 'admin.portal.lpr.enrolled.learners.table.user_email.column.heading', defaultMessage: 'Email', description: 'Column heading for the user email column in the enrolled learners table', }), - key: 'user_email', - columnSortable: true, + accessor: 'userEmail', + Cell: UserEmail, }, { - label: intl.formatMessage({ + Header: intl.formatMessage({ id: 'admin.portal.lpr.enrolled.learners.table.lms_user_created.column.heading', defaultMessage: 'Account Created', description: 'Column heading for the lms user created column in the enrolled learners table', }), - key: 'lms_user_created', - columnSortable: true, + accessor: 'lmsUserCreated', + Cell: ({ row }) => i18nFormatTimestamp({ intl, timestamp: row.values.lmsUserCreated }), }, { - label: intl.formatMessage({ + Header: intl.formatMessage({ id: 'admin.portal.lpr.enrolled.learners.table.enrollment_count.column.heading', defaultMessage: 'Total Course Enrollment Count', description: 'Column heading for the course enrollment count column in the enrolled learners table', }), - key: 'enrollment_count', - columnSortable: true, + accessor: 'enrollmentCount', }, ]; - const formatLearnerData = learners => learners.map(learner => ({ - ...learner, - user_email: {learner.user_email}, - lms_user_created: i18nFormatTimestamp({ - intl, timestamp: learner.lms_user_created, - }), - })); - return ( - ); }; -export default EnrolledLearnersTable; +const mapStateToProps = state => ({ + enterpriseId: state.portalConfiguration.enterpriseId, +}); + +EnrolledLearnersTable.propTypes = { + id: PropTypes.string.isRequired, + enterpriseId: PropTypes.string.isRequired, +}; + +export default connect(mapStateToProps)(EnrolledLearnersTable); diff --git a/src/components/LearnerActivityTable/LearnerActivityTable.test.jsx b/src/components/LearnerActivityTable/LearnerActivityTable.test.jsx index 3d8a62b94b..e58c171772 100644 --- a/src/components/LearnerActivityTable/LearnerActivityTable.test.jsx +++ b/src/components/LearnerActivityTable/LearnerActivityTable.test.jsx @@ -1,169 +1,159 @@ -import React from 'react'; import { MemoryRouter } from 'react-router-dom'; import { IntlProvider } from '@edx/frontend-platform/i18n'; -import renderer from 'react-test-renderer'; import configureMockStore from 'redux-mock-store'; import thunk from 'redux-thunk'; import { Provider } from 'react-redux'; import { mount } from 'enzyme'; import LearnerActivityTable from '.'; +import usePaginatedTableData from '../../hooks/usePaginatedTableData'; +import { mockUseCourseEnrollments, mockEmptyCourseEnrollmentsData } from './data/tests/constants'; const enterpriseId = 'test-enterprise'; const mockStore = configureMockStore([thunk]); -const learnerActivityEmptyStore = mockStore({ - portalConfiguration: { - enterpriseId, - }, - table: { - 'active-week': { - data: { - results: [], - current_page: 1, - num_pages: 1, - }, - ordering: null, - loading: false, - error: null, - }, - }, -}); -const tableMockData = { - data: { - count: 2, - num_pages: 1, - current_page: 1, - results: [ - { - id: 1, - passed_date: '2018-09-23T16:27:34.690065Z', - course_title: 'Dive into ReactJS', - course_key: 'edX/ReactJS', - user_email: 'awesome.me@example.com', - course_list_price: '200', - course_start_date: '2017-10-21T23:47:32.738Z', - course_end_date: '2018-05-13T12:47:27.534Z', - current_grade: '0.66', - progress_status: 'Failed', - last_activity_date: '2018-09-22T10:59:28.628Z', - }, - { - id: 5, - passed_date: '2018-09-22T16:27:34.690065Z', - course_title: 'Redux with ReactJS', - course_key: 'edX/Redux_ReactJS', - user_email: 'new@example.com', - course_list_price: '200', - course_start_date: '2017-10-21T23:47:32.738Z', - course_end_date: '2018-05-13T12:47:27.534Z', - current_grade: '0.80', - progress_status: 'Passed', - last_activity_date: '2018-09-25T10:59:28.628Z', - }, - ], - next: null, - start: 0, - previous: null, - }, - ordering: null, - loading: false, - error: null, -}; +jest.mock('../../hooks/usePaginatedTableData', () => jest.fn()); -const learnerActivityStore = mockStore({ +const store = mockStore({ portalConfiguration: { enterpriseId, }, - table: { - 'active-week': tableMockData, - 'inactive-week': tableMockData, - 'inactive-month': tableMockData, - }, }); -const LearnerActivityEmptyTableWrapper = props => ( - - - - - - - -); - const LearnerActivityTableWrapper = props => ( - - + + ); const verifyLearnerActivityTableRendered = (tableId, activity, columnTitles, rowsData) => { - const wrapper = mount(( - - )); - // Verify that table has correct number of columns - expect(wrapper.find(`.${tableId} thead th`).length).toEqual(columnTitles.length); - - // Verify only expected columns are shown - wrapper.find(`.${tableId} thead th`).forEach((column, index) => { + const wrapper = mount(); + const table = wrapper.find('[role="table"]'); + const headerColumns = table.find('thead th'); + const tableRows = table.find('tbody tr'); + + expect(headerColumns).toHaveLength(columnTitles.length); + + headerColumns.forEach((column, index) => { expect(column.text()).toContain(columnTitles[index]); }); - // Verify that table has correct number of rows - expect(wrapper.find(`.${tableId} tbody tr`).length).toEqual(2); + expect(tableRows).toHaveLength(rowsData.length); - // Verify each row in table has correct data - wrapper.find(`.${tableId} tbody tr`).forEach((row, rowIndex) => { - row.find('td').forEach((cell, colIndex) => { + tableRows.forEach((row, rowIndex) => { + const cells = row.find('td'); + cells.forEach((cell, colIndex) => { expect(cell.text()).toEqual(rowsData[rowIndex][colIndex]); }); }); }; describe('LearnerActivityTable', () => { - it('renders empty state correctly', () => { - const tree = renderer - .create(( - - )) - .toJSON(); - expect(tree).toMatchSnapshot(); + beforeEach(() => { + usePaginatedTableData.mockReturnValue(mockUseCourseEnrollments); + }); + + afterEach(() => jest.clearAllMocks()); + + it('renders empty table correctly', () => { + usePaginatedTableData.mockReturnValue(mockEmptyCourseEnrollmentsData); + + const wrapper = mount( + , + ); + + expect(wrapper.find('[role="table"]').exists()).toBe(true); + expect(wrapper.find('tbody tr').length).toBe(0); }); it('renders active learners table correctly', () => { - const tree = renderer - .create(( - - )) - .toJSON(); - expect(tree).toMatchSnapshot(); + const tableId = 'active-week'; + const activity = 'active_past_week'; + const columnTitles = [ + 'Email', + 'Course Title', + 'Course Price', + 'Start Date', + 'End Date', + 'Passed Date', + 'Current Grade', + 'Progress Status', + 'Last Activity Date', + ]; + + const wrapper = mount(); + const table = wrapper.find('[role="table"]'); + const headerColumns = table.find('thead th'); + + expect(table.exists()).toBe(true); + expect(headerColumns).toHaveLength(columnTitles.length); + headerColumns.forEach((column, index) => { + expect(column.text()).toContain(columnTitles[index]); + }); + + expect(wrapper.find('tbody tr').length).toBeGreaterThan(0); }); it('renders inactive past week learners table correctly', () => { - const tree = renderer - .create(( - - )) - .toJSON(); - expect(tree).toMatchSnapshot(); + const tableId = 'inactive-week'; + const activity = 'inactive_past_week'; + const columnTitles = [ + 'Email', + 'Course Title', + 'Course Price', + 'Start Date', + 'End Date', + 'Current Grade', + 'Progress Status', + 'Last Activity Date', + ]; + + const wrapper = mount(); + const table = wrapper.find('[role="table"]'); + const headerColumns = table.find('thead th'); + + expect(table.exists()).toBe(true); + expect(headerColumns).toHaveLength(columnTitles.length); + headerColumns.forEach((column, index) => { + expect(column.text()).toContain(columnTitles[index]); + }); + + const columnHeaders = headerColumns.map(col => col.text()); + expect(columnHeaders.includes('Passed Date')).toBe(false); + expect(wrapper.find('tbody tr').length).toBeGreaterThan(0); }); it('renders inactive past month learners table correctly', () => { - const tree = renderer - .create(( - - )) - .toJSON(); - expect(tree).toMatchSnapshot(); + const tableId = 'inactive-month'; + const activity = 'inactive_past_month'; + const columnTitles = [ + 'Email', + 'Course Title', + 'Course Price', + 'Start Date', + 'End Date', + 'Current Grade', + 'Progress Status', + 'Last Activity Date', + ]; + + const wrapper = mount(); + const table = wrapper.find('[role="table"]'); + const headerColumns = table.find('thead th'); + + expect(table.exists()).toBe(true); + expect(headerColumns).toHaveLength(columnTitles.length); + headerColumns.forEach((column, index) => { + expect(column.text()).toContain(columnTitles[index]); + }); + + const columnHeaders = headerColumns.map(col => col.text()); + expect(columnHeaders.includes('Passed Date')).toBe(false); + expect(wrapper.find('tbody tr').length).toBeGreaterThan(0); }); it('renders active learners table with correct data', () => { diff --git a/src/components/LearnerActivityTable/__snapshots__/LearnerActivityTable.test.jsx.snap b/src/components/LearnerActivityTable/__snapshots__/LearnerActivityTable.test.jsx.snap deleted file mode 100644 index 2230a6da0d..0000000000 --- a/src/components/LearnerActivityTable/__snapshots__/LearnerActivityTable.test.jsx.snap +++ /dev/null @@ -1,1477 +0,0 @@ -// Jest Snapshot v1, https://goo.gl/fbAQLP - -exports[`LearnerActivityTable renders active learners table correctly 1`] = ` -
-
-
-
- - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - -
- - - - - - - - - - - - - - - - - -
- - awesome.me@example.com - - - Dive into ReactJS - - $200 - - October 21, 2017 - - May 13, 2018 - - September 23, 2018 - - 66% - - Failed - - September 22, 2018 -
- - new@example.com - - - Redux with ReactJS - - $200 - - October 21, 2017 - - May 13, 2018 - - September 22, 2018 - - 80% - - Passed - - September 25, 2018 -
-
-
-
-
-
- -
-
-
-`; - -exports[`LearnerActivityTable renders empty state correctly 1`] = ` -
- - - - - -
-
- There are no results. -
-
-
-`; - -exports[`LearnerActivityTable renders inactive past month learners table correctly 1`] = ` -
-
-
-
- - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - -
- - - - - - - - - - - - - - - -
- - awesome.me@example.com - - - Dive into ReactJS - - $200 - - October 21, 2017 - - May 13, 2018 - - 66% - - Failed - - September 22, 2018 -
- - new@example.com - - - Redux with ReactJS - - $200 - - October 21, 2017 - - May 13, 2018 - - 80% - - Passed - - September 25, 2018 -
-
-
-
-
-
- -
-
-
-`; - -exports[`LearnerActivityTable renders inactive past week learners table correctly 1`] = ` -
-
-
-
- - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - -
- - - - - - - - - - - - - - - -
- - awesome.me@example.com - - - Dive into ReactJS - - $200 - - October 21, 2017 - - May 13, 2018 - - 66% - - Failed - - September 22, 2018 -
- - new@example.com - - - Redux with ReactJS - - $200 - - October 21, 2017 - - May 13, 2018 - - 80% - - Passed - - September 25, 2018 -
-
-
-
-
-
- -
-
-
-`; diff --git a/src/components/LearnerActivityTable/data/hooks/useCourseEnrollments.js b/src/components/LearnerActivityTable/data/hooks/useCourseEnrollments.js new file mode 100644 index 0000000000..28a9273e42 --- /dev/null +++ b/src/components/LearnerActivityTable/data/hooks/useCourseEnrollments.js @@ -0,0 +1,11 @@ +import usePaginatedTableData from '../../../../hooks/usePaginatedTableData'; +import EnterpriseDataApiService from '../../../../data/services/EnterpriseDataApiService'; + +const useCourseEnrollments = (enterpriseId, tableId, apiFieldsForColumnAccessor) => usePaginatedTableData({ + enterpriseId, + tableId, + apiFieldsForColumnAccessor, + fetchFunction: EnterpriseDataApiService.fetchCourseEnrollments, +}); + +export default useCourseEnrollments; diff --git a/src/components/LearnerActivityTable/data/hooks/useCourseEnrollments.test.js b/src/components/LearnerActivityTable/data/hooks/useCourseEnrollments.test.js new file mode 100644 index 0000000000..35c06e888e --- /dev/null +++ b/src/components/LearnerActivityTable/data/hooks/useCourseEnrollments.test.js @@ -0,0 +1,130 @@ +import { renderHook, act } from '@testing-library/react-hooks'; +import useCourseEnrollments from './useCourseEnrollments'; +import EnterpriseDataApiService from '../../../../data/services/EnterpriseDataApiService'; + +jest.mock('../../../../data/services/EnterpriseDataApiService'); + +const mockApiFields = { + userEmail: { key: 'user_email' }, + courseTitle: { key: 'course_title' }, +}; + +const enterpriseId = 'enterprise-123'; +const tableId = 'test-table'; + +describe('useCourseEnrollments', () => { + const mockResponse = { + data: { + count: 2, + results: [ + { + user_email: 'john@example.com', + course_title: 'React Basics', + }, + { + user_email: 'jane@example.com', + course_title: 'Redux Deep Dive', + }, + ], + }, + }; + + const emptyResponse = { + data: { + count: 0, + results: [], + }, + }; + + beforeEach(() => { + jest.clearAllMocks(); + }); + + it('fetches and returns course enrollment data successfully', async () => { + EnterpriseDataApiService.fetchCourseEnrollments.mockResolvedValueOnce(mockResponse); + + const { result } = renderHook(() => useCourseEnrollments(enterpriseId, tableId, mockApiFields)); + + await act(async () => { + await result.current.fetchDataImmediate({ + pageIndex: 0, + pageSize: 10, + sortBy: [], + }); + }); + + expect(EnterpriseDataApiService.fetchCourseEnrollments).toHaveBeenCalledWith(enterpriseId, { + page: 1, + pageSize: 10, + }); + + expect(result.current.data.results).toHaveLength(2); + expect(result.current.data.itemCount).toBe(2); + expect(result.current.hasData).toBe(true); + expect(result.current.isLoading).toBe(false); + }); + + it('handles empty data response', async () => { + EnterpriseDataApiService.fetchCourseEnrollments.mockResolvedValueOnce(emptyResponse); + + const { result } = renderHook(() => useCourseEnrollments(enterpriseId, tableId, mockApiFields)); + + await act(async () => { + await result.current.fetchDataImmediate({ + pageIndex: 0, + pageSize: 10, + sortBy: [], + }); + }); + + expect(result.current.data.results).toHaveLength(0); + expect(result.current.hasData).toBe(false); + }); + + it('sets loading state correctly during fetch', async () => { + let resolvePromise; + EnterpriseDataApiService.fetchCourseEnrollments.mockReturnValueOnce( + new Promise((resolve) => { + resolvePromise = resolve; + }), + ); + + const { result } = renderHook(() => useCourseEnrollments(enterpriseId, tableId, mockApiFields)); + + act(() => { + result.current.fetchDataImmediate({ + pageIndex: 0, + pageSize: 5, + sortBy: [], + }); + }); + + expect(result.current.isLoading).toBe(true); + + await act(async () => { + resolvePromise(mockResponse); + }); + + expect(result.current.isLoading).toBe(false); + }); + + it('logs error when fetch fails', async () => { + const error = new Error('API failure'); + EnterpriseDataApiService.fetchCourseEnrollments.mockRejectedValueOnce(error); + + const { result } = renderHook(() => useCourseEnrollments(enterpriseId, tableId, mockApiFields)); + + await act(async () => { + await result.current.fetchDataImmediate({ + pageIndex: 1, + pageSize: 5, + sortBy: [], + filters: {}, + }); + }); + + expect(result.current.data.results).toHaveLength(0); + expect(result.current.isLoading).toBe(false); + expect(result.current.hasData).toBe(false); + }); +}); diff --git a/src/components/LearnerActivityTable/data/tests/constants.js b/src/components/LearnerActivityTable/data/tests/constants.js new file mode 100644 index 0000000000..e133d10884 --- /dev/null +++ b/src/components/LearnerActivityTable/data/tests/constants.js @@ -0,0 +1,46 @@ +export const mockUseCourseEnrollments = { + isLoading: false, + data: { + itemCount: 2, + pageCount: 1, + results: [ + { + userEmail: 'awesome.me@example.com', + courseTitle: 'Dive into ReactJS', + courseListPrice: 200, + courseStartDate: '2017-10-21', + courseEndDate: '2018-05-13', + passedDate: '2018-09-23', + currentGrade: 0.66, + progressStatus: 'Failed', + lastActivityDate: '2018-09-22', + }, + { + userEmail: 'new@example.com', + courseTitle: 'Redux with ReactJS', + courseListPrice: 200, + courseStartDate: '2017-10-21', + courseEndDate: '2018-05-13', + passedDate: '2018-09-22', + currentGrade: 0.8, + progressStatus: 'Passed', + lastActivityDate: '2018-09-25', + }, + ], + }, + fetchData: jest.fn(), + fetchDataImmediate: jest.fn(), + hasData: true, +}; + +export const mockEmptyCourseEnrollmentsData = { + isLoading: false, + data: { + itemCount: 0, + pageCount: 0, + results: [], + }, + fetchData: jest.fn(), + fetchDataImmediate: jest.fn(), + hasData: false, +}; diff --git a/src/components/LearnerActivityTable/index.jsx b/src/components/LearnerActivityTable/index.jsx index e591d77627..e5c206582c 100644 --- a/src/components/LearnerActivityTable/index.jsx +++ b/src/components/LearnerActivityTable/index.jsx @@ -1,152 +1,213 @@ -import React from 'react'; +/* eslint-disable react-hooks/exhaustive-deps */ import PropTypes from 'prop-types'; - -import { injectIntl, intlShape } from '@edx/frontend-platform/i18n'; - -import TableContainer from '../../containers/TableContainer'; +import { useIntl } from '@edx/frontend-platform/i18n'; +import { DataTable, TextFilter } from '@openedx/paragon'; +import { connect } from 'react-redux'; +import { useLocation, useNavigate } from 'react-router-dom'; +import { useMemo, useCallback, useEffect } from 'react'; +import useCourseEnrollments from './data/hooks/useCourseEnrollments'; +import { PAGE_SIZE } from '../../data/constants/table'; import { - i18nFormatTimestamp, i18nFormatPassedTimestamp, i18nFormatProgressStatus, formatPercentage, + i18nFormatTimestamp, + i18nFormatPassedTimestamp, + i18nFormatProgressStatus, + formatPercentage, + updateUrlWithPageNumber, } from '../../utils'; -import EnterpriseDataApiService from '../../data/services/EnterpriseDataApiService'; - -class LearnerActivityTable extends React.Component { - getTableColumns() { - const { activity, intl } = this.props; - const tableColumns = [ - { - label: intl.formatMessage({ - id: 'admin.portal.lpr.learner.activity.table.user_email.column.heading', - defaultMessage: 'Email', - description: 'Column heading for the user email column in the learner activity table', - }), - key: 'user_email', - columnSortable: true, - }, - { - label: intl.formatMessage({ - id: 'admin.portal.lpr.learner.activity.table.course_title.column.heading', - defaultMessage: 'Course Title', - description: 'Column heading for the course title column in the learner activity table', - }), - key: 'course_title', - columnSortable: true, - }, - { - label: intl.formatMessage({ - id: 'admin.portal.lpr.learner.activity.table.course_list_price.column.heading', - defaultMessage: 'Course Price', - description: 'Column heading for the course price column in the learner activity table', - }), - key: 'course_list_price', - columnSortable: true, - }, - { - label: intl.formatMessage({ - id: 'admin.portal.lpr.learner.activity.table.course_start_date.column.heading', - defaultMessage: 'Start Date', - description: 'Column heading for the course start date column in the learner activity table', - }), - key: 'course_start_date', - columnSortable: true, - }, - { - label: intl.formatMessage({ - id: 'admin.portal.lpr.learner.activity.table.course_end_date.column.heading', - defaultMessage: 'End Date', - description: 'Column heading for the course end date column in the learner activity table', - }), - key: 'course_end_date', - columnSortable: true, - }, - { - label: intl.formatMessage({ - id: 'admin.portal.lpr.learner.activity.table.passed_date.column.heading', - defaultMessage: 'Passed Date', - description: 'Column heading for the passed date column in the learner activity table', - }), - key: 'passed_date', - columnSortable: true, - }, - { - label: intl.formatMessage({ - id: 'admin.portal.lpr.learner.activity.table.current_grade.column.heading', - defaultMessage: 'Current Grade', - description: 'Column heading for the current grade column in the learner activity table', - }), - key: 'current_grade', - columnSortable: true, - }, - { - label: intl.formatMessage({ - id: 'admin.portal.lpr.learner.activity.table.progress_status.column.heading', - defaultMessage: 'Progress Status', - description: 'Column heading for the progress status column in the learner activity table', - }), - key: 'progress_status', - columnSortable: true, - }, - { - label: intl.formatMessage({ - id: 'admin.portal.lpr.learner.activity.table.enrollment_date.column.heading', - defaultMessage: 'Last Activity Date', - description: 'Column heading for the last activity date column in the learner activity table', - }), - key: 'last_activity_date', - columnSortable: true, - }, - ]; - - if (activity !== 'active_past_week') { - return tableColumns.filter(column => column.key !== 'passed_date'); - } - return tableColumns; - } +import { formatPrice } from '../learner-credit-management/data'; +import { useTableData } from '../Admin/TableDataContext'; + +const FilterStatus = (rest) => ; + +const UserEmail = ({ row }) => ( + {row.original.userEmail} +); + +UserEmail.propTypes = { + row: PropTypes.shape({ + original: PropTypes.shape({ + userEmail: PropTypes.string, + }).isRequired, + }).isRequired, +}; + +const LearnerActivityTable = ({ id, enterpriseId, activity }) => { + const intl = useIntl(); + const location = useLocation(); + const navigate = useNavigate(); + const { setTableHasData } = useTableData(); + + // Parse the current page from URL query parameters - adjust for zero-based indexing + const queryParams = useMemo(() => new URLSearchParams(location.search), [location.search]); + const pageFromUrl = parseInt(queryParams.get(`${id}-page`), 10) || 1; // Default to page 1 in URL + const currentPageFromUrl = pageFromUrl - 1; // Convert to zero-based for DataTable + + const apiFieldsForColumnAccessor = useMemo(() => ({ + userEmail: { key: 'user_email' }, + courseTitle: { key: 'course_title' }, + courseListPrice: { key: 'course_list_price' }, + courseStartDate: { key: 'course_start_date' }, + courseEndDate: { key: 'course_end_date' }, + passedDate: { key: 'passed_date' }, + currentGrade: { key: 'current_grade' }, + progressStatus: { key: 'progress_status' }, + lastActivityDate: { key: 'last_activity_date' }, + }), []); - formatTableData = enrollments => enrollments.map(enrollment => ({ - ...enrollment, - user_email: {enrollment.user_email}, - last_activity_date: i18nFormatTimestamp({ intl: this.props.intl, timestamp: enrollment.last_activity_date }), - course_start_date: i18nFormatTimestamp({ intl: this.props.intl, timestamp: enrollment.course_start_date }), - course_end_date: i18nFormatTimestamp({ intl: this.props.intl, timestamp: enrollment.course_end_date }), - enrollment_date: i18nFormatTimestamp({ - intl: this.props.intl, timestamp: enrollment.enrollment_date, - }), - passed_date: i18nFormatPassedTimestamp({ intl: this.props.intl, timestamp: enrollment.passed_date }), - user_account_creation_date: i18nFormatTimestamp({ - intl: this.props.intl, timestamp: enrollment.user_account_creation_date, - }), - progress_status: i18nFormatProgressStatus({ intl: this.props.intl, progressStatus: enrollment.progress_status }), - course_list_price: enrollment.course_list_price ? `$${enrollment.course_list_price}` : '', - current_grade: formatPercentage({ decimal: enrollment.current_grade }), - })); - - render() { - const { activity, id } = this.props; - return ( - EnterpriseDataApiService.fetchCourseEnrollments( - enterpriseId, - { - learnerActivity: activity, - ...options, - }, - )} - columns={this.getTableColumns()} - formatData={this.formatTableData} - tableSortable - /> - ); + const { + isLoading, + data: courseEnrollments, + fetchData: fetchCourseEnrollments, + fetchDataImmediate, + hasData, + } = useCourseEnrollments(enterpriseId, id, apiFieldsForColumnAccessor); + + /// To load data correctly the first time, we use the non-debounced `fetchDataImmediate` + // on initial load to ensure the data is fetched immediately without any delay. + useEffect(() => { + fetchDataImmediate({ + pageIndex: currentPageFromUrl, + pageSize: PAGE_SIZE, + sortBy: [], + }, true); + }, []); + + // Update context when data status changes + useEffect(() => { + setTableHasData(id, hasData); + }, [id, hasData]); + + // Wrap fetchCourseEnrollments to update the URL when pagination changes + const fetchTableData = useCallback((tableState) => { + const newPageForUrl = tableState.pageIndex + 1; // Convert zero-based index to one-based for URL + updateUrlWithPageNumber(id, newPageForUrl, location, navigate); + + return fetchCourseEnrollments(tableState); + }, [fetchCourseEnrollments]); + + const columns = [ + { + Header: intl.formatMessage({ + id: 'admin.portal.lpr.learner.activity.table.user_email.column.heading', + defaultMessage: 'Email', + description: 'Column heading for the user email column in the learner activity table', + }), + accessor: 'userEmail', + Cell: UserEmail, + }, + { + Header: intl.formatMessage({ + id: 'admin.portal.lpr.learner.activity.table.course_title.column.heading', + defaultMessage: 'Course Title', + description: 'Column heading for the course title column in the learner activity table', + }), + accessor: 'courseTitle', + }, + { + Header: intl.formatMessage({ + id: 'admin.portal.lpr.learner.activity.table.course_list_price.column.heading', + defaultMessage: 'Course Price', + description: 'Column heading for the course price column in the learner activity table', + }), + accessor: 'courseListPrice', + Cell: ({ row }) => formatPrice(row.values.courseListPrice), + }, + { + Header: intl.formatMessage({ + id: 'admin.portal.lpr.learner.activity.table.course_start_date.column.heading', + defaultMessage: 'Start Date', + description: 'Column heading for the course start date column in the learner activity table', + }), + accessor: 'courseStartDate', + Cell: ({ row }) => i18nFormatTimestamp({ intl, timestamp: row.values.courseStartDate }), + }, + { + Header: intl.formatMessage({ + id: 'admin.portal.lpr.learner.activity.table.course_end_date.column.heading', + defaultMessage: 'End Date', + description: 'Column heading for the course end date column in the learner activity table', + }), + accessor: 'courseEndDate', + Cell: ({ row }) => i18nFormatTimestamp({ intl, timestamp: row.values.courseEndDate }), + }, + { + Header: intl.formatMessage({ + id: 'admin.portal.lpr.learner.activity.table.passed_date.column.heading', + defaultMessage: 'Passed Date', + description: 'Column heading for the passed date column in the learner activity table', + }), + accessor: 'passedDate', + Cell: ({ row }) => i18nFormatPassedTimestamp({ intl, timestamp: row.values.passedDate }), + }, + { + Header: intl.formatMessage({ + id: 'admin.portal.lpr.learner.activity.table.current_grade.column.heading', + defaultMessage: 'Current Grade', + description: 'Column heading for the current grade column in the learner activity table', + }), + accessor: 'currentGrade', + Cell: ({ row }) => formatPercentage({ decimal: row.values.currentGrade }), + }, + { + Header: intl.formatMessage({ + id: 'admin.portal.lpr.learner.activity.table.progress_status.column.heading', + defaultMessage: 'Progress Status', + description: 'Column heading for the progress status column in the learner activity table', + }), + accessor: 'progressStatus', + Cell: ({ row }) => i18nFormatProgressStatus({ intl, progressStatus: row.values.progressStatus }), + }, + { + Header: intl.formatMessage({ + id: 'admin.portal.lpr.learner.activity.table.enrollment_date.column.heading', + defaultMessage: 'Last Activity Date', + description: 'Column heading for the last activity date column in the learner activity table', + }), + accessor: 'lastActivityDate', + Cell: ({ row }) => i18nFormatTimestamp({ intl, timestamp: row.values.lastActivityDate }), + }, + ]; + + if (activity !== 'active_past_week') { + columns.splice(columns.findIndex(col => col.accessor === 'passedDate'), 1); } -} + + return ( + + ); +}; + +const mapStateToProps = state => ({ + enterpriseId: state.portalConfiguration.enterpriseId, +}); LearnerActivityTable.propTypes = { id: PropTypes.string.isRequired, + enterpriseId: PropTypes.string.isRequired, activity: PropTypes.string.isRequired, - // injected - intl: intlShape.isRequired, }; -export default injectIntl(LearnerActivityTable); +export default connect(mapStateToProps)(LearnerActivityTable); diff --git a/src/components/PastWeekPassedLearnersTable/PastWeekPassedLearnersTable.test.jsx b/src/components/PastWeekPassedLearnersTable/PastWeekPassedLearnersTable.test.jsx index fab3445cd9..832ed321bf 100644 --- a/src/components/PastWeekPassedLearnersTable/PastWeekPassedLearnersTable.test.jsx +++ b/src/components/PastWeekPassedLearnersTable/PastWeekPassedLearnersTable.test.jsx @@ -1,114 +1,265 @@ -import React from 'react'; -import { MemoryRouter } from 'react-router-dom'; -import renderer from 'react-test-renderer'; +import { IntlProvider } from '@edx/frontend-platform/i18n'; import configureMockStore from 'redux-mock-store'; import thunk from 'redux-thunk'; import { Provider } from 'react-redux'; -import { IntlProvider } from '@edx/frontend-platform/i18n'; +import { createMemoryHistory } from 'history'; +import { Router } from 'react-router'; +import { act } from '@testing-library/react'; import { mount } from 'enzyme'; - import PastWeekPassedLearnersTable from '.'; +import usePastWeekPassedLearners from './data/hooks/usePastWeekPassedLearners'; +import { PAGE_SIZE } from '../../data/constants/table'; + +// Mock the hooks +jest.mock('./data/hooks/usePastWeekPassedLearners', () => jest.fn()); +jest.mock('../Admin/TableDataContext', () => ({ + useTableData: () => ({ + setTableHasData: jest.fn(), + }), +})); -const enterpriseId = 'test-enterprise'; const mockStore = configureMockStore([thunk]); -const store = mockStore({ - portalConfiguration: { - enterpriseId, - }, - table: { - 'completed-learners-week': { + +// Mock implementations +const mockFetchData = jest.fn().mockResolvedValue({}); +const mockFetchDataImmediate = jest.fn(); + +describe('PastWeekPassedLearnersTable', () => { + const enterpriseId = 'test-enterprise-id'; + const tableId = 'completed-learners-week'; + + const store = mockStore({ + portalConfiguration: { + enterpriseId, + }, + }); + + const defaultProps = { + id: tableId, + }; + + beforeEach(() => { + // Setup default mock implementation + usePastWeekPassedLearners.mockReturnValue({ + isLoading: false, data: { - count: 2, - num_pages: 1, - current_page: 1, results: [ { - id: 1, - passed_date: '2018-09-23T16:27:34.690065Z', - course_title: 'Dive into ReactJS', - course_key: 'edX/ReactJS', - user_email: 'awesome.me@example.com', + userEmail: 'test1@example.com', + courseTitle: 'React Basics', + passedDate: '2025-04-20T12:00:00Z', }, { - id: 5, - passed_date: '2018-09-22T16:27:34.690065Z', - course_title: 'Redux with ReactJS', - course_key: 'edX/Redux_ReactJS', - user_email: 'new@example.com', - + userEmail: 'test2@example.com', + courseTitle: 'Advanced React', + passedDate: '2025-04-19T12:00:00Z', }, ], - next: null, - start: 0, - previous: null, + itemCount: 2, + pageCount: 1, }, - ordering: null, - loading: false, - error: null, - }, - }, -}); + fetchData: mockFetchData, + fetchDataImmediate: mockFetchDataImmediate, + hasData: true, + }); + }); -const PastWeekPassedLearnersWrapper = props => ( - - - - - - - -); + afterEach(() => { + jest.clearAllMocks(); + }); -describe('PastWeekPassedLearnersTable', () => { - let wrapper; - - it('renders table correctly', () => { - const tree = renderer - .create(( - - )) - .toJSON(); - expect(tree).toMatchSnapshot(); + const PastWeekPassedLearnersTableWrapper = (props = {}) => { + const history = createMemoryHistory(); + return ( + + + + + + + + ); + }; + + it('renders the table with learner data', () => { + const wrapper = mount(); + + // Check if table exists + const table = wrapper.find('DataTable'); + expect(table.exists()).toBe(true); + + // Verify DataTable props + expect(table.prop('id')).toBe(tableId); + expect(table.prop('data')).toEqual([ + { + userEmail: 'test1@example.com', + courseTitle: 'React Basics', + passedDate: '2025-04-20T12:00:00Z', + }, + { + userEmail: 'test2@example.com', + courseTitle: 'Advanced React', + passedDate: '2025-04-19T12:00:00Z', + }, + ]); + expect(table.prop('itemCount')).toBe(2); + expect(table.prop('pageCount')).toBe(1); + expect(table.prop('isLoading')).toBe(false); + + // Verify columns are correctly configured + expect(table.prop('columns').length).toBe(3); + expect(table.prop('columns')[0].accessor).toBe('userEmail'); + expect(table.prop('columns')[1].accessor).toBe('courseTitle'); + expect(table.prop('columns')[2].accessor).toBe('passedDate'); + }); + + it('renders empty table when no data is available', () => { + usePastWeekPassedLearners.mockReturnValue({ + isLoading: false, + data: { + results: [], + itemCount: 0, + pageCount: 0, + }, + fetchData: mockFetchData, + fetchDataImmediate: mockFetchDataImmediate, + hasData: false, + }); + + const wrapper = mount(); + + const table = wrapper.find('DataTable'); + expect(table.exists()).toBe(true); + expect(table.prop('data')).toEqual([]); + expect(table.prop('itemCount')).toBe(0); + expect(table.prop('pageCount')).toBe(0); }); - it('renders table with correct data', () => { - const tableId = 'completed-learners-week'; - const columnTitles = ['Email', 'Course Title', 'Passed Date']; - const rowsData = [ - [ - 'awesome.me@example.com', - 'Dive into ReactJS', - 'September 23, 2018', - ], - [ - 'new@example.com', - 'Redux with ReactJS', - 'September 22, 2018', - ], - ]; - - wrapper = mount(( - - )); - - // Verify that table has correct number of columns - expect(wrapper.find(`.${tableId} thead th`).length).toEqual(3); - - // Verify only expected columns are shown - wrapper.find(`.${tableId} thead th`).forEach((column, index) => { - expect(column.text()).toContain(columnTitles[index]); + it('shows loading state when data is being fetched', () => { + usePastWeekPassedLearners.mockReturnValue({ + isLoading: true, + data: { + results: [], + itemCount: 0, + pageCount: 0, + }, + fetchData: mockFetchData, + fetchDataImmediate: mockFetchDataImmediate, + hasData: false, }); - // Verify that table has correct number of rows - expect(wrapper.find(`.${tableId} tbody tr`).length).toEqual(2); + const wrapper = mount(); + + const table = wrapper.find('DataTable'); + expect(table.prop('isLoading')).toBe(true); + }); + + it('fetches data immediately on mount', () => { + mount(); + + expect(mockFetchDataImmediate).toHaveBeenCalledTimes(1); + expect(mockFetchDataImmediate).toHaveBeenCalledWith( + { + pageIndex: 0, + pageSize: PAGE_SIZE, // PAGE_SIZE constant value + sortBy: [ + { id: 'passedDate', desc: true }, + ], + }, + true, + ); + }); + + it('uses URL query parameters for initial page', () => { + const history = createMemoryHistory(); + history.push(`?${tableId}-page=2`); // Set page 2 in URL + + const wrapper = mount( + + + + + + + , + ); + + const table = wrapper.find('DataTable'); + // Check that initialState has pageIndex set to 1 (0-based index for page 2) + expect(table.prop('initialState').pageIndex).toBe(1); + + // Check that fetchDataImmediate was called with pageIndex 1 + expect(mockFetchDataImmediate).toHaveBeenCalledWith( + expect.objectContaining({ + pageIndex: 1, + }), + true, + ); + }); + + it('updates URL when page changes', async () => { + const history = createMemoryHistory(); + jest.spyOn(history, 'push'); - // Verify each row in table has correct data - wrapper.find(`.${tableId} tbody tr`).forEach((row, rowIndex) => { - row.find('td').forEach((cell, colIndex) => { - expect(cell.text()).toEqual(rowsData[rowIndex][colIndex]); + const wrapper = mount( + + + + + + + , + ); + + // Simulate page change by calling fetchData prop with new table state + const table = wrapper.find('DataTable'); + await act(async () => { + await table.prop('fetchData')({ + pageIndex: 1, // Navigate to page 2 (0-indexed) + pageSize: 50, + sortBy: [], }); }); + + // Check that fetchData was called + expect(mockFetchData).toHaveBeenCalledWith({ + pageIndex: 1, + pageSize: 50, + sortBy: [], + }); + }); + + it('renders UserEmail component correctly', () => { + const wrapper = mount(); + + // Find columns in the DataTable props + const columns = wrapper.find('DataTable').prop('columns'); + const emailColumn = columns.find(col => col.accessor === 'userEmail'); + + // Test the Cell renderer with a sample row + const testRow = { + original: { + userEmail: 'test@example.com', + }, + }; + + const emailCell = mount( + + {emailColumn.Cell({ row: testRow })} + , + ); + + expect(emailCell.find('[data-hj-suppress]').text()).toBe('test@example.com'); + }); + + it('formats timestamps correctly in the passedDate column', () => { + const wrapper = mount(); + + // Since the actual formatting depends on a utility function i18nFormatTimestamp, + // we can verify the Cell prop is properly configured + const columns = wrapper.find('DataTable').prop('columns'); + const dateColumn = columns.find(col => col.accessor === 'passedDate'); + expect(dateColumn).toBeDefined(); + expect(typeof dateColumn.Cell).toBe('function'); }); }); diff --git a/src/components/PastWeekPassedLearnersTable/__snapshots__/PastWeekPassedLearnersTable.test.jsx.snap b/src/components/PastWeekPassedLearnersTable/__snapshots__/PastWeekPassedLearnersTable.test.jsx.snap deleted file mode 100644 index 1de3d90d6d..0000000000 --- a/src/components/PastWeekPassedLearnersTable/__snapshots__/PastWeekPassedLearnersTable.test.jsx.snap +++ /dev/null @@ -1,273 +0,0 @@ -// Jest Snapshot v1, https://goo.gl/fbAQLP - -exports[`PastWeekPassedLearnersTable renders table correctly 1`] = ` -
-
-
-
- - - - - - - - - - - - - - - - - - - - -
- - - - - -
- - awesome.me@example.com - - - Dive into ReactJS - - September 23, 2018 -
- - new@example.com - - - Redux with ReactJS - - September 22, 2018 -
-
-
-
-
-
- -
-
-
-`; diff --git a/src/components/PastWeekPassedLearnersTable/data/hooks/usePastWeekPassedLearners.js b/src/components/PastWeekPassedLearnersTable/data/hooks/usePastWeekPassedLearners.js new file mode 100644 index 0000000000..2d121e86e2 --- /dev/null +++ b/src/components/PastWeekPassedLearnersTable/data/hooks/usePastWeekPassedLearners.js @@ -0,0 +1,14 @@ +import usePaginatedTableData from '../../../../hooks/usePaginatedTableData'; +import EnterpriseDataApiService from '../../../../data/services/EnterpriseDataApiService'; + +const usePastWeekPassedLearners = (enterpriseId, tableId, apiFieldsForColumnAccessor) => usePaginatedTableData({ + enterpriseId, + tableId, + apiFieldsForColumnAccessor, + fetchFunction: EnterpriseDataApiService.fetchCourseEnrollments, + fetchFunctionOptions: { + passedDate: 'last_week', + }, +}); + +export default usePastWeekPassedLearners; diff --git a/src/components/PastWeekPassedLearnersTable/data/hooks/usePastWeekPassedLearners.test.js b/src/components/PastWeekPassedLearnersTable/data/hooks/usePastWeekPassedLearners.test.js new file mode 100644 index 0000000000..9c4b76a294 --- /dev/null +++ b/src/components/PastWeekPassedLearnersTable/data/hooks/usePastWeekPassedLearners.test.js @@ -0,0 +1,167 @@ +import { renderHook } from '@testing-library/react-hooks'; +import usePastWeekPassedLearners from './usePastWeekPassedLearners'; +import usePaginatedTableData from '../../../../hooks/usePaginatedTableData'; +import EnterpriseDataApiService from '../../../../data/services/EnterpriseDataApiService'; + +// Mock dependencies +jest.mock('../../../../hooks/usePaginatedTableData', () => jest.fn()); +jest.mock('../../../../data/services/EnterpriseDataApiService', () => ({ + fetchCourseEnrollments: jest.fn(), +})); + +describe('usePastWeekPassedLearners', () => { + const enterpriseId = 'test-enterprise-id'; + const tableId = 'completed-learners-week'; + const apiFieldsForColumnAccessor = { + userEmail: { key: 'user_email' }, + courseTitle: { key: 'course_title' }, + passedDate: { key: 'passed_date' }, + }; + + beforeEach(() => { + usePaginatedTableData.mockReturnValue({ + isLoading: false, + data: { + results: [ + { + userEmail: 'test1@example.com', + courseTitle: 'React Basics', + passedDate: '2025-04-20T12:00:00Z', + }, + { + userEmail: 'test2@example.com', + courseTitle: 'Advanced React', + passedDate: '2025-04-19T12:00:00Z', + }, + ], + itemCount: 2, + pageCount: 1, + }, + fetchData: jest.fn(), + fetchDataImmediate: jest.fn(), + hasData: true, + }); + }); + + afterEach(() => { + jest.clearAllMocks(); + }); + + it('calls usePaginatedTableData with correct arguments', () => { + renderHook(() => usePastWeekPassedLearners(enterpriseId, tableId, apiFieldsForColumnAccessor)); + + expect(usePaginatedTableData).toHaveBeenCalledWith({ + enterpriseId, + tableId, + apiFieldsForColumnAccessor, + fetchFunction: EnterpriseDataApiService.fetchCourseEnrollments, + fetchFunctionOptions: { + passedDate: 'last_week', + }, + }); + }); + + it('returns the correct data structure', () => { + const { result } = renderHook(() => usePastWeekPassedLearners(enterpriseId, tableId, apiFieldsForColumnAccessor)); + + expect(result.current).toEqual({ + isLoading: false, + data: { + results: [ + { + userEmail: 'test1@example.com', + courseTitle: 'React Basics', + passedDate: '2025-04-20T12:00:00Z', + }, + { + userEmail: 'test2@example.com', + courseTitle: 'Advanced React', + passedDate: '2025-04-19T12:00:00Z', + }, + ], + itemCount: 2, + pageCount: 1, + }, + fetchData: expect.any(Function), + fetchDataImmediate: expect.any(Function), + hasData: true, + }); + }); + + it('handles loading state correctly', () => { + usePaginatedTableData.mockReturnValue({ + isLoading: true, + data: null, + fetchData: jest.fn(), + fetchDataImmediate: jest.fn(), + hasData: false, + }); + + const { result } = renderHook(() => usePastWeekPassedLearners(enterpriseId, tableId, apiFieldsForColumnAccessor)); + + expect(result.current.isLoading).toBe(true); + expect(result.current.data).toBeNull(); + expect(result.current.hasData).toBe(false); + }); + + it('handles empty data correctly', () => { + usePaginatedTableData.mockReturnValue({ + isLoading: false, + data: { + results: [], + itemCount: 0, + pageCount: 0, + }, + fetchData: jest.fn(), + fetchDataImmediate: jest.fn(), + hasData: false, + }); + + const { result } = renderHook(() => usePastWeekPassedLearners(enterpriseId, tableId, apiFieldsForColumnAccessor)); + + expect(result.current.data.results).toEqual([]); + expect(result.current.data.itemCount).toBe(0); + expect(result.current.data.pageCount).toBe(0); + expect(result.current.hasData).toBe(false); + }); + + it('fetches data using fetchData function', () => { + const mockFetchData = jest.fn(); + usePaginatedTableData.mockReturnValue({ + isLoading: false, + data: { + results: [], + itemCount: 0, + pageCount: 0, + }, + fetchData: mockFetchData, + fetchDataImmediate: jest.fn(), + hasData: false, + }); + + const { result } = renderHook(() => usePastWeekPassedLearners(enterpriseId, tableId, apiFieldsForColumnAccessor)); + + result.current.fetchData({ pageIndex: 0, pageSize: 50, sortBy: [] }); + expect(mockFetchData).toHaveBeenCalledWith({ pageIndex: 0, pageSize: 50, sortBy: [] }); + }); + + it('fetches data immediately using fetchDataImmediate function', () => { + const mockFetchDataImmediate = jest.fn(); + usePaginatedTableData.mockReturnValue({ + isLoading: false, + data: { + results: [], + itemCount: 0, + pageCount: 0, + }, + fetchData: jest.fn(), + fetchDataImmediate: mockFetchDataImmediate, + hasData: false, + }); + + const { result } = renderHook(() => usePastWeekPassedLearners(enterpriseId, tableId, apiFieldsForColumnAccessor)); + + result.current.fetchDataImmediate({ pageIndex: 0, pageSize: 50, sortBy: [] }); + expect(mockFetchDataImmediate).toHaveBeenCalledWith({ pageIndex: 0, pageSize: 50, sortBy: [] }); + }); +}); diff --git a/src/components/PastWeekPassedLearnersTable/index.jsx b/src/components/PastWeekPassedLearnersTable/index.jsx index 2fa60f8b62..a63c1437d1 100644 --- a/src/components/PastWeekPassedLearnersTable/index.jsx +++ b/src/components/PastWeekPassedLearnersTable/index.jsx @@ -1,66 +1,136 @@ -import React from 'react'; - +/* eslint-disable react-hooks/exhaustive-deps */ +import { useCallback, useEffect, useMemo } from 'react'; +import PropTypes from 'prop-types'; import { useIntl } from '@edx/frontend-platform/i18n'; +import { DataTable, TextFilter } from '@openedx/paragon'; +import { connect } from 'react-redux'; +import { useLocation, useNavigate } from 'react-router-dom'; +import { i18nFormatTimestamp, updateUrlWithPageNumber } from '../../utils'; +import usePastWeekPassedLearners from './data/hooks/usePastWeekPassedLearners'; +import { PAGE_SIZE } from '../../data/constants/table'; +import { useTableData } from '../Admin/TableDataContext'; + +const FilterStatus = (rest) => ; -import TableContainer from '../../containers/TableContainer'; -import EnterpriseDataApiService from '../../data/services/EnterpriseDataApiService'; -import { i18nFormatTimestamp } from '../../utils'; +const UserEmail = ({ row }) => ( + {row.original.userEmail} +); + +UserEmail.propTypes = { + row: PropTypes.shape({ + original: PropTypes.shape({ + userEmail: PropTypes.string, + }).isRequired, + }).isRequired, +}; -const PastWeekPassedLearnersTable = () => { +const PastWeekPassedLearnersTable = ({ id, enterpriseId }) => { const intl = useIntl(); + const location = useLocation(); + const navigate = useNavigate(); + const { setTableHasData } = useTableData(); + + // Parse the current page from URL query parameters + const queryParams = useMemo(() => new URLSearchParams(location.search), [location.search]); + const pageFromUrl = parseInt(queryParams.get(`${id}-page`), 10) || 1; // Default to page 1 in URL + const currentPageFromUrl = pageFromUrl - 1; // Convert to zero-based for DataTable + + const apiFieldsForColumnAccessor = useMemo(() => ({ + userEmail: { key: 'user_email' }, + courseTitle: { key: 'course_title' }, + passedDate: { key: 'passed_date' }, + }), []); + + const { + isLoading, + data: pastWeekPassedLearners, + fetchData: fetchLearnersData, + fetchDataImmediate, + hasData, + } = usePastWeekPassedLearners(enterpriseId, id, apiFieldsForColumnAccessor); - const tableColumns = [ + const columns = [ { - label: intl.formatMessage({ + Header: intl.formatMessage({ id: 'admin.portal.lpr.past.week.passed.learners.table.user_email.column.heading', defaultMessage: 'Email', description: 'Column heading for the user email column in the past week passed learners table', }), - key: 'user_email', - columnSortable: true, + accessor: 'userEmail', + Cell: UserEmail, }, { - label: intl.formatMessage({ + Header: intl.formatMessage({ id: 'admin.portal.lpr.past.week.passed.learners.table.course_title.column.heading', defaultMessage: 'Course Title', description: 'Column heading for the course title column in the past week passed learners table', }), - key: 'course_title', - columnSortable: true, + accessor: 'courseTitle', }, { - label: intl.formatMessage({ + Header: intl.formatMessage({ id: 'admin.portal.lpr.past.week.passed.learners.table.passed_date.column.heading', defaultMessage: 'Passed Date', description: 'Column heading for the passed date column in the past week passed learners table', }), - key: 'passed_date', - columnSortable: true, + accessor: 'passedDate', + Cell: ({ row }) => i18nFormatTimestamp({ intl, timestamp: row.values.passedDate }), }, ]; - const formatLearnerData = learners => learners.map(learner => ({ - ...learner, - user_email: {learner.user_email}, - passed_date: i18nFormatTimestamp({ intl, timestamp: learner.passed_date }), - })); + useEffect(() => { + fetchDataImmediate({ + pageIndex: currentPageFromUrl, + pageSize: PAGE_SIZE, + sortBy: [ + { id: 'passedDate', desc: true }, + ], + }, true); + }, []); + + // Update context when data status changes + useEffect(() => { + setTableHasData(id, hasData); + }, [id, hasData]); + + const fetchTableData = useCallback((tableState) => { + const newPageForUrl = tableState.pageIndex + 1; // Convert zero-based index to one-based for URL + updateUrlWithPageNumber(id, newPageForUrl, location, navigate); + + return fetchLearnersData(tableState); + }, [fetchLearnersData]); return ( - EnterpriseDataApiService.fetchCourseEnrollments( - enterpriseId, - { - passedDate: 'last_week', - ...options, - }, - )} - columns={tableColumns} - formatData={formatLearnerData} - tableSortable + ); }; -export default PastWeekPassedLearnersTable; +PastWeekPassedLearnersTable.propTypes = { + id: PropTypes.string.isRequired, + enterpriseId: PropTypes.string.isRequired, +}; + +const mapStateToProps = state => ({ + enterpriseId: state.portalConfiguration.enterpriseId, +}); + +export default connect(mapStateToProps)(PastWeekPassedLearnersTable); diff --git a/src/components/PeopleManagement/LearnerDetailPage/LearnerDetailPage.jsx b/src/components/PeopleManagement/LearnerDetailPage/LearnerDetailPage.jsx index 0a0d112d51..54b6cdd409 100644 --- a/src/components/PeopleManagement/LearnerDetailPage/LearnerDetailPage.jsx +++ b/src/components/PeopleManagement/LearnerDetailPage/LearnerDetailPage.jsx @@ -14,7 +14,7 @@ import { useLearnerProfileView, useLearnerCreditPlans, } from '../data/hooks'; -import useEnterpriseLearnerData from './data/hooks'; +import { useEnterpriseLearnerData } from './data/hooks'; import LearnerDetailGroupMemberships from './LearnerDetailGroupMemberships'; import LearnerAccess from './LearnerAccess'; import CourseEnrollments from './CourseEnrollments'; diff --git a/src/components/PeopleManagement/LearnerDetailPage/data/hooks.js b/src/components/PeopleManagement/LearnerDetailPage/data/hooks.js index f39cc46655..d69b4d442d 100644 --- a/src/components/PeopleManagement/LearnerDetailPage/data/hooks.js +++ b/src/components/PeopleManagement/LearnerDetailPage/data/hooks.js @@ -12,7 +12,7 @@ import LmsApiService from '../../../../data/services/LmsApiService'; * @param {string} learnerId Enterprise customer learner id * @returns An object containing `isLoading` and `learnerData`. */ -const useEnterpriseLearnerData = (enterpriseUUID, learnerId) => { +export const useEnterpriseLearnerData = (enterpriseUUID, learnerId) => { const [isLoading, setIsLoading] = useState(true); const [learnerData, setLearnerData] = useState({ userId: '', @@ -47,5 +47,3 @@ const useEnterpriseLearnerData = (enterpriseUUID, learnerId) => { learnerData, }; }; - -export default useEnterpriseLearnerData; diff --git a/src/components/PeopleManagement/LearnerDetailPage/data/hooks.test.js b/src/components/PeopleManagement/LearnerDetailPage/data/hooks.test.js index 2d822d1bc8..eabee1d898 100644 --- a/src/components/PeopleManagement/LearnerDetailPage/data/hooks.test.js +++ b/src/components/PeopleManagement/LearnerDetailPage/data/hooks.test.js @@ -1,7 +1,7 @@ import { waitFor } from '@testing-library/dom'; import { renderHook } from '@testing-library/react-hooks'; import LmsApiService from '../../../../data/services/LmsApiService'; -import useEnterpriseLearnerData from './hooks'; +import { useEnterpriseLearnerData } from './hooks'; jest.mock('@edx/frontend-platform/logging', () => ({ logError: jest.fn(), diff --git a/src/components/RegisteredLearnersTable/RegisteredLearnersTable.test.jsx b/src/components/RegisteredLearnersTable/RegisteredLearnersTable.test.jsx index e619a1586a..bf65862d5b 100644 --- a/src/components/RegisteredLearnersTable/RegisteredLearnersTable.test.jsx +++ b/src/components/RegisteredLearnersTable/RegisteredLearnersTable.test.jsx @@ -1,52 +1,229 @@ -import React from 'react'; -import { MemoryRouter } from 'react-router-dom'; -import renderer from 'react-test-renderer'; import { IntlProvider } from '@edx/frontend-platform/i18n'; import configureMockStore from 'redux-mock-store'; import thunk from 'redux-thunk'; import { Provider } from 'react-redux'; +import { createMemoryHistory } from 'history'; +import { Router } from 'react-router'; +import { act } from '@testing-library/react'; +import { mount } from 'enzyme'; import RegisteredLearnersTable from '.'; +import useRegisteredLearners from './data/hooks/useRegisteredLearners'; +import { mockLearners, mockEmptyLearners } from './data/tests/constants'; + +// Mock the hooks +jest.mock('./data/hooks/useRegisteredLearners', () => jest.fn()); +jest.mock('../Admin/TableDataContext', () => ({ + useTableData: () => ({ + setTableHasData: jest.fn(), + }), +})); -const enterpriseId = 'test-enterprise'; const mockStore = configureMockStore([thunk]); -const store = mockStore({ - portalConfiguration: { - enterpriseId, - }, - table: { - 'registered-unenrolled-learners': { - data: { - results: [], - current_page: 1, - num_pages: 1, - }, - ordering: null, - loading: false, - error: null, - }, - }, -}); -const RegisteredLearnersWrapper = props => ( - - - - - - - -); +// Mock implementations +const mockFetchData = jest.fn().mockResolvedValue({}); +const mockFetchDataImmediate = jest.fn(); describe('RegisteredLearnersTable', () => { - it('renders empty state correctly', () => { - const tree = renderer - .create(( - - )) - .toJSON(); - expect(tree).toMatchSnapshot(); + const enterpriseId = 'test-enterprise-id'; + const tableId = 'registered-learners'; + + const store = mockStore({ + portalConfiguration: { + enterpriseId, + }, + }); + + const defaultProps = { + id: tableId, + }; + + beforeEach(() => { + // Setup default mock implementation + useRegisteredLearners.mockReturnValue({ + isLoading: false, + data: mockLearners, + fetchData: mockFetchData, + fetchDataImmediate: mockFetchDataImmediate, + hasData: true, + }); + }); + + afterEach(() => { + jest.clearAllMocks(); + }); + + const RegisteredLearnersTableWrapper = (props = {}) => { + const history = createMemoryHistory(); + return ( + + + + + + + + ); + }; + + it('renders the table with learner data', () => { + const wrapper = mount(); + + // Check if table exists + const table = wrapper.find('DataTable'); + expect(table.exists()).toBe(true); + + // Verify DataTable props + expect(table.prop('id')).toBe(tableId); + expect(table.prop('data')).toEqual(mockLearners.results); + expect(table.prop('itemCount')).toBe(mockLearners.itemCount); + expect(table.prop('pageCount')).toBe(mockLearners.pageCount); + expect(table.prop('isLoading')).toBe(false); + + // Verify columns are correctly configured + expect(table.prop('columns').length).toBe(2); + expect(table.prop('columns')[0].accessor).toBe('userEmail'); + expect(table.prop('columns')[1].accessor).toBe('lmsUserCreated'); + }); + + it('renders empty table when no data is available', () => { + useRegisteredLearners.mockReturnValue({ + isLoading: false, + data: mockEmptyLearners, + fetchData: mockFetchData, + fetchDataImmediate: mockFetchDataImmediate, + hasData: false, + }); + + const wrapper = mount(); + + const table = wrapper.find('DataTable'); + expect(table.exists()).toBe(true); + expect(table.prop('data')).toEqual([]); + expect(table.prop('itemCount')).toBe(0); + expect(table.prop('pageCount')).toBe(0); + }); + + it('shows loading state when data is being fetched', () => { + useRegisteredLearners.mockReturnValue({ + isLoading: true, + data: mockEmptyLearners, + fetchData: mockFetchData, + fetchDataImmediate: mockFetchDataImmediate, + hasData: false, + }); + + const wrapper = mount(); + + const table = wrapper.find('DataTable'); + expect(table.prop('isLoading')).toBe(true); + }); + + it('fetches data immediately on mount', () => { + mount(); + + expect(mockFetchDataImmediate).toHaveBeenCalledTimes(1); + expect(mockFetchDataImmediate).toHaveBeenCalledWith( + { + pageIndex: 0, + pageSize: 50, // PAGE_SIZE constant value + sortBy: [], + }, + true, + ); + }); + + it('uses URL query parameters for initial page', () => { + const history = createMemoryHistory(); + history.push(`?${tableId}-page=3`); // Set page 3 in URL + + const wrapper = mount( + + + + + + + , + ); + + const table = wrapper.find('DataTable'); + // Check that initialState has pageIndex set to 2 (0-based index for page 3) + expect(table.prop('initialState').pageIndex).toBe(2); + + // Check that fetchDataImmediate was called with pageIndex 2 + expect(mockFetchDataImmediate).toHaveBeenCalledWith( + expect.objectContaining({ + pageIndex: 2, + }), + true, + ); + }); + + it('updates URL when page changes', async () => { + const history = createMemoryHistory(); + jest.spyOn(history, 'push'); + + const wrapper = mount( + + + + + + + , + ); + + // Simulate page change by calling fetchData prop with new table state + const table = wrapper.find('DataTable'); + await act(async () => { + await table.prop('fetchData')({ + pageIndex: 2, // Navigate to page 3 (0-indexed) + pageSize: 50, + sortBy: [], + }); + }); + + // Check that fetchData was called + expect(mockFetchData).toHaveBeenCalledWith({ + pageIndex: 2, + pageSize: 50, + sortBy: [], + }); + }); + + it('renders UserEmail component correctly', () => { + const wrapper = mount(); + + // Find columns in the DataTable props + const columns = wrapper.find('DataTable').prop('columns'); + const emailColumn = columns.find(col => col.accessor === 'userEmail'); + + // Test the Cell renderer with a sample row + const testRow = { + original: { + userEmail: 'test@example.com', + }, + }; + + const emailCell = mount( + + {emailColumn.Cell({ row: testRow })} + , + ); + + expect(emailCell.find('[data-hj-suppress]').text()).toBe('test@example.com'); + }); + + it('formats timestamps correctly in the lmsUserCreated column', () => { + const wrapper = mount(); + + // Since the actual formatting depends on a utility function i18nFormatTimestamp, + // we can verify the Cell prop is properly configured + const columns = wrapper.find('DataTable').prop('columns'); + const dateColumn = columns.find(col => col.accessor === 'lmsUserCreated'); + expect(dateColumn).toBeDefined(); + expect(typeof dateColumn.Cell).toBe('function'); }); }); diff --git a/src/components/RegisteredLearnersTable/__snapshots__/RegisteredLearnersTable.test.jsx.snap b/src/components/RegisteredLearnersTable/__snapshots__/RegisteredLearnersTable.test.jsx.snap deleted file mode 100644 index a371442a53..0000000000 --- a/src/components/RegisteredLearnersTable/__snapshots__/RegisteredLearnersTable.test.jsx.snap +++ /dev/null @@ -1,37 +0,0 @@ -// Jest Snapshot v1, https://goo.gl/fbAQLP - -exports[`RegisteredLearnersTable renders empty state correctly 1`] = ` -
- - - - - -
-
- There are no results. -
-
-
-`; diff --git a/src/components/RegisteredLearnersTable/data/hooks/useRegisteredLearners.js b/src/components/RegisteredLearnersTable/data/hooks/useRegisteredLearners.js new file mode 100644 index 0000000000..4c6daa53dc --- /dev/null +++ b/src/components/RegisteredLearnersTable/data/hooks/useRegisteredLearners.js @@ -0,0 +1,11 @@ +import usePaginatedTableData from '../../../../hooks/usePaginatedTableData'; +import EnterpriseDataApiService from '../../../../data/services/EnterpriseDataApiService'; + +const useRegisteredLearners = (enterpriseId, tableId, apiFieldsForColumnAccessor) => usePaginatedTableData({ + enterpriseId, + tableId, + apiFieldsForColumnAccessor, + fetchFunction: EnterpriseDataApiService.fetchUnenrolledRegisteredLearners, +}); + +export default useRegisteredLearners; diff --git a/src/components/RegisteredLearnersTable/data/hooks/useRegisteredLearners.test.js b/src/components/RegisteredLearnersTable/data/hooks/useRegisteredLearners.test.js new file mode 100644 index 0000000000..ecb9bc6fff --- /dev/null +++ b/src/components/RegisteredLearnersTable/data/hooks/useRegisteredLearners.test.js @@ -0,0 +1,133 @@ +import { renderHook, act } from '@testing-library/react-hooks'; +import useRegisteredLearners from './useRegisteredLearners'; +import EnterpriseDataApiService from '../../../../data/services/EnterpriseDataApiService'; + +jest.mock('../../../../data/services/EnterpriseDataApiService'); + +const mockApiFields = { + userEmail: { key: 'user_email' }, + firstName: { key: 'first_name' }, + lastName: { key: 'last_name' }, +}; + +const enterpriseId = 'test-enterprise-id'; +const tableId = 'registered-learners-table'; + +describe('useRegisteredLearners', () => { + const mockResponse = { + data: { + count: 2, + results: [ + { + user_email: 'learner1@example.com', + first_name: 'John', + last_name: 'Doe', + }, + { + user_email: 'learner2@example.com', + first_name: 'Jane', + last_name: 'Smith', + }, + ], + }, + }; + + const emptyResponse = { + data: { + count: 0, + results: [], + }, + }; + + beforeEach(() => { + jest.clearAllMocks(); + }); + + it('fetches and returns unenrolled registered learners data successfully', async () => { + EnterpriseDataApiService.fetchUnenrolledRegisteredLearners.mockResolvedValueOnce(mockResponse); + + const { result } = renderHook(() => useRegisteredLearners(enterpriseId, tableId, mockApiFields)); + + await act(async () => { + await result.current.fetchDataImmediate({ + pageIndex: 0, + pageSize: 10, + sortBy: [], + }); + }); + + expect(EnterpriseDataApiService.fetchUnenrolledRegisteredLearners).toHaveBeenCalledWith(enterpriseId, { + page: 1, + pageSize: 10, + }); + + expect(result.current.data.results).toHaveLength(2); + expect(result.current.data.itemCount).toBe(2); + expect(result.current.hasData).toBe(true); + expect(result.current.isLoading).toBe(false); + }); + + it('handles empty data response', async () => { + EnterpriseDataApiService.fetchUnenrolledRegisteredLearners.mockResolvedValueOnce(emptyResponse); + + const { result } = renderHook(() => useRegisteredLearners(enterpriseId, tableId, mockApiFields)); + + await act(async () => { + await result.current.fetchDataImmediate({ + pageIndex: 0, + pageSize: 10, + sortBy: [], + }); + }); + + expect(result.current.data.results).toHaveLength(0); + expect(result.current.hasData).toBe(false); + }); + + it('sets loading state correctly during fetch', async () => { + let resolvePromise; + EnterpriseDataApiService.fetchUnenrolledRegisteredLearners.mockReturnValueOnce( + new Promise((resolve) => { + resolvePromise = resolve; + }), + ); + + const { result } = renderHook(() => useRegisteredLearners(enterpriseId, tableId, mockApiFields)); + + act(() => { + result.current.fetchDataImmediate({ + pageIndex: 0, + pageSize: 5, + sortBy: [], + }); + }); + + expect(result.current.isLoading).toBe(true); + + await act(async () => { + resolvePromise(mockResponse); + }); + + expect(result.current.isLoading).toBe(false); + }); + + it('logs error when fetch fails', async () => { + const error = new Error('API failure'); + EnterpriseDataApiService.fetchUnenrolledRegisteredLearners.mockRejectedValueOnce(error); + + const { result } = renderHook(() => useRegisteredLearners(enterpriseId, tableId, mockApiFields)); + + await act(async () => { + await result.current.fetchDataImmediate({ + pageIndex: 1, + pageSize: 5, + sortBy: [], + filters: {}, + }); + }); + + expect(result.current.data.results).toHaveLength(0); + expect(result.current.isLoading).toBe(false); + expect(result.current.hasData).toBe(false); + }); +}); diff --git a/src/components/RegisteredLearnersTable/data/tests/constants.js b/src/components/RegisteredLearnersTable/data/tests/constants.js new file mode 100644 index 0000000000..8487170ea8 --- /dev/null +++ b/src/components/RegisteredLearnersTable/data/tests/constants.js @@ -0,0 +1,21 @@ +// Sample data for tests +export const mockLearners = { + results: [ + { + userEmail: 'learner1@example.com', + lmsUserCreated: '2023-01-15T12:30:00Z', + }, + { + userEmail: 'learner2@example.com', + lmsUserCreated: '2023-02-20T09:45:00Z', + }, + ], + itemCount: 2, + pageCount: 1, +}; + +export const mockEmptyLearners = { + results: [], + itemCount: 0, + pageCount: 0, +}; diff --git a/src/components/RegisteredLearnersTable/index.jsx b/src/components/RegisteredLearnersTable/index.jsx index fefe7f95c9..12c59c0b1b 100644 --- a/src/components/RegisteredLearnersTable/index.jsx +++ b/src/components/RegisteredLearnersTable/index.jsx @@ -1,54 +1,128 @@ -import React from 'react'; - +/* eslint-disable react-hooks/exhaustive-deps */ +import { useMemo, useCallback, useEffect } from 'react'; +import PropTypes from 'prop-types'; +import { connect } from 'react-redux'; import { useIntl } from '@edx/frontend-platform/i18n'; +import { DataTable, TextFilter } from '@openedx/paragon'; +import { useLocation, useNavigate } from 'react-router-dom'; + +import { i18nFormatTimestamp, updateUrlWithPageNumber } from '../../utils'; +import { PAGE_SIZE } from '../../data/constants/table'; +import { useTableData } from '../Admin/TableDataContext'; +import useRegisteredLearners from './data/hooks/useRegisteredLearners'; -import TableContainer from '../../containers/TableContainer'; +const FilterStatus = (rest) => ; -import { i18nFormatTimestamp } from '../../utils'; -import EnterpriseDataApiService from '../../data/services/EnterpriseDataApiService'; +const UserEmail = ({ row }) => ( + {row.original.userEmail} +); + +UserEmail.propTypes = { + row: PropTypes.shape({ + original: PropTypes.shape({ + userEmail: PropTypes.string, + }).isRequired, + }).isRequired, +}; -const RegisteredLearnersTable = () => { +const RegisteredLearnersTable = ({ id, enterpriseId }) => { const intl = useIntl(); + const location = useLocation(); + const navigate = useNavigate(); + const { setTableHasData } = useTableData(); - const tableColumns = [ + // Parse the current page from URL query parameters - adjust for zero-based indexing + const queryParams = useMemo(() => new URLSearchParams(location.search), [location.search]); + const pageFromUrl = parseInt(queryParams.get(`${id}-page`), 10) || 1; // Default to page 1 in URL + const currentPageFromUrl = pageFromUrl - 1; // Convert to zero-based for DataTable + + const apiFieldsForColumnAccessor = useMemo(() => ({ + userEmail: { key: 'user_email' }, + lmsUserCreated: { key: 'lms_user_created' }, + }), []); + + const columns = [ { - label: intl.formatMessage({ + Header: intl.formatMessage({ id: 'admin.portal.lpr.registered.learners.table.user_email.column.heading', defaultMessage: 'Email', description: 'Column heading for the user email column in the registered learners table', }), - key: 'user_email', - columnSortable: true, + accessor: 'userEmail', + Cell: UserEmail, }, { - label: intl.formatMessage({ + Header: intl.formatMessage({ id: 'admin.portal.lpr.registered.learners.table.lms_user_created.column.heading', defaultMessage: 'Account Created', description: 'Column heading for the lms user created column in the registered learners table', }), - key: 'lms_user_created', - columnSortable: true, + accessor: 'lmsUserCreated', + Cell: ({ row }) => i18nFormatTimestamp({ intl, timestamp: row.values.lmsUserCreated }), }, ]; - const formatLearnerData = learners => learners.map(learner => ({ - ...learner, - user_email: {learner.user_email}, - lms_user_created: i18nFormatTimestamp({ - intl, timestamp: learner.lms_user_created, - }), - })); + const { + isLoading, + data: registeredLearners, + fetchData: fetchRegisteredLearners, + fetchDataImmediate, + hasData, + } = useRegisteredLearners(enterpriseId, id, apiFieldsForColumnAccessor); + + useEffect(() => { + fetchDataImmediate({ + pageIndex: currentPageFromUrl, + pageSize: PAGE_SIZE, + sortBy: [], + }, true); + }, []); + + // Update context when data status changes + useEffect(() => { + setTableHasData(id, hasData); + }, [id, hasData]); + + const fetchTableData = useCallback((tableState) => { + const newPageForUrl = tableState.pageIndex + 1; // Convert zero-based index to one-based for URL + updateUrlWithPageNumber(id, newPageForUrl, location, navigate); + + return fetchRegisteredLearners(tableState); + }, [fetchRegisteredLearners]); return ( - ); }; -export default RegisteredLearnersTable; +RegisteredLearnersTable.propTypes = { + id: PropTypes.string.isRequired, + enterpriseId: PropTypes.string.isRequired, +}; + +const mapStateToProps = state => ({ + enterpriseId: state.portalConfiguration.enterpriseId, +}); + +export default connect(mapStateToProps)(RegisteredLearnersTable); diff --git a/src/data/constants/table.js b/src/data/constants/table.js index aeb1588897..c4ff23b9aa 100644 --- a/src/data/constants/table.js +++ b/src/data/constants/table.js @@ -5,6 +5,8 @@ const SORT_REQUEST = 'SORT_REQUEST'; const SORT_SUCCESS = 'SORT_SUCCESS'; const SORT_FAILURE = 'SORT_FAILURE'; const CLEAR_TABLE = 'CLEAR_TABLE'; +const PAGE_SIZE = 50; +const DEFAULT_PAGE = 0; export { PAGINATION_REQUEST, @@ -14,4 +16,6 @@ export { SORT_SUCCESS, SORT_FAILURE, CLEAR_TABLE, + PAGE_SIZE, + DEFAULT_PAGE, }; diff --git a/src/eventTracking.js b/src/eventTracking.js index a8962ce12d..fd5a60948f 100644 --- a/src/eventTracking.js +++ b/src/eventTracking.js @@ -19,6 +19,7 @@ const CONTENT_HIGHLIGHTS_PREFIX = `${PROJECT_NAME}.content_highlights`; const LEARNER_CREDIT_MANAGEMENT_PREFIX = `${PROJECT_NAME}.learner_credit_management`; const GROUPS_PEOPLE_MANAGEMENT_PREFIX = `${PROJECT_NAME}.people_management`; const LEARNER_PROGRESS_REPORT_PREFIX = `${PROJECT_NAME}.learner_progress_report`; +const PROGRESS_REPORT_PREFIX = `${PROJECT_NAME}.progress_report`; // people-management const PEOPLE_MANAGEMENT_EVENTS = { @@ -123,6 +124,10 @@ export const CONTENT_HIGHLIGHTS_EVENTS = { const SETTINGS_ACCESS_PREFIX = `${SETTINGS_PREFIX}.ACCESS`; +export const PROGRESS_REPORT_EVENTS = { + DATATABLE_SORT_BY_OR_FILTER: `${PROGRESS_REPORT_PREFIX}.datatable.sort_by_or_filter.changed`, +}; + export const SETTINGS_ACCESS_EVENTS = { UNIVERSAL_LINK_TOGGLE: `${SETTINGS_ACCESS_PREFIX}.universal-link.toggle.clicked`, UNIVERSAL_LINK_GENERATE: `${SETTINGS_ACCESS_PREFIX}.universal-link.generate.clicked`, @@ -228,6 +233,7 @@ const EVENT_NAMES = { PEOPLE_MANAGEMENT: PEOPLE_MANAGEMENT_EVENTS, LEARNER_PROGRESS_REPORT: LEARNER_PROGRESS_REPORT_EVENTS, LEARNER_PROFILE_VIEW: LEARNER_PROFILE_VIEW_EVENTS, + PROGRESS_REPORT: PROGRESS_REPORT_EVENTS, }; export default EVENT_NAMES; diff --git a/src/hooks/tests/usePaginatedTableData.test.jsx b/src/hooks/tests/usePaginatedTableData.test.jsx new file mode 100644 index 0000000000..233508ff53 --- /dev/null +++ b/src/hooks/tests/usePaginatedTableData.test.jsx @@ -0,0 +1,176 @@ +import { renderHook, act } from '@testing-library/react-hooks'; +import { camelCaseObject } from '@edx/frontend-platform/utils'; +import { logError } from '@edx/frontend-platform/logging'; +import { trackDataTableEvent } from '../../utils'; +import usePaginatedTableData from '../usePaginatedTableData'; + +// Mock external dependencies +jest.mock('@edx/frontend-platform/utils', () => ({ + ...jest.requireActual('@edx/frontend-platform/utils'), + camelCaseObject: jest.fn(), +})); + +jest.mock('../../utils', () => ({ + ...jest.requireActual('../../utils'), + trackDataTableEvent: jest.fn(), + applySortByToOptions: jest.fn(), +})); + +jest.mock('@edx/frontend-platform/logging', () => ({ + logError: jest.fn(), +})); + +jest.mock('lodash.debounce', () => jest.fn((fn) => fn)); + +describe('usePaginatedTableData', () => { + const mockFetchFunction = jest.fn(); + const defaultProps = { + enterpriseId: 'enterprise123', + tableId: 'table123', + apiFieldsForColumnAccessor: { column1: 'field1', column2: 'field2' }, + fetchFunction: mockFetchFunction, + }; + + beforeEach(() => { + jest.clearAllMocks(); + }); + + it('should return initial loading state', () => { + const { result } = renderHook(() => usePaginatedTableData(defaultProps)); + + expect(result.current.isLoading).toBe(true); + expect(result.current.data).toEqual({ itemCount: 0, pageCount: 0, results: [] }); + expect(result.current.hasData).toBe(false); + }); + + it('should fetch data and update state on success', async () => { + const mockData = { + data: { + count: 10, + numPages: 2, + results: [{ id: 1 }, { id: 2 }], + }, + }; + + mockFetchFunction.mockResolvedValueOnce(mockData); + camelCaseObject.mockReturnValueOnce(mockData.data); + + const { result } = renderHook(() => usePaginatedTableData(defaultProps)); + + await act(async () => { + await result.current.fetchData({ pageIndex: 0, pageSize: 5, sortBy: [] }); + }); + + expect(mockFetchFunction).toHaveBeenCalledWith('enterprise123', { page: 1, pageSize: 5 }); + expect(result.current.isLoading).toBe(false); + expect(result.current.data).toEqual({ + itemCount: 10, + pageCount: 2, + results: [{ id: 1 }, { id: 2 }], + }); + expect(result.current.hasData).toBe(true); + }); + + it('should handle fetch errors', async () => { + const mockError = new Error('Fetch error'); + mockFetchFunction.mockRejectedValueOnce(mockError); + + const { result } = renderHook(() => usePaginatedTableData(defaultProps)); + + await act(async () => { + await result.current.fetchData({ pageIndex: 0, pageSize: 5, sortBy: [] }); + }); + + expect(logError).toHaveBeenCalledWith(mockError, expect.objectContaining({ + tableState: expect.objectContaining({ + tableId: 'table123', + enterpriseId: 'enterprise123', + filters: 'none', + sortBy: '[]', + }), + message: 'Error fetching data for table table123', + })); + expect(result.current.isLoading).toBe(false); + expect(result.current.data).toEqual({ itemCount: 0, pageCount: 0, results: [] }); + expect(result.current.hasData).toBe(false); + }); + + it('should track data table events', async () => { + const mockData = { + data: { + count: 10, + numPages: 2, + results: [{ id: 1 }, { id: 2 }], + }, + }; + + mockFetchFunction.mockResolvedValueOnce(mockData); + camelCaseObject.mockReturnValueOnce(mockData.data); + + const { result } = renderHook(() => usePaginatedTableData(defaultProps)); + + await act(async () => { + await result.current.fetchData({ pageIndex: 0, pageSize: 5, sortBy: [] }); + }); + + expect(trackDataTableEvent).toHaveBeenCalledWith({ + shouldTrackRef: expect.any(Object), + enterpriseId: 'enterprise123', + eventName: 'edx.ui.enterprise.admin_portal.progress_report.datatable.sort_by_or_filter.changed', + tableId: 'table123', + options: { page: 1, pageSize: 5 }, + }); + }); + + it('should return correct value for hasData', async () => { + const mockData = { + data: { + count: 10, + numPages: 2, + results: [{ id: 1 }, { id: 2 }], + }, + }; + + mockFetchFunction.mockResolvedValueOnce(mockData); + camelCaseObject.mockReturnValueOnce(mockData.data); + + const { result } = renderHook(() => usePaginatedTableData(defaultProps)); + + await act(async () => { + await result.current.fetchData({ pageIndex: 0, pageSize: 5, sortBy: [] }); + }); + + expect(result.current.hasData).toBe(true); + + const emptyMockData = { data: { count: 0, numPages: 0, results: [] } }; + mockFetchFunction.mockResolvedValueOnce(emptyMockData); + camelCaseObject.mockReturnValueOnce(emptyMockData.data); + + await act(async () => { + await result.current.fetchData({ pageIndex: 0, pageSize: 5, sortBy: [] }); + }); + + expect(result.current.hasData).toBe(false); + }); + + it('should debounce the fetchData function', async () => { + const mockData = { + data: { + count: 10, + numPages: 2, + results: [{ id: 1 }, { id: 2 }], + }, + }; + + mockFetchFunction.mockResolvedValueOnce(mockData); + camelCaseObject.mockReturnValueOnce(mockData.data); + + const { result } = renderHook(() => usePaginatedTableData(defaultProps)); + + await act(async () => { + await result.current.fetchData({ pageIndex: 0, pageSize: 5, sortBy: [] }); + }); + + expect(mockFetchFunction).toHaveBeenCalledTimes(1); + }); +}); diff --git a/src/hooks/usePaginatedTableData.jsx b/src/hooks/usePaginatedTableData.jsx new file mode 100644 index 0000000000..5ccb629fd6 --- /dev/null +++ b/src/hooks/usePaginatedTableData.jsx @@ -0,0 +1,107 @@ +import { + useCallback, useMemo, useRef, useState, +} from 'react'; +import { camelCaseObject } from '@edx/frontend-platform/utils'; +import debounce from 'lodash.debounce'; +import { logError } from '@edx/frontend-platform/logging'; +import { applySortByToOptions, trackDataTableEvent } from '../utils'; +import EVENT_NAMES from '../eventTracking'; + +/** + * A reusable React hook for fetching paginated, sortable table data from an API. + * + * @param {Object} params + * @param {string} params.enterpriseId - The enterprise identifier used for the API call. + * @param {string} params.tableId - An ID used for event tracking and error context. + * @param {Object} params.apiFieldsForColumnAccessor - Mapping of column IDs to API field keys. + * @param {Function} params.fetchFunction - A function to fetch the data. Must return a promise + * resolving to an object with `.data`. + * + * @returns {{ + * isLoading: boolean, + * data: { + * itemCount: number, + * pageCount: number, + * results: Array, + * }, + * fetchData: Function, + * hasData: boolean, + * }} + */ +const usePaginatedTableData = ({ + enterpriseId, + tableId, + apiFieldsForColumnAccessor, + fetchFunction, + fetchFunctionOptions, +}) => { + const shouldTrackFetchEvents = useRef(false); + + const [isLoading, setIsLoading] = useState(true); + const [tableData, setTableData] = useState({ + itemCount: 0, + pageCount: 0, + results: [], + }); + + /** + * Fetches paginated data using the provided fetchFunction. + */ + const fetchData = useCallback(async (args) => { + try { + setIsLoading(true); + const options = { + page: args.pageIndex + 1, + pageSize: args.pageSize, + }; + applySortByToOptions(args.sortBy, apiFieldsForColumnAccessor, options); + + const response = await fetchFunction(enterpriseId, { + ...fetchFunctionOptions, + ...options, + }); + const data = camelCaseObject(response.data); + + setTableData({ + itemCount: data.count, + pageCount: data.numPages ?? Math.floor(data.count / options.pageSize), + results: data.results, + }); + + trackDataTableEvent({ + shouldTrackRef: shouldTrackFetchEvents, + enterpriseId, + eventName: EVENT_NAMES.PROGRESS_REPORT.DATATABLE_SORT_BY_OR_FILTER, + tableId, + options, + }); + } catch (error) { + logError(error, { + tableState: { + tableId, + enterpriseId, + filters: args.filters || 'none', + sortBy: JSON.stringify(args.sortBy || []), + }, + message: `Error fetching data for table ${tableId}`, + }); + } finally { + setIsLoading(false); + } + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [enterpriseId, tableId, apiFieldsForColumnAccessor, fetchFunction]); + + const debouncedFetchData = useMemo(() => debounce(fetchData, 300), [fetchData]); + + const hasData = useMemo(() => tableData.results.length > 0, [tableData.results]); + + return { + isLoading, + data: tableData, + fetchData: debouncedFetchData, + fetchDataImmediate: fetchData, + hasData, + }; +}; + +export default usePaginatedTableData; diff --git a/src/utils.js b/src/utils.js index 9a02688402..498f44d8a5 100644 --- a/src/utils.js +++ b/src/utils.js @@ -15,6 +15,8 @@ import isNumeric from 'validator/lib/isNumeric'; import { logError } from '@edx/frontend-platform/logging'; import { snakeCaseObject } from '@edx/frontend-platform/utils'; +import { sendEnterpriseTrackEvent } from '@edx/frontend-enterprise-utils'; + import { features } from './config'; import { @@ -720,6 +722,102 @@ const getFromLocalStorage = (key) => { return savedValue ? JSON.parse(savedValue) : null; }; +/** This utility was extracted from individual table components where it was being + * reused across multiple places. Centralizing it here helps maintain DRY principles + * and improves maintainability. + * + * @param {Array} sortBy - Array of sort configuration objects ({ id, desc }). + * @param {Object} apiFieldsForColumnAccessor - Mapping of column ids to API field definitions. + * @param {Object} options - Object to which the generated ordering string will be assigned. + */ +const applySortByToOptions = (sortBy, apiFieldsForColumnAccessor, options) => { + if (!sortBy || sortBy.length === 0) { + return; + } + + const orderingStrings = sortBy + .map(({ id, desc }) => { + const fieldForColumnAccessor = apiFieldsForColumnAccessor[id]; + if (!fieldForColumnAccessor) { + return undefined; + } + const apiFieldKey = fieldForColumnAccessor.key; + return desc ? `-${apiFieldKey}` : apiFieldKey; + }) + .filter(Boolean); + + Object.assign(options, { + ordering: orderingStrings.join(','), + }); +}; + +/** + * Tracks a DataTable event for user interactions such as pagination, sorting, or filtering. + * Ensures that events are not sent on the initial page load, but only after the user interacts with the table. + * + * @param {Object} params - Parameters for tracking the event. + * @param {Object} params.shouldTrackRef - A ref object used to determine whether tracking should occur. + * @param {string} params.enterpriseId - The ID of the enterprise context for the event. + * @param {string} params.eventName - The name of the event to be tracked. + * @param {string} params.tableId - Unique identifier for the DataTable. + * @param {Object} params.options - Additional data to be sent with the event (e.g., filters, sort, page info). + * + * @returns {boolean} Updated value of shouldTrackRef.current indicating if tracking is now enabled. + */ +const trackDataTableEvent = ({ + shouldTrackRef, + enterpriseId, + eventName, + tableId, + options, +}) => { + if (shouldTrackRef.current) { + // track event only after original API query to avoid sending event on initial page load + // only track event when user performs manual data operation (e.g., pagination, sort, filter) + sendEnterpriseTrackEvent( + enterpriseId, + eventName, + { + tableId, + ...options, + }, + ); + } else { + // set to true to enable tracking events on future API queries + // eslint-disable-next-line no-param-reassign + shouldTrackRef.current = true; + } + + return shouldTrackRef.current; +}; + +/** + * Tracks a DataTable event for user interactions such as pagination, sorting, or filtering. + * Ensures that events are not sent on the initial page load, but only after the user interacts with the table. + * + * @param {Object} params - Parameters for tracking the event. + * @param {Object} params.shouldTrackRef - A ref object used to determine whether tracking should occur. + * @param {string} params.enterpriseId - The ID of the enterprise context for the event. + * @param {string} params.eventName - The name of the event to be tracked. + * @param {string} params.tableId - Unique identifier for the DataTable. + * @param {Object} params.options - Additional data to be sent with the event (e.g., filters, sort, page info). + * + * @returns {boolean} Updated value of shouldTrackRef.current indicating if tracking is now enabled. + */ +const updateUrlWithPageNumber = (tableId, pageNumber, location, navigate, replace = true) => { + const newQueryParams = new URLSearchParams(location.search); + + if (pageNumber !== 1) { // Default page is 1 + newQueryParams.set(`${tableId}-page`, pageNumber); + } else { + newQueryParams.delete(`${tableId}-page`); + } + + const newSearch = newQueryParams.toString(); + const queryString = newSearch ? `?${newSearch}` : ''; + navigate(`${location.pathname}${queryString}`, { replace }); +}; + export { camelCaseDict, camelCaseDictArray, @@ -774,4 +872,7 @@ export { removeStringsFromListCaseInsensitive, saveToLocalStorage, getFromLocalStorage, + applySortByToOptions, + trackDataTableEvent, + updateUrlWithPageNumber, }; diff --git a/src/utils.test.js b/src/utils.test.js index f081cbcf06..178c59b8bf 100644 --- a/src/utils.test.js +++ b/src/utils.test.js @@ -21,8 +21,20 @@ import { removeStringsFromListCaseInsensitive, saveToLocalStorage, getFromLocalStorage, + applySortByToOptions, + trackDataTableEvent, + updateUrlWithPageNumber, + } from './utils'; +jest.mock('@edx/frontend-enterprise-utils', () => ({ + ...jest.requireActual('@edx/frontend-enterprise-utils'), + sendEnterpriseTrackEvent: jest.fn(), +})); +// Needed to mock before import so jest uses the mock correctly +// eslint-disable-next-line import/order, import/first +import { sendEnterpriseTrackEvent } from '@edx/frontend-enterprise-utils'; + jest.mock('@edx/frontend-platform/logging', () => ({ ...jest.requireActual('@edx/frontend-platform/logging'), logError: jest.fn(), @@ -283,4 +295,185 @@ describe('utils', () => { }); }); }); + describe('applySortByToOptions', () => { + it('should not modify options when sortBy is empty', () => { + const options = {}; + const sortBy = []; + const apiFieldsForColumnAccessor = { someColumn: { key: 'column_name' } }; + + applySortByToOptions(sortBy, apiFieldsForColumnAccessor, options); + + expect(options).toEqual({}); + }); + + it('should correctly apply sorting options when sortBy is provided', () => { + const options = {}; + const sortBy = [ + { id: 'someColumn', desc: false }, + { id: 'anotherColumn', desc: true }, + ]; + const apiFieldsForColumnAccessor = { + someColumn: { key: 'column_name' }, + anotherColumn: { key: 'another_column' }, + }; + + applySortByToOptions(sortBy, apiFieldsForColumnAccessor, options); + + expect(options).toEqual({ + ordering: 'column_name,-another_column', // Ensure ordering matches the expected format + }); + }); + + it('should handle missing keys in apiFieldsForColumnAccessor gracefully', () => { + const options = {}; + const sortBy = [ + { id: 'nonExistentColumn', desc: false }, + { id: 'someColumn', desc: true }, + ]; + const apiFieldsForColumnAccessor = { + someColumn: { key: 'column_name' }, + }; + + applySortByToOptions(sortBy, apiFieldsForColumnAccessor, options); + + expect(options).toEqual({ + ordering: '-column_name', // Only column_name should be included in ordering + }); + }); + + it('should return undefined if sortBy is not provided', () => { + const options = {}; + const sortBy = null; + const apiFieldsForColumnAccessor = { someColumn: { key: 'column_name' } }; + + applySortByToOptions(sortBy, apiFieldsForColumnAccessor, options); + + expect(options).toEqual({}); + }); + }); + describe('trackDataTableEvent', () => { + beforeEach(() => { + jest.clearAllMocks(); + }); + + it('sends track event when shouldTrackRef.current is true', () => { + const shouldTrackRef = { current: true }; + const enterpriseId = 'test-enterprise-id'; + const eventName = 'test-event-name'; + const tableId = 'test-table-id'; + const options = { page: 1, pageSize: 10 }; + + const result = trackDataTableEvent({ + shouldTrackRef, + enterpriseId, + eventName, + tableId, + options, + }); + + expect(sendEnterpriseTrackEvent).toHaveBeenCalledWith( + enterpriseId, + eventName, + { + tableId, + ...options, + }, + ); + expect(result).toBe(true); + expect(shouldTrackRef.current).toBe(true); + }); + + it('does not send track event but sets shouldTrackRef to true when initial value is false', () => { + const shouldTrackRef = { current: false }; + const enterpriseId = 'test-enterprise-id'; + const eventName = 'test-event-name'; + const tableId = 'test-table-id'; + const options = { page: 1, pageSize: 10 }; + + const result = trackDataTableEvent({ + shouldTrackRef, + enterpriseId, + eventName, + tableId, + options, + }); + + expect(sendEnterpriseTrackEvent).not.toHaveBeenCalled(); + expect(result).toBe(true); + expect(shouldTrackRef.current).toBe(true); + }); + + it('handles undefined options properly', () => { + const shouldTrackRef = { current: true }; + const enterpriseId = 'test-enterprise-id'; + const eventName = 'test-event-name'; + const tableId = 'test-table-id'; + + trackDataTableEvent({ + shouldTrackRef, + enterpriseId, + eventName, + tableId, + }); + + expect(sendEnterpriseTrackEvent).toHaveBeenCalledWith( + enterpriseId, + eventName, + { + tableId, + }, + ); + }); + }); + describe('updateUrlWithPageNumber', () => { + const mockNavigate = jest.fn(); + const createMockLocation = (search = '') => ({ + pathname: '/test-path', + search, + }); + + beforeEach(() => { + jest.clearAllMocks(); + }); + + it('adds page number to URL when pageNumber is not 1', () => { + const location = createMockLocation(); + const pageNumber = 3; + const id = 'test-table'; + + updateUrlWithPageNumber(id, pageNumber, location, mockNavigate); + + expect(mockNavigate).toHaveBeenCalledWith('/test-path?test-table-page=3', { replace: true }); + }); + + it('removes page number from URL when pageNumber is 1', () => { + const location = createMockLocation('?test-table-page=3&filter=active'); + const pageNumber = 1; + const id = 'test-table'; + + updateUrlWithPageNumber(id, pageNumber, location, mockNavigate); + + expect(mockNavigate).toHaveBeenCalledWith('/test-path?filter=active', { replace: true }); + }); + + it('respects existing query parameters when adding page number', () => { + const location = createMockLocation('?filter=active&sort=name'); + const pageNumber = 2; + const id = 'test-table'; + + updateUrlWithPageNumber(id, pageNumber, location, mockNavigate); + + expect(mockNavigate).toHaveBeenCalledWith('/test-path?filter=active&sort=name&test-table-page=2', { replace: true }); + }); + + it('uses replace=false when specified', () => { + const location = createMockLocation(); + const pageNumber = 2; + const id = 'test-table'; + + updateUrlWithPageNumber(id, pageNumber, location, mockNavigate, false); + + expect(mockNavigate).toHaveBeenCalledWith('/test-path?test-table-page=2', { replace: false }); + }); + }); });