From 9ae143e15a372342d6a77c3c358b5215b02a1465 Mon Sep 17 00:00:00 2001 From: irfanuddinahmad Date: Fri, 23 May 2025 18:28:08 +0500 Subject: [PATCH] feat: replaced TableContainer with DataTable in enrolledLearnersTable --- src/components/Admin/Admin.test.jsx | 4 - .../Admin/DownloadButtonWrapper.jsx | 1 + src/components/Admin/index.jsx | 2 +- .../EnrolledLearnersTable.test.jsx | 67 +++++---- .../EnrolledLearnersTable.test.jsx.snap | 37 ----- .../data/hooks/useCourseUsers.js | 11 ++ .../data/hooks/useCourseUsers.test.js | 130 ++++++++++++++++ .../data/tests/constants.js | 34 +++++ .../EnrolledLearnersTable/index.jsx | 141 ++++++++++++++---- 9 files changed, 328 insertions(+), 99 deletions(-) delete mode 100644 src/components/EnrolledLearnersTable/__snapshots__/EnrolledLearnersTable.test.jsx.snap create mode 100644 src/components/EnrolledLearnersTable/data/hooks/useCourseUsers.js create mode 100644 src/components/EnrolledLearnersTable/data/hooks/useCourseUsers.test.js create mode 100644 src/components/EnrolledLearnersTable/data/tests/constants.js diff --git a/src/components/Admin/Admin.test.jsx b/src/components/Admin/Admin.test.jsx index eaf8516356..32ac2967bc 100644 --- a/src/components/Admin/Admin.test.jsx +++ b/src/components/Admin/Admin.test.jsx @@ -480,10 +480,6 @@ describe('', () => { csvFetchMethod: 'fetchCourseEnrollments', csvFetchParams: [enterpriseId, {}, { csv: true }], }, - 'enrolled-learners': { - csvFetchMethod: 'fetchEnrolledLearners', - csvFetchParams: [enterpriseId, {}, { csv: true }], - }, }; afterEach(() => { diff --git a/src/components/Admin/DownloadButtonWrapper.jsx b/src/components/Admin/DownloadButtonWrapper.jsx index df0b3cef74..8b1014acb3 100644 --- a/src/components/Admin/DownloadButtonWrapper.jsx +++ b/src/components/Admin/DownloadButtonWrapper.jsx @@ -17,6 +17,7 @@ const DownloadButtonWrapper = ({ 'learners-inactive-month', 'registered-unenrolled-learners', 'enrolled-learners-inactive-courses', + 'enrolled-learners', 'completed-learners', 'completed-learners-week', ].includes(actionSlug); diff --git a/src/components/Admin/index.jsx b/src/components/Admin/index.jsx index 3b10af483e..c6e112e6d8 100644 --- a/src/components/Admin/index.jsx +++ b/src/components/Admin/index.jsx @@ -125,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 }) ), 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);