diff --git a/src/components/Admin/CompletedLearnersTable.jsx b/src/components/Admin/CompletedLearnersTable.jsx new file mode 100644 index 0000000000..639cac0157 --- /dev/null +++ b/src/components/Admin/CompletedLearnersTable.jsx @@ -0,0 +1,64 @@ +import React from 'react'; +import { useIntl } from '@edx/frontend-platform/i18n'; +import { DataTable } from '@openedx/paragon'; +import { useGenericTableData } from './data/hooks'; +import EnterpriseDataApiService from '../../data/services/EnterpriseDataApiService'; +import Email from './Email'; + +const CompletedLearnersTable = (enterpriseId) => { + const intl = useIntl(); + const { + isLoading, + tableData, + fetchTableData, + } = useGenericTableData( + enterpriseId, + 'completed-learners', + EnterpriseDataApiService.fetchCompletedLearners, + { + userEmail: { key: 'user_email' }, + completedCourses: { key: 'completed_courses' }, + }, + ); + + return ( + + ); +}; + +export default CompletedLearnersTable; diff --git a/src/components/CompletedLearnersTable/CompletedLearnersTable.test.jsx b/src/components/Admin/CompletedLearnersTable.test.jsx similarity index 72% rename from src/components/CompletedLearnersTable/CompletedLearnersTable.test.jsx rename to src/components/Admin/CompletedLearnersTable.test.jsx index ece4178180..3a79c64fb8 100644 --- a/src/components/CompletedLearnersTable/CompletedLearnersTable.test.jsx +++ b/src/components/Admin/CompletedLearnersTable.test.jsx @@ -6,26 +6,20 @@ import configureMockStore from 'redux-mock-store'; import thunk from 'redux-thunk'; import { Provider } from 'react-redux'; -import CompletedLearnersTable from '.'; +import CompletedLearnersTable from './CompletedLearnersTable'; +import { useGenericTableData } from './data/hooks'; const mockStore = configureMockStore([thunk]); const enterpriseId = 'test-enterprise'; + +jest.mock('./data/hooks/useGenericTableData', () => ( + jest.fn().mockReturnValue({}) +)); + 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 => ( @@ -42,6 +36,15 @@ const CompletedLearnersWrapper = props => ( describe('CompletedLearnersTable', () => { it('renders empty state correctly', () => { + useGenericTableData.mockReturnValue({ + isLoading: false, + tableData: { + results: [], + itemCount: 0, + pageCount: 0, + }, + fetchTableData: jest.fn(), + }); const tree = renderer .create(( diff --git a/src/components/Admin/Email.jsx b/src/components/Admin/Email.jsx new file mode 100644 index 0000000000..aeea99a39b --- /dev/null +++ b/src/components/Admin/Email.jsx @@ -0,0 +1,16 @@ +import React from 'react'; +import PropTypes from 'prop-types'; + +const Email = ({ row }) => ( + {row.original.userEmail} +); + +Email.propTypes = { + row: PropTypes.shape({ + original: PropTypes.shape({ + userEmail: PropTypes.string.isRequired, + }).isRequired, + }).isRequired, +}; + +export default Email; diff --git a/src/components/Admin/Email.test.jsx b/src/components/Admin/Email.test.jsx new file mode 100644 index 0000000000..15b53c4df4 --- /dev/null +++ b/src/components/Admin/Email.test.jsx @@ -0,0 +1,42 @@ +import React from 'react'; +import { render, screen } from '@testing-library/react'; +import '@testing-library/jest-dom'; +import Email from './Email'; + +describe('Email Component', () => { + it('should display the user email and suppress highlighting', () => { + const mockRow = { + original: { + userEmail: 'test@example.com', + }, + }; + + render( + , + ); + + // Assert that the email is rendered correctly + const emailElement = screen.getByText('test@example.com'); + expect(emailElement).toBeInTheDocument(); + + // Assert that data-hj-suppress is present to suppress highlighting + expect(emailElement).toHaveAttribute('data-hj-suppress'); + }); + + it('should throw a prop-type warning if the email is not provided', () => { + const consoleSpy = jest.spyOn(console, 'error').mockImplementation(() => {}); + + const invalidRow = { + original: {}, + }; + + // Render with invalid props + render(); + + // Assert that a prop-types error has been logged + expect(consoleSpy).toHaveBeenCalled(); + + // Clean up the spy + consoleSpy.mockRestore(); + }); +}); diff --git a/src/components/Admin/__snapshots__/CompletedLearnersTable.test.jsx.snap b/src/components/Admin/__snapshots__/CompletedLearnersTable.test.jsx.snap new file mode 100644 index 0000000000..ecbab29c16 --- /dev/null +++ b/src/components/Admin/__snapshots__/CompletedLearnersTable.test.jsx.snap @@ -0,0 +1,236 @@ +// Jest Snapshot v1, https://goo.gl/fbAQLP + +exports[`CompletedLearnersTable renders empty state correctly 1`] = ` +
+
+
+
+
+
+
+
+
+
+
+
+
+ + + + + + + + +
+ + + Email + + + + + + + + + + + Total Course Completed Count + + + + + + + +
+
+
+ No results found +
+
+ +
+
+
+
+`; diff --git a/src/components/Admin/data/hooks/index.js b/src/components/Admin/data/hooks/index.js index 0932cd221d..43104c3661 100644 --- a/src/components/Admin/data/hooks/index.js +++ b/src/components/Admin/data/hooks/index.js @@ -1,55 +1,2 @@ -import { useEffect, useState } from 'react'; -import { logError } from '@edx/frontend-platform/logging'; -import EnterpriseDataApiService from '../../../../data/services/EnterpriseDataApiService'; - -const useModuleActivityReport = ({ - enterpriseId, page, filters, searchQuery, -}) => { - const [isLoading, setIsLoading] = useState(true); - - const [paginationData, setPaginationData] = useState({ - itemCount: 0, - pageCount: 0, - data: [], - }); - - useEffect(() => { - // Reset the loading state - setIsLoading(true); - - EnterpriseDataApiService.fetchEnterpriseModuleActivityReport(enterpriseId, { - page: page + 1, - search: searchQuery, - ...filters, - }) - .then((response) => { - setPaginationData({ - itemCount: response.data.count, - pageCount: response.data.num_pages, - data: response.data.results, - currentPage: response.data.currentPage, - }); - - // Reset the loading state - setIsLoading(false); - }) - .catch((err) => { - logError(err); - - // Reset the loading state - setIsLoading(false); - }); - }, [ - enterpriseId, - page, - filters, - searchQuery, - ]); - - return { - isLoading, - paginationData, - }; -}; - -export default useModuleActivityReport; +export { default as useGenericTableData } from './useGenericTableData'; +export { default as useModuleActivityReport } from './useModuleActivityReport'; diff --git a/src/components/Admin/data/hooks/useGenericTableData.js b/src/components/Admin/data/hooks/useGenericTableData.js new file mode 100644 index 0000000000..c8896326a9 --- /dev/null +++ b/src/components/Admin/data/hooks/useGenericTableData.js @@ -0,0 +1,88 @@ +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 { sendEnterpriseTrackEvent } from '@edx/frontend-enterprise-utils'; +import EVENT_NAMES from '../../../../eventTracking'; + +const applySortByToOptions = (sortBy, options, apiFieldsForColumnAccessor) => { + if (!sortBy || sortBy.length === 0) { + return; + } + const orderingStrings = sortBy.map(({ id, desc }) => { + const apiFieldForColumnAccessor = apiFieldsForColumnAccessor[id]; + if (!apiFieldForColumnAccessor) { + return undefined; + } + const apiFieldKey = apiFieldForColumnAccessor.key; + return desc ? `-${apiFieldKey}` : apiFieldKey; + }).filter(orderingString => !!orderingString); + + Object.assign(options, { + ordering: orderingStrings.join(','), + }); +}; + +const useGenericTableData = (enterpriseId, tableId, fetchMethod, apiFields) => { + const shouldTrackFetchEvents = useRef(false); + const [isLoading, setIsLoading] = useState(true); + const [tableData, setTableData] = useState({ + itemCount: 0, + pageCount: 0, + results: [], + }); + + const memoizedApiFields = useMemo(() => apiFields, [apiFields]); + + const fetchTableData = useCallback(async (args) => { + try { + setIsLoading(true); + const options = { + page: args.pageIndex + 1, + pageSize: args.pageSize, + }; + applySortByToOptions(args.sortBy, options, memoizedApiFields); + + const response = await fetchMethod(enterpriseId, options); + const data = camelCaseObject(response.data); + + setTableData({ + itemCount: data.count, + pageCount: data.numPages ?? Math.floor(data.count / options.pageSize), + results: data.results, + }); + + if (shouldTrackFetchEvents.current) { + sendEnterpriseTrackEvent( + enterpriseId, + EVENT_NAMES.PROGRESS_REPORT.DATATABLE_SORT_BY_OR_FILTER, + { + tableId, + ...options, + }, + ); + } else { + shouldTrackFetchEvents.current = true; + } + } catch (error) { + logError(error); + } finally { + setIsLoading(false); + } + }, [enterpriseId, tableId, fetchMethod, memoizedApiFields]); + + const debouncedFetchTableData = useMemo( + () => debounce(fetchTableData, 300), + [fetchTableData], + ); + + return { + isLoading, + tableData, + fetchTableData: debouncedFetchTableData, + }; +}; + +export default useGenericTableData; diff --git a/src/components/Admin/data/hooks/useGenericTableData.test.js b/src/components/Admin/data/hooks/useGenericTableData.test.js new file mode 100644 index 0000000000..4800204052 --- /dev/null +++ b/src/components/Admin/data/hooks/useGenericTableData.test.js @@ -0,0 +1,157 @@ +import { renderHook } from '@testing-library/react-hooks'; +import debounce from 'lodash.debounce'; +import { logError } from '@edx/frontend-platform/logging'; +import { sendEnterpriseTrackEvent } from '@edx/frontend-enterprise-utils'; +import EVENT_NAMES from '../../../../eventTracking'; +import useGenericTableData from './useGenericTableData'; + +jest.mock('lodash.debounce', () => jest.fn(fn => fn)); +jest.mock('@edx/frontend-platform/logging', () => ({ + logError: jest.fn(), +})); +jest.mock('@edx/frontend-enterprise-utils', () => { + const originalModule = jest.requireActual('@edx/frontend-enterprise-utils'); + return ({ + ...originalModule, + sendEnterpriseTrackEvent: jest.fn(), + }); +}); + +const mockFetchMethod = jest.fn(); +const mockEnterpriseId = 'test-enterprise-id'; +const mockTableId = 'test-table-id'; +const mockApiFields = { + userEmail: { key: 'user_email' }, + completedCourses: { key: 'completed_courses' }, +}; + +describe('useGenericTableData hook', () => { + beforeEach(() => { + mockFetchMethod.mockReset(); + sendEnterpriseTrackEvent.mockReset(); + debounce.mockClear(); + }); + + it('should initialize with loading state and empty data', () => { + const { result } = renderHook(() => useGenericTableData( + mockEnterpriseId, + mockTableId, + mockFetchMethod, + mockApiFields, + )); + + expect(result.current.isLoading).toBe(true); + + expect(result.current.tableData).toEqual({ + itemCount: 0, + pageCount: 0, + results: [], + }); + }); + + it('should call fetchMethod and update state when fetchTableData is invoked', async () => { + const mockResponseData = { + data: { + count: 2, + numPages: 1, + results: [{ user_email: 'test@example.com', completed_courses: 5 }], + }, + }; + + mockFetchMethod.mockResolvedValueOnce(mockResponseData); + + const { result, waitForNextUpdate } = renderHook(() => useGenericTableData( + mockEnterpriseId, + mockTableId, + mockFetchMethod, + mockApiFields, + )); + + result.current.fetchTableData({ pageIndex: 0, pageSize: 10, sortBy: [] }); + + await waitForNextUpdate(); + + expect(mockFetchMethod).toHaveBeenCalledWith(mockEnterpriseId, { page: 1, pageSize: 10 }); + expect(result.current.isLoading).toBe(false); + expect(result.current.tableData).toEqual({ + itemCount: 2, + pageCount: 1, + results: [{ userEmail: 'test@example.com', completedCourses: 5 }], + }); + }); + + it('should handle API errors and log the error', async () => { + const mockError = new Error('API failed'); + mockFetchMethod.mockRejectedValueOnce(mockError); + + const { result, waitForNextUpdate } = renderHook(() => useGenericTableData( + mockEnterpriseId, + mockTableId, + mockFetchMethod, + mockApiFields, + )); + + result.current.fetchTableData({ pageIndex: 0, pageSize: 10, sortBy: [] }); + + await waitForNextUpdate(); + + expect(logError).toHaveBeenCalledWith(mockError); + expect(result.current.isLoading).toBe(false); + expect(result.current.tableData).toEqual({ + itemCount: 0, + pageCount: 0, + results: [], + }); + }); + + it('should debounce fetchTableData', async () => { + const mockDebounce = jest.fn(fn => fn); + debounce.mockImplementationOnce(mockDebounce); + + const { result } = renderHook(() => useGenericTableData( + mockEnterpriseId, + mockTableId, + mockFetchMethod, + mockApiFields, + )); + + expect(debounce).toHaveBeenCalled(); + expect(typeof result.current.fetchTableData).toBe('function'); + }); + + it('should track event after fetching data', async () => { + const mockResponseData = { + data: { + count: 2, + numPages: 1, + results: [{ user_email: 'test@example.com', completed_courses: 5 }], + }, + }; + mockFetchMethod.mockResolvedValue(mockResponseData); + + const { result, waitForNextUpdate } = renderHook(() => useGenericTableData( + mockEnterpriseId, + mockTableId, + mockFetchMethod, + mockApiFields, + )); + + // First fetch (will not track the event) + result.current.fetchTableData({ pageIndex: 0, pageSize: 10, sortBy: [] }); + // // Second fetch (should track the event) + result.current.fetchTableData({ pageIndex: 1, pageSize: 10, sortBy: [] }); + + await waitForNextUpdate(); + + expect(sendEnterpriseTrackEvent).toHaveBeenCalledTimes(1); + expect(sendEnterpriseTrackEvent).toHaveBeenCalledWith( + mockEnterpriseId, + EVENT_NAMES.PROGRESS_REPORT.DATATABLE_SORT_BY_OR_FILTER, + { + tableId: mockTableId, + page: 2, + pageSize: 10, + }, + ); + }); +}); diff --git a/src/components/Admin/data/hooks/useModuleActivityReport.js b/src/components/Admin/data/hooks/useModuleActivityReport.js new file mode 100644 index 0000000000..0932cd221d --- /dev/null +++ b/src/components/Admin/data/hooks/useModuleActivityReport.js @@ -0,0 +1,55 @@ +import { useEffect, useState } from 'react'; +import { logError } from '@edx/frontend-platform/logging'; +import EnterpriseDataApiService from '../../../../data/services/EnterpriseDataApiService'; + +const useModuleActivityReport = ({ + enterpriseId, page, filters, searchQuery, +}) => { + const [isLoading, setIsLoading] = useState(true); + + const [paginationData, setPaginationData] = useState({ + itemCount: 0, + pageCount: 0, + data: [], + }); + + useEffect(() => { + // Reset the loading state + setIsLoading(true); + + EnterpriseDataApiService.fetchEnterpriseModuleActivityReport(enterpriseId, { + page: page + 1, + search: searchQuery, + ...filters, + }) + .then((response) => { + setPaginationData({ + itemCount: response.data.count, + pageCount: response.data.num_pages, + data: response.data.results, + currentPage: response.data.currentPage, + }); + + // Reset the loading state + setIsLoading(false); + }) + .catch((err) => { + logError(err); + + // Reset the loading state + setIsLoading(false); + }); + }, [ + enterpriseId, + page, + filters, + searchQuery, + ]); + + return { + isLoading, + paginationData, + }; +}; + +export default useModuleActivityReport; diff --git a/src/components/Admin/index.jsx b/src/components/Admin/index.jsx index d259d8f9a1..bb5b983067 100644 --- a/src/components/Admin/index.jsx +++ b/src/components/Admin/index.jsx @@ -15,7 +15,7 @@ import EnrollmentsTable from '../EnrollmentsTable'; import RegisteredLearnersTable from '../RegisteredLearnersTable'; import EnrolledLearnersTable from '../EnrolledLearnersTable'; import EnrolledLearnersForInactiveCoursesTable from '../EnrolledLearnersForInactiveCoursesTable'; -import CompletedLearnersTable from '../CompletedLearnersTable'; +import CompletedLearnersTable from './CompletedLearnersTable'; import PastWeekPassedLearnersTable from '../PastWeekPassedLearnersTable'; import LearnerActivityTable from '../LearnerActivityTable'; import DownloadCsvButton from '../../containers/DownloadCsvButton'; diff --git a/src/components/Admin/tabs/ModuleActivityReport.jsx b/src/components/Admin/tabs/ModuleActivityReport.jsx index 88cbe3fc37..753c9f9471 100644 --- a/src/components/Admin/tabs/ModuleActivityReport.jsx +++ b/src/components/Admin/tabs/ModuleActivityReport.jsx @@ -5,7 +5,7 @@ import PropTypes from 'prop-types'; import { useIntl } from '@edx/frontend-platform/i18n'; import EnterpriseDataApiService from '../../../data/services/EnterpriseDataApiService'; import DownloadCsvButton from './DownloadCSVButton'; -import useModuleActivityReport from '../data/hooks'; +import useModuleActivityReport from '../data/hooks/useModuleActivityReport'; import SearchBar from '../../SearchBar'; const ModuleActivityReport = ({ enterpriseId }) => { 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/index.jsx b/src/components/CompletedLearnersTable/index.jsx deleted file mode 100644 index 87bcda6bdf..0000000000 --- a/src/components/CompletedLearnersTable/index.jsx +++ /dev/null @@ -1,49 +0,0 @@ -import React from 'react'; - -import { useIntl } from '@edx/frontend-platform/i18n'; - -import TableContainer from '../../containers/TableContainer'; -import EnterpriseDataApiService from '../../data/services/EnterpriseDataApiService'; - -const CompletedLearnersTable = () => { - const intl = useIntl(); - - const tableColumns = [ - { - label: 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, - }, - { - label: 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, - }, - ]; - - const formatLearnerData = learners => learners.map(learner => ({ - ...learner, - user_email: {learner.user_email}, - })); - - return ( - - ); -}; - -export default CompletedLearnersTable; diff --git a/src/eventTracking.js b/src/eventTracking.js index f7dfd9872f..7525d89a36 100644 --- a/src/eventTracking.js +++ b/src/eventTracking.js @@ -17,6 +17,7 @@ const SUBSCRIPTION_PREFIX = `${PROJECT_NAME}.subscriptions`; const SETTINGS_PREFIX = `${PROJECT_NAME}.settings`; const CONTENT_HIGHLIGHTS_PREFIX = `${PROJECT_NAME}.content_highlights`; const LEARNER_CREDIT_MANAGEMENT_PREFIX = `${PROJECT_NAME}.learner_credit_management`; +const PROGRESS_REPORT_PREFIX = `${PROJECT_NAME}.progress_report`; // Sub-prefixes // Subscriptions @@ -95,6 +96,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`, @@ -184,6 +189,7 @@ const EVENT_NAMES = { SUBSCRIPTIONS: SUBSCRIPTION_EVENTS, CONTENT_HIGHLIGHTS: CONTENT_HIGHLIGHTS_EVENTS, LEARNER_CREDIT_MANAGEMENT: LEARNER_CREDIT_MANAGEMENT_EVENTS, + PROGRESS_REPORT: PROGRESS_REPORT_EVENTS, }; export default EVENT_NAMES;