From 7b5e690a76ae15c04cd61b9d02873a66837b31b3 Mon Sep 17 00:00:00 2001 From: muhammad-ammar Date: Fri, 30 Aug 2024 12:53:41 +0500 Subject: [PATCH 1/2] feat: integrate advance analytics charts --- .../AdvanceAnalyticsV2/AnalyticsV2Page.jsx | 2 + .../charts/ChartWrapper.jsx | 45 ++++++ .../AdvanceAnalyticsV2/data/constants.js | 24 ++- .../AdvanceAnalyticsV2/data/hooks.js | 36 ++--- .../AdvanceAnalyticsV2/data/hooks.test.jsx | 109 +++++++++++-- .../tabs/AnalyticsTable.jsx | 100 ++++++++++++ .../AdvanceAnalyticsV2/tabs/Completions.jsx | 135 ++++++++++++++-- .../tabs/Completions.test.jsx | 122 ++++++++++++++- .../AdvanceAnalyticsV2/tabs/Engagements.jsx | 147 ++++++++++++++++-- .../tabs/Engagements.test.jsx | 125 ++++++++++++++- .../AdvanceAnalyticsV2/tabs/Enrollments.jsx | 137 ++++++++++++++-- .../tabs/Enrollments.test.jsx | 120 +++++++++++++- .../AdvanceAnalyticsV2/tabs/Leaderboard.jsx | 100 +++--------- .../tabs/Leaderboard.test.jsx | 17 +- .../AdvanceAnalyticsV2/tabs/Skills.jsx | 146 +++++++++-------- .../AdvanceAnalyticsV2/tabs/Skills.test.jsx | 8 +- src/data/services/EnterpriseDataApiService.js | 28 ++-- .../tests/EnterpriseDataApiService.test.js | 41 +++-- src/setupTest.js | 1 + 19 files changed, 1168 insertions(+), 275 deletions(-) create mode 100644 src/components/AdvanceAnalyticsV2/charts/ChartWrapper.jsx create mode 100644 src/components/AdvanceAnalyticsV2/tabs/AnalyticsTable.jsx diff --git a/src/components/AdvanceAnalyticsV2/AnalyticsV2Page.jsx b/src/components/AdvanceAnalyticsV2/AnalyticsV2Page.jsx index 6040d706e1..7b78fc5b4e 100644 --- a/src/components/AdvanceAnalyticsV2/AnalyticsV2Page.jsx +++ b/src/components/AdvanceAnalyticsV2/AnalyticsV2Page.jsx @@ -213,6 +213,8 @@ const AnalyticsV2Page = ({ enterpriseId }) => { diff --git a/src/components/AdvanceAnalyticsV2/charts/ChartWrapper.jsx b/src/components/AdvanceAnalyticsV2/charts/ChartWrapper.jsx new file mode 100644 index 0000000000..e61a04b420 --- /dev/null +++ b/src/components/AdvanceAnalyticsV2/charts/ChartWrapper.jsx @@ -0,0 +1,45 @@ +import React from 'react'; +import PropTypes from 'prop-types'; +import ProgressOverlay from '../ProgressOverlay'; +import ScatterChart from './ScatterChart'; +import LineChart from './LineChart'; +import BarChart from './BarChart'; + +const ChartWrapper = ({ + isLoading, + isError, + chartType, + chartProps, + loadingMessage, +}) => { + if (isLoading || isError) { + return ( + + ); + } + + const renderChart = () => { + const chartMap = { + ScatterChart: , + LineChart: , + BarChart: , + }; + + return chartMap[chartType]; + }; + + return renderChart(); +}; + +ChartWrapper.propTypes = { + isLoading: PropTypes.bool.isRequired, + isError: PropTypes.bool.isRequired, + chartType: PropTypes.oneOf(['ScatterChart', 'LineChart', 'BarChart']).isRequired, + chartProps: PropTypes.object.isRequired, + loadingMessage: PropTypes.string.isRequired, +}; + +export default ChartWrapper; diff --git a/src/components/AdvanceAnalyticsV2/data/constants.js b/src/components/AdvanceAnalyticsV2/data/constants.js index 505e4f21f8..1fa630a9ca 100644 --- a/src/components/AdvanceAnalyticsV2/data/constants.js +++ b/src/components/AdvanceAnalyticsV2/data/constants.js @@ -53,7 +53,27 @@ const generateKey = (key, enterpriseUUID, requestOptions) => [ // Inspired by https://tkdodo.eu/blog/effective-react-query-keys#use-query-key-factories. export const advanceAnalyticsQueryKeys = { all: analyticsDefaultKeys, - skills: (enterpriseUUID, requestOptions) => generateKey('skills', enterpriseUUID, requestOptions), + skills: (enterpriseUUID, requestOptions) => ( + generateKey('skills', enterpriseUUID, requestOptions) + ), + completions: (enterpriseUUID, requestOptions) => ( + generateKey('completions', enterpriseUUID, requestOptions) + ), + engagements: (enterpriseUUID, requestOptions) => ( + generateKey('engagements', enterpriseUUID, requestOptions) + ), + enrollments: (enterpriseUUID, requestOptions) => ( + generateKey('enrollments', enterpriseUUID, requestOptions) + ), + enrollmentsTable: (enterpriseUUID, requestOptions) => ( + generateKey(analyticsDataTableKeys.enrollments, enterpriseUUID, requestOptions) + ), + engagementsTable: (enterpriseUUID, requestOptions) => ( + generateKey(analyticsDataTableKeys.engagements, enterpriseUUID, requestOptions) + ), + completionsTable: (enterpriseUUID, requestOptions) => ( + generateKey(analyticsDataTableKeys.completions, enterpriseUUID, requestOptions) + ), leaderboardTable: (enterpriseUUID, requestOptions) => ( generateKey(analyticsDataTableKeys.leaderboard, enterpriseUUID, requestOptions) ), @@ -75,3 +95,5 @@ export const skillsTypeColorMap = { 'Soft Skill': '#638FFF', Certification: '#FE6100', }; + +export const chartColorMap = { certificate: '#3669C9', audit: '#06262B' }; diff --git a/src/components/AdvanceAnalyticsV2/data/hooks.js b/src/components/AdvanceAnalyticsV2/data/hooks.js index 45fee4dce0..c64ef19910 100644 --- a/src/components/AdvanceAnalyticsV2/data/hooks.js +++ b/src/components/AdvanceAnalyticsV2/data/hooks.js @@ -4,34 +4,26 @@ import { useQuery } from '@tanstack/react-query'; import { advanceAnalyticsQueryKeys } from './constants'; import EnterpriseDataApiService from '../../../data/services/EnterpriseDataApiService'; -export const useEnterpriseSkillsAnalytics = (enterpriseCustomerUUID, startDate, endDate, queryOptions = {}) => { - const requestOptions = { startDate, endDate }; - return useQuery({ - queryKey: advanceAnalyticsQueryKeys.skills(enterpriseCustomerUUID, requestOptions), - queryFn: () => EnterpriseDataApiService.fetchAdminAnalyticsSkills(enterpriseCustomerUUID, requestOptions), - staleTime: 1 * (1000 * 60 * 60), // 1 hour. Length of time before your data becomes stale - cacheTime: 2 * (1000 * 60 * 60), // 2 hours. Length of time before inactive data gets removed from the cache - ...queryOptions, - }); -}; - -export const useEnterpriseAnalyticsTableData = ( +export const useEnterpriseAnalyticsData = ({ enterpriseCustomerUUID, - tableKey, + key, startDate, endDate, - currentPage, + granularity = undefined, + calculation = undefined, + currentPage = undefined, queryOptions = {}, -) => { - const requestOptions = { startDate, endDate, page: currentPage }; +}) => { + const requestOptions = { + startDate, endDate, granularity, calculation, page: currentPage, + }; return useQuery({ - queryKey: advanceAnalyticsQueryKeys[tableKey](enterpriseCustomerUUID, requestOptions), - queryFn: () => EnterpriseDataApiService.fetchAdminAnalyticsTableData( + queryKey: advanceAnalyticsQueryKeys[key](enterpriseCustomerUUID, requestOptions), + queryFn: () => EnterpriseDataApiService.fetchAdminAnalyticsData( enterpriseCustomerUUID, - tableKey, + key, requestOptions, ), - select: (respnose) => respnose.data, staleTime: 0.5 * (1000 * 60 * 60), // 30 minutes. Length of time before your data becomes stale cacheTime: 0.75 * (1000 * 60 * 60), // 45 minutes. Length of time before inactive data gets removed from the cache keepPreviousData: true, @@ -43,9 +35,9 @@ export const usePaginatedData = (data) => useMemo(() => { if (data) { return { data: data.results, - pageCount: data.num_pages, + pageCount: data.numPages, itemCount: data.count, - currentPage: data.current_page, + currentPage: data.currentPage, }; } diff --git a/src/components/AdvanceAnalyticsV2/data/hooks.test.jsx b/src/components/AdvanceAnalyticsV2/data/hooks.test.jsx index c64bb2ea81..9d4514c618 100644 --- a/src/components/AdvanceAnalyticsV2/data/hooks.test.jsx +++ b/src/components/AdvanceAnalyticsV2/data/hooks.test.jsx @@ -4,8 +4,8 @@ import MockAdapter from 'axios-mock-adapter'; import { renderHook } from '@testing-library/react-hooks'; import { QueryClientProvider } from '@tanstack/react-query'; import { getAuthenticatedHttpClient } from '@edx/frontend-platform/auth'; -import { camelCaseObject } from '@edx/frontend-platform/utils'; -import { useEnterpriseSkillsAnalytics } from './hooks'; +import { snakeCaseObject, camelCaseObject } from '@edx/frontend-platform/utils'; +import { useEnterpriseAnalyticsData } from './hooks'; import EnterpriseDataApiService from '../../../data/services/EnterpriseDataApiService'; import { queryClient } from '../../test/testUtils'; @@ -13,35 +13,103 @@ jest.mock('@edx/frontend-platform/logging', () => ({ logError: jest.fn(), })); -jest.spyOn(EnterpriseDataApiService, 'fetchAdminAnalyticsSkills'); +jest.spyOn(EnterpriseDataApiService, 'fetchAdminAnalyticsData'); const axiosMock = new MockAdapter(axios); getAuthenticatedHttpClient.mockReturnValue(axios); -const mockAnalyticsSkillsData = { - top_skills: [], - top_skills_by_enrollments: [], - top_skills_by_completions: [], +const mockAnalyticsCompletionsChartsData = { + completions_over_time: [], + top_courses_by_completions: [], + top_subjects_by_completions: [], }; -axiosMock.onAny().reply(200); -axios.get = jest.fn(() => Promise.resolve({ data: mockAnalyticsSkillsData })); +const mockAnalyticsLeaderboardTableData = [ + { + email: 'user@example.com', + dailySessions: 243, + learningTimeSeconds: 1111, + learningTimeHours: 3.4, + averageSessionLength: 1.6, + courseCompletions: 4, + }, +]; const TEST_ENTERPRISE_ID = '33ce6562-95e0-4ecf-a2a7-7d407eb96f69'; -describe('useEnterpriseSkillsAnalytics', () => { +describe('useEnterpriseAnalyticsData', () => { + afterEach(() => { + axiosMock.reset(); + }); + const wrapper = ({ children }) => ( {children} ); - it('fetch skills analytics data', async () => { + it('fetch analytics chart data', async () => { + const startDate = '2021-01-01'; + const endDate = '2021-12-31'; + const requestOptions = { startDate, endDate }; + const queryParams = new URLSearchParams(snakeCaseObject(requestOptions)); + const baseURL = `${EnterpriseDataApiService.enterpriseAdminAnalyticsV2BaseUrl}${TEST_ENTERPRISE_ID}`; + const analyticsCompletionsURL = `${baseURL}/completions/stats?${queryParams.toString()}`; + axiosMock.onGet(`${analyticsCompletionsURL}`).reply(200, mockAnalyticsCompletionsChartsData); + const { result, waitForNextUpdate } = renderHook( + () => useEnterpriseAnalyticsData({ + enterpriseCustomerUUID: TEST_ENTERPRISE_ID, + key: 'completions', + startDate, + endDate, + }), + { wrapper }, + ); + + expect(result.current).toEqual( + expect.objectContaining({ + isLoading: true, + error: null, + data: undefined, + }), + ); + + await waitForNextUpdate(); + + expect(EnterpriseDataApiService.fetchAdminAnalyticsData).toHaveBeenCalled(); + expect(EnterpriseDataApiService.fetchAdminAnalyticsData).toHaveBeenCalledWith( + TEST_ENTERPRISE_ID, + 'completions', + { + calculation: undefined, + endDate: '2021-12-31', + granularity: undefined, + page: undefined, + startDate: '2021-01-01', + }, + ); + expect(result.current).toEqual(expect.objectContaining({ + isLoading: false, + error: null, + data: camelCaseObject(mockAnalyticsCompletionsChartsData), + })); + expect(axiosMock.history.get[0].url).toBe(analyticsCompletionsURL); + }); + it('fetch analytics table data', async () => { const startDate = '2021-01-01'; const endDate = '2021-12-31'; const requestOptions = { startDate, endDate }; + const queryParams = new URLSearchParams(snakeCaseObject(requestOptions)); + const baseURL = `${EnterpriseDataApiService.enterpriseAdminAnalyticsV2BaseUrl}${TEST_ENTERPRISE_ID}`; + const analyticsLeaderboardURL = `${baseURL}/leaderboard?${queryParams.toString()}`; + axiosMock.onGet(`${analyticsLeaderboardURL}`).reply(200, mockAnalyticsLeaderboardTableData); const { result, waitForNextUpdate } = renderHook( - () => useEnterpriseSkillsAnalytics(TEST_ENTERPRISE_ID, startDate, endDate), + () => useEnterpriseAnalyticsData({ + enterpriseCustomerUUID: TEST_ENTERPRISE_ID, + key: 'leaderboardTable', + startDate, + endDate, + }), { wrapper }, ); @@ -55,12 +123,23 @@ describe('useEnterpriseSkillsAnalytics', () => { await waitForNextUpdate(); - expect(EnterpriseDataApiService.fetchAdminAnalyticsSkills).toHaveBeenCalled(); - expect(EnterpriseDataApiService.fetchAdminAnalyticsSkills).toHaveBeenCalledWith(TEST_ENTERPRISE_ID, requestOptions); + expect(EnterpriseDataApiService.fetchAdminAnalyticsData).toHaveBeenCalled(); + expect(EnterpriseDataApiService.fetchAdminAnalyticsData).toHaveBeenCalledWith( + TEST_ENTERPRISE_ID, + 'completions', + { + calculation: undefined, + endDate: '2021-12-31', + granularity: undefined, + page: undefined, + startDate: '2021-01-01', + }, + ); expect(result.current).toEqual(expect.objectContaining({ isLoading: false, error: null, - data: camelCaseObject(mockAnalyticsSkillsData), + data: camelCaseObject(mockAnalyticsLeaderboardTableData), })); + expect(axiosMock.history.get[0].url).toBe(analyticsLeaderboardURL); }); }); diff --git a/src/components/AdvanceAnalyticsV2/tabs/AnalyticsTable.jsx b/src/components/AdvanceAnalyticsV2/tabs/AnalyticsTable.jsx new file mode 100644 index 0000000000..cda64dc68d --- /dev/null +++ b/src/components/AdvanceAnalyticsV2/tabs/AnalyticsTable.jsx @@ -0,0 +1,100 @@ +import React, { useState, useCallback } from 'react'; +import { useIntl } from '@edx/frontend-platform/i18n'; +import PropTypes from 'prop-types'; +import { DataTable, TablePaginationMinimal } from '@openedx/paragon'; +import Header from '../Header'; +import { analyticsDataTableKeys } from '../data/constants'; + +import { useEnterpriseAnalyticsData, usePaginatedData } from '../data/hooks'; + +const AnalyticsTable = ({ + name, + tableColumns, + tableTitle, + tableSubtitle, + enableCSVDownload, + startDate, + endDate, + enterpriseId, +}) => { + const intl = useIntl(); + const [currentPage, setCurrentPage] = useState(0); + + const { + isLoading, data, isPreviousData, + } = useEnterpriseAnalyticsData({ + enterpriseCustomerUUID: enterpriseId, + key: analyticsDataTableKeys[name], + startDate, + endDate, + // pages index from 1 in backend, frontend components index from 0 + currentPage: currentPage + 1, + }); + + const fetchData = useCallback( + (args) => { + if (args.pageIndex !== currentPage) { + setCurrentPage(args.pageIndex); + } + }, + [currentPage], + ); + + const paginatedData = usePaginatedData(data); + + return ( +
+
+
+ + + + + + + + +
+
+ ); +}; + +AnalyticsTable.propTypes = { + name: PropTypes.string.isRequired, + tableColumns: PropTypes.arrayOf(PropTypes.shape({})).isRequired, + tableTitle: PropTypes.string.isRequired, + tableSubtitle: PropTypes.string.isRequired, + enableCSVDownload: PropTypes.bool.isRequired, + enterpriseId: PropTypes.string.isRequired, + startDate: PropTypes.string.isRequired, + endDate: PropTypes.string.isRequired, +}; + +export default AnalyticsTable; diff --git a/src/components/AdvanceAnalyticsV2/tabs/Completions.jsx b/src/components/AdvanceAnalyticsV2/tabs/Completions.jsx index 4d68d74a58..bfbb52ad8c 100644 --- a/src/components/AdvanceAnalyticsV2/tabs/Completions.jsx +++ b/src/components/AdvanceAnalyticsV2/tabs/Completions.jsx @@ -1,15 +1,28 @@ import React from 'react'; import { useIntl } from '@edx/frontend-platform/i18n'; import PropTypes from 'prop-types'; -import EmptyChart from '../charts/EmptyChart'; import Header from '../Header'; -import { ANALYTICS_TABS, CHART_TYPES } from '../data/constants'; +import { ANALYTICS_TABS, CHART_TYPES, chartColorMap } from '../data/constants'; +import AnalyticsTable from './AnalyticsTable'; +import ChartWrapper from '../charts/ChartWrapper'; +import { useEnterpriseAnalyticsData } from '../data/hooks'; const Completions = ({ startDate, endDate, granularity, calculation, enterpriseId, }) => { const intl = useIntl(); + const { + isLoading, isError, data, + } = useEnterpriseAnalyticsData({ + enterpriseCustomerUUID: enterpriseId, + key: ANALYTICS_TABS.COMPLETIONS, + startDate, + endDate, + granularity, + calculation, + }); + return (
@@ -33,7 +46,26 @@ const Completions = ({ enterpriseId={enterpriseId} isDownloadCSV /> - + Number of Completions: %{y}', + }} + loadingMessage={intl.formatMessage({ + id: 'advance.analytics.completions.tab.chart.top.courses.by.completions.loading.message', + defaultMessage: 'Loading top courses by completions chart data', + description: 'Loading message for the top courses by completions chart.', + })} + />
- + Number of Completions: %{y}', + }} + loadingMessage={intl.formatMessage({ + id: 'advance.analytics.completions.tab.chart.top.10.courses.by.completions.loading.message', + defaultMessage: 'Loading top 10 courses by completions chart data', + description: 'Loading message for the top 10 courses by completions chart.', + })} + />
- + Number of Completions: %{y}', + }} + loadingMessage={intl.formatMessage({ + id: 'advance.analytics.completions.tab.chart.top.subjects.by.completions.loading.message', + defaultMessage: 'Loading top 10 subjects by completions chart data', + description: 'Loading message for the top 10 subjects by completions chart.', + })} + />
-
-
); diff --git a/src/components/AdvanceAnalyticsV2/tabs/Completions.test.jsx b/src/components/AdvanceAnalyticsV2/tabs/Completions.test.jsx index 4d36fe40e4..55ee9698dc 100644 --- a/src/components/AdvanceAnalyticsV2/tabs/Completions.test.jsx +++ b/src/components/AdvanceAnalyticsV2/tabs/Completions.test.jsx @@ -1,14 +1,68 @@ -import { render } from '@testing-library/react'; +import { + render, screen, waitFor, within, +} from '@testing-library/react'; +import { QueryClientProvider } from '@tanstack/react-query'; import { IntlProvider } from '@edx/frontend-platform/i18n'; -import Completions from './Completions'; import '@testing-library/jest-dom'; +import MockAdapter from 'axios-mock-adapter'; +import axios from 'axios'; +import { getAuthenticatedHttpClient } from '@edx/frontend-platform/auth'; +import Completions from './Completions'; +import { queryClient } from '../../test/testUtils'; +import EnterpriseDataApiService from '../../../data/services/EnterpriseDataApiService'; + +const mockAnalyticsData = { + next: null, + previous: null, + count: 2, + num_pages: 1, + current_page: 1, + results: [ + { + email: 'user100@example.com', + course_title: 'Course 1', + course_subject: 'Subject 1', + passed_date: '2021-01-01', + }, + { + email: 'user200@example.com', + course_title: 'Course 2', + course_subject: 'Subject 2', + passed_date: '2022-01-01', + }, + ], +}; + +jest.spyOn(EnterpriseDataApiService, 'fetchAdminAnalyticsData'); +const axiosMock = new MockAdapter(axios); +getAuthenticatedHttpClient.mockReturnValue(axios); +axiosMock.onAny().reply(200); +axios.get = jest.fn(() => Promise.resolve({ data: mockAnalyticsData })); + +jest.mock('../charts/LineChart', () => { + const MockedLineChart = () =>
Mocked LineChart
; + return MockedLineChart; +}); + +jest.mock('../charts/BarChart', () => { + const MockedBarChart = () =>
Mocked BarChart
; + return MockedBarChart; +}); describe('Completions Component', () => { - test('renders all sections with correct classes and content', () => { + test('renders all charts correctly', async () => { const { container } = render( - - - , + + + + , + , ); const sections = [ @@ -39,5 +93,61 @@ describe('Completions Component', () => { expect(section).toHaveTextContent(title); expect(section).toHaveTextContent(subtitle); }); + + await waitFor(() => { + expect(EnterpriseDataApiService.fetchAdminAnalyticsData).toHaveBeenCalled(); + + expect(screen.getByText('Mocked LineChart')).toBeInTheDocument(); + const elements = screen.getAllByText('Mocked BarChart'); + expect(elements).toHaveLength(2); + + // ensure the correct number of rows are rendered (including header row) + const rows = screen.getAllByRole('row'); + expect(rows).toHaveLength(mockAnalyticsData.count + 1); // +1 for header row + + // validate header row + const columnHeaders = within(rows[0]).getAllByRole('columnheader'); + ['Email', 'Course Title', 'Course Subject', 'Passed Date'].forEach((column, index) => { + expect(columnHeaders[index].textContent).toEqual(column); + }); + + // validate content of each data row + mockAnalyticsData.results.forEach((user, index) => { + const rowCells = within(rows[index + 1]).getAllByRole('cell'); // Skip header row + expect(rowCells[0]).toHaveTextContent(user.email); + expect(rowCells[1]).toHaveTextContent(user.course_title); + expect(rowCells[2]).toHaveTextContent(user.course_subject); + expect(rowCells[3]).toHaveTextContent(user.passed_date); + }); + }); + }); + test('renders charts with correct loading messages', () => { + jest.mock('../data/hooks', () => ({ + useEnterpriseAnalyticsTableData: jest.fn().mockReturnValue({ + isLoading: true, + data: null, + isError: false, + isFetching: false, + error: null, + }), + })); + + render( + + + + , + , + ); + + expect(screen.getByText('Loading top courses by completions chart data')).toBeInTheDocument(); + expect(screen.getByText('Loading top 10 courses by completions chart data')).toBeInTheDocument(); + expect(screen.getByText('Loading top 10 subjects by completions chart data')).toBeInTheDocument(); }); }); diff --git a/src/components/AdvanceAnalyticsV2/tabs/Engagements.jsx b/src/components/AdvanceAnalyticsV2/tabs/Engagements.jsx index 13988f06c4..fcf78fd535 100644 --- a/src/components/AdvanceAnalyticsV2/tabs/Engagements.jsx +++ b/src/components/AdvanceAnalyticsV2/tabs/Engagements.jsx @@ -1,12 +1,26 @@ import React from 'react'; import { useIntl } from '@edx/frontend-platform/i18n'; import PropTypes from 'prop-types'; -import EmptyChart from '../charts/EmptyChart'; import Header from '../Header'; -import { ANALYTICS_TABS, CHART_TYPES } from '../data/constants'; +import { ANALYTICS_TABS, CHART_TYPES, chartColorMap } from '../data/constants'; +import AnalyticsTable from './AnalyticsTable'; +import ChartWrapper from '../charts/ChartWrapper'; +import { useEnterpriseAnalyticsData } from '../data/hooks'; -const Engagements = ({ startDate, endDate, enterpriseId }) => { +const Engagements = ({ + startDate, endDate, granularity, calculation, enterpriseId, +}) => { const intl = useIntl(); + const { + isLoading, isError, data, + } = useEnterpriseAnalyticsData({ + enterpriseCustomerUUID: enterpriseId, + key: ANALYTICS_TABS.ENGAGEMENTS, + startDate, + endDate, + granularity, + calculation, + }); return (
@@ -29,7 +43,26 @@ const Engagements = ({ startDate, endDate, enterpriseId }) => { enterpriseId={enterpriseId} isDownloadCSV /> - + Learning Hours: %{y}', + }} + loadingMessage={intl.formatMessage({ + id: 'advance.analytics.engagements.tab.chart.learning.hours.over.time.loading.message', + defaultMessage: 'Loading learning hours over time chart data', + description: 'Loading message for the learning hours over time chart.', + })} + />
{ enterpriseId={enterpriseId} isDownloadCSV /> - + Learning Hours: %{y}', + }} + loadingMessage={intl.formatMessage({ + id: 'advance.analytics.engagements.tab.chart.top.10.courses.by.learning.hours.loading.message', + defaultMessage: 'Loading top 10 courses by learning hours chart data', + description: 'Loading message for the top 10 courses by learning hours chart.', + })} + />
{ enterpriseId={enterpriseId} isDownloadCSV /> - + Learning Hours: %{y}', + }} + loadingMessage={intl.formatMessage({ + id: 'advance.analytics.engagements.tab.chart.top.10.subjects.by.learning.hours.loading.message', + defaultMessage: 'Loading top 10 subjects by learning hours chart data', + description: 'Loading message for the top 10 subjects by learning hours chart.', + })} + />
-
-
); @@ -101,5 +222,7 @@ Engagements.propTypes = { startDate: PropTypes.string.isRequired, endDate: PropTypes.string.isRequired, enterpriseId: PropTypes.string.isRequired, + granularity: PropTypes.string.isRequired, + calculation: PropTypes.string.isRequired, }; export default Engagements; diff --git a/src/components/AdvanceAnalyticsV2/tabs/Engagements.test.jsx b/src/components/AdvanceAnalyticsV2/tabs/Engagements.test.jsx index 5e335151ce..66ebd656a2 100644 --- a/src/components/AdvanceAnalyticsV2/tabs/Engagements.test.jsx +++ b/src/components/AdvanceAnalyticsV2/tabs/Engagements.test.jsx @@ -1,14 +1,70 @@ -import { render } from '@testing-library/react'; +import { + render, screen, waitFor, within, +} from '@testing-library/react'; +import { QueryClientProvider } from '@tanstack/react-query'; import { IntlProvider } from '@edx/frontend-platform/i18n'; -import Engagements from './Engagements'; import '@testing-library/jest-dom'; +import MockAdapter from 'axios-mock-adapter'; +import axios from 'axios'; +import { getAuthenticatedHttpClient } from '@edx/frontend-platform/auth'; +import Engagements from './Engagements'; +import { queryClient } from '../../test/testUtils'; +import EnterpriseDataApiService from '../../../data/services/EnterpriseDataApiService'; + +const mockAnalyticsData = { + next: null, + previous: null, + count: 2, + num_pages: 1, + current_page: 1, + results: [ + { + email: 'user100@example.com', + course_title: 'Course 1', + activity_date: '2020-01-01', + course_subject: 'Subject 1', + learning_time_hours: 12, + }, + { + email: 'user200@example.com', + course_title: 'Course 2', + activity_date: '20219-01-01', + course_subject: 'Subject 2', + learning_time_hours: 22, + }, + ], +}; + +jest.spyOn(EnterpriseDataApiService, 'fetchAdminAnalyticsData'); +const axiosMock = new MockAdapter(axios); +getAuthenticatedHttpClient.mockReturnValue(axios); +axiosMock.onAny().reply(200); +axios.get = jest.fn(() => Promise.resolve({ data: mockAnalyticsData })); + +jest.mock('../charts/LineChart', () => { + const MockedLineChart = () =>
Mocked LineChart
; + return MockedLineChart; +}); + +jest.mock('../charts/BarChart', () => { + const MockedBarChart = () =>
Mocked BarChart
; + return MockedBarChart; +}); describe('Engagements Component', () => { - test('renders all sections with correct classes and content', () => { + test('renders all sections with correct classes and content', async () => { const { container } = render( - - - , + + + + , + , ); const sections = [ @@ -39,5 +95,62 @@ describe('Engagements Component', () => { expect(section).toHaveTextContent(title); expect(section).toHaveTextContent(subtitle); }); + + await waitFor(() => { + expect(EnterpriseDataApiService.fetchAdminAnalyticsData).toHaveBeenCalled(); + + expect(screen.getByText('Mocked LineChart')).toBeInTheDocument(); + const elements = screen.getAllByText('Mocked BarChart'); + expect(elements).toHaveLength(2); + + // ensure the correct number of rows are rendered (including header row) + const rows = screen.getAllByRole('row'); + expect(rows).toHaveLength(mockAnalyticsData.count + 1); // +1 for header row + + // validate header row + const columnHeaders = within(rows[0]).getAllByRole('columnheader'); + ['Email', 'Course Title', 'Activity Date', 'Course Subject', 'Learning Hours'].forEach((column, index) => { + expect(columnHeaders[index].textContent).toEqual(column); + }); + + // validate content of each data row + mockAnalyticsData.results.forEach((user, index) => { + const rowCells = within(rows[index + 1]).getAllByRole('cell'); // Skip header row + expect(rowCells[0]).toHaveTextContent(user.email); + expect(rowCells[1]).toHaveTextContent(user.course_title); + expect(rowCells[2]).toHaveTextContent(user.activity_date); + expect(rowCells[3]).toHaveTextContent(user.course_subject); + expect(rowCells[4]).toHaveTextContent(user.learning_time_hours); + }); + }); + }); + test('renders charts with correct loading messages', () => { + jest.mock('../data/hooks', () => ({ + useEnterpriseAnalyticsTableData: jest.fn().mockReturnValue({ + isLoading: true, + data: null, + isError: false, + isFetching: false, + error: null, + }), + })); + + render( + + + + , + , + ); + + expect(screen.getByText('Loading learning hours over time chart data')).toBeInTheDocument(); + expect(screen.getByText('Loading top 10 courses by learning hours chart data')).toBeInTheDocument(); + expect(screen.getByText('Loading top 10 subjects by learning hours chart data')).toBeInTheDocument(); }); }); diff --git a/src/components/AdvanceAnalyticsV2/tabs/Enrollments.jsx b/src/components/AdvanceAnalyticsV2/tabs/Enrollments.jsx index 36f41b0319..7464eba1a4 100644 --- a/src/components/AdvanceAnalyticsV2/tabs/Enrollments.jsx +++ b/src/components/AdvanceAnalyticsV2/tabs/Enrollments.jsx @@ -1,14 +1,26 @@ import React from 'react'; import { useIntl } from '@edx/frontend-platform/i18n'; import PropTypes from 'prop-types'; -import EmptyChart from '../charts/EmptyChart'; import Header from '../Header'; -import { ANALYTICS_TABS, CHART_TYPES } from '../data/constants'; +import { ANALYTICS_TABS, CHART_TYPES, chartColorMap } from '../data/constants'; +import AnalyticsTable from './AnalyticsTable'; +import ChartWrapper from '../charts/ChartWrapper'; +import { useEnterpriseAnalyticsData } from '../data/hooks'; const Enrollments = ({ startDate, endDate, granularity, calculation, enterpriseId, }) => { const intl = useIntl(); + const { + isLoading, isError, data, + } = useEnterpriseAnalyticsData({ + enterpriseCustomerUUID: enterpriseId, + key: ANALYTICS_TABS.ENROLLMENTS, + startDate, + endDate, + granularity, + calculation, + }); return (
@@ -33,7 +45,26 @@ const Enrollments = ({ enterpriseId={enterpriseId} isDownloadCSV /> - + Enrolls: %{y}', + }} + loadingMessage={intl.formatMessage({ + id: 'advance.analytics.enrollments.tab.chart.enrollments.over.time.loading.message', + defaultMessage: 'Loading enrollments over time chart data', + description: 'Loading message for the enrollments over time chart.', + })} + />
- + Enrolls: %{y}', + }} + loadingMessage={intl.formatMessage({ + id: 'advance.analytics.enrollments.tab.chart.top.courses.by.enrollments.loading.message', + defaultMessage: 'Loading top courses by enrollments chart data', + description: 'Loading message for the top courses by enrollments chart.', + })} + />
- + Enrolls: %{y}', + }} + loadingMessage={intl.formatMessage({ + id: 'advance.analytics.enrollments.tab.chart.top.subjects.by.enrollments.loading.message', + defaultMessage: 'Loading top subjects by enrollments chart data', + description: 'Loading message for the top subjects by enrollments chart.', + })} + />
-
-
); diff --git a/src/components/AdvanceAnalyticsV2/tabs/Enrollments.test.jsx b/src/components/AdvanceAnalyticsV2/tabs/Enrollments.test.jsx index 87136be9d0..08dbfb9733 100644 --- a/src/components/AdvanceAnalyticsV2/tabs/Enrollments.test.jsx +++ b/src/components/AdvanceAnalyticsV2/tabs/Enrollments.test.jsx @@ -1,14 +1,64 @@ -import { render } from '@testing-library/react'; +import { + render, screen, waitFor, within, +} from '@testing-library/react'; +import { QueryClientProvider } from '@tanstack/react-query'; import { IntlProvider } from '@edx/frontend-platform/i18n'; -import Enrollments from './Enrollments'; import '@testing-library/jest-dom'; +import MockAdapter from 'axios-mock-adapter'; +import axios from 'axios'; +import { getAuthenticatedHttpClient } from '@edx/frontend-platform/auth'; +import Enrollments from './Enrollments'; +import { queryClient } from '../../test/testUtils'; +import EnterpriseDataApiService from '../../../data/services/EnterpriseDataApiService'; + +const mockAnalyticsData = { + next: null, + previous: null, + count: 2, + num_pages: 1, + current_page: 1, + results: [ + { + email: 'user100@example.com', + course_title: 'Course 1', + course_subject: 'Subject 1', + enroll_type: 'certificate', + enterprise_enrollment_date: '2020-01-01', + }, + { + email: 'user200@example.com', + course_title: 'Course 2', + course_subject: 'Subject 2', + enroll_type: 'certificate 2', + enterprise_enrollment_date: '2021-01-01', + }, + ], +}; + +jest.spyOn(EnterpriseDataApiService, 'fetchAdminAnalyticsData'); +const axiosMock = new MockAdapter(axios); +getAuthenticatedHttpClient.mockReturnValue(axios); +axiosMock.onAny().reply(200); +axios.get = jest.fn(() => Promise.resolve({ data: mockAnalyticsData })); + +jest.mock('../charts/LineChart', () => { + const MockedLineChart = () =>
Mocked LineChart
; + return MockedLineChart; +}); + +jest.mock('../charts/BarChart', () => { + const MockedBarChart = () =>
Mocked BarChart
; + return MockedBarChart; +}); describe('Enrollments Component', () => { - test('renders all sections with correct classes and content', () => { + test('renders all sections with correct classes and content', async () => { const { container } = render( - - - , + + + + , + , ); const sections = [ @@ -39,5 +89,63 @@ describe('Enrollments Component', () => { expect(section).toHaveTextContent(title); expect(section).toHaveTextContent(subtitle); }); + + await waitFor(() => { + expect(EnterpriseDataApiService.fetchAdminAnalyticsData).toHaveBeenCalled(); + + expect(screen.getByText('Mocked LineChart')).toBeInTheDocument(); + const elements = screen.getAllByText('Mocked BarChart'); + expect(elements).toHaveLength(2); + + // ensure the correct number of rows are rendered (including header row) + const rows = screen.getAllByRole('row'); + expect(rows).toHaveLength(mockAnalyticsData.count + 1); // +1 for header row + + // validate header row + const columns = ['Email', 'Course Title', 'Course Subject', 'Enroll Type', 'Enterprise Enrollment Date']; + const columnHeaders = within(rows[0]).getAllByRole('columnheader'); + columns.forEach((column, index) => { + expect(columnHeaders[index].textContent).toEqual(column); + }); + + // validate content of each data row + mockAnalyticsData.results.forEach((user, index) => { + const rowCells = within(rows[index + 1]).getAllByRole('cell'); // Skip header row + expect(rowCells[0]).toHaveTextContent(user.email); + expect(rowCells[1]).toHaveTextContent(user.course_title); + expect(rowCells[2]).toHaveTextContent(user.course_subject); + expect(rowCells[3]).toHaveTextContent(user.enroll_type); + expect(rowCells[4]).toHaveTextContent(user.enterprise_enrollment_date); + }); + }); + }); + test('renders charts with correct loading messages', () => { + jest.mock('../data/hooks', () => ({ + useEnterpriseAnalyticsTableData: jest.fn().mockReturnValue({ + isLoading: true, + data: null, + isError: false, + isFetching: false, + error: null, + }), + })); + + render( + + + + , + , + ); + + expect(screen.getByText('Loading enrollments over time chart data')).toBeInTheDocument(); + expect(screen.getByText('Loading top courses by enrollments chart data')).toBeInTheDocument(); + expect(screen.getByText('Loading top subjects by enrollments chart data')).toBeInTheDocument(); }); }); diff --git a/src/components/AdvanceAnalyticsV2/tabs/Leaderboard.jsx b/src/components/AdvanceAnalyticsV2/tabs/Leaderboard.jsx index d350b6e4c1..31825aab75 100644 --- a/src/components/AdvanceAnalyticsV2/tabs/Leaderboard.jsx +++ b/src/components/AdvanceAnalyticsV2/tabs/Leaderboard.jsx @@ -1,128 +1,76 @@ -import React, { useState, useCallback } from 'react'; +import React from 'react'; import { useIntl } from '@edx/frontend-platform/i18n'; import PropTypes from 'prop-types'; -import { DataTable, TablePaginationMinimal } from '@openedx/paragon'; -import Header from '../Header'; -import { ANALYTICS_TABS, analyticsDataTableKeys } from '../data/constants'; - -import { useEnterpriseAnalyticsTableData, usePaginatedData } from '../data/hooks'; +import { ANALYTICS_TABS } from '../data/constants'; +import AnalyticsTable from './AnalyticsTable'; const Leaderboard = ({ startDate, endDate, enterpriseId }) => { const intl = useIntl(); - const [currentPage, setCurrentPage] = useState(0); - - const { - isLoading, data, isPreviousData, - } = useEnterpriseAnalyticsTableData( - enterpriseId, - analyticsDataTableKeys.leaderboard, - startDate, - endDate, - // pages index from 1 in backend, frontend components index from 0 - currentPage + 1, - ); - - const fetchData = useCallback( - (args) => { - if (args.pageIndex !== currentPage) { - setCurrentPage(args.pageIndex); - } - }, - [currentPage], - ); - - const paginatedData = usePaginatedData(data); return (
-
- - - - - - - - + />
); diff --git a/src/components/AdvanceAnalyticsV2/tabs/Leaderboard.test.jsx b/src/components/AdvanceAnalyticsV2/tabs/Leaderboard.test.jsx index 6e75e455ad..0f6ce03d14 100644 --- a/src/components/AdvanceAnalyticsV2/tabs/Leaderboard.test.jsx +++ b/src/components/AdvanceAnalyticsV2/tabs/Leaderboard.test.jsx @@ -11,7 +11,7 @@ import Leaderboard from './Leaderboard'; import { queryClient } from '../../test/testUtils'; import EnterpriseDataApiService from '../../../data/services/EnterpriseDataApiService'; -jest.spyOn(EnterpriseDataApiService, 'fetchAdminAnalyticsTableData'); +jest.spyOn(EnterpriseDataApiService, 'fetchAdminAnalyticsData'); const axiosMock = new MockAdapter(axios); getAuthenticatedHttpClient.mockReturnValue(axios); @@ -103,12 +103,25 @@ describe('Leaderboard Component', () => { expect(headers[4]).toHaveTextContent('Course Completions'); await waitFor(() => { - expect(EnterpriseDataApiService.fetchAdminAnalyticsTableData).toHaveBeenCalled(); + expect(EnterpriseDataApiService.fetchAdminAnalyticsData).toHaveBeenCalled(); // ensure the correct number of rows are rendered (including header row) const rows = screen.getAllByRole('row'); expect(rows).toHaveLength(3 + 1); // +1 for header row + // validate header row + const columns = [ + 'Email', + 'Learning Hours', + 'Daily Sessions', + 'Average Session Length (Hours)', + 'Course Completions', + ]; + const columnHeaders = within(rows[0]).getAllByRole('columnheader'); + columns.forEach((column, index) => { + expect(columnHeaders[index].textContent).toEqual(column); + }); + // validate content of each data row mockLeaderboardData.results.forEach((user, index) => { const rowCells = within(rows[index + 1]).getAllByRole('cell'); // Skip header row diff --git a/src/components/AdvanceAnalyticsV2/tabs/Skills.jsx b/src/components/AdvanceAnalyticsV2/tabs/Skills.jsx index 65733fd4e8..68f3289a79 100644 --- a/src/components/AdvanceAnalyticsV2/tabs/Skills.jsx +++ b/src/components/AdvanceAnalyticsV2/tabs/Skills.jsx @@ -2,24 +2,23 @@ import React from 'react'; import { useIntl } from '@edx/frontend-platform/i18n'; import PropTypes from 'prop-types'; import Header from '../Header'; -import BarChart from '../charts/BarChart'; import { ANALYTICS_TABS, CHART_TYPES, skillsColorMap, skillsTypeColorMap, } from '../data/constants'; -import ScatterChart from '../charts/ScatterChart'; -import ProgressOverlay from '../ProgressOverlay'; -import { useEnterpriseSkillsAnalytics } from '../data/hooks'; +import { useEnterpriseAnalyticsData } from '../data/hooks'; +import ChartWrapper from '../charts/ChartWrapper'; const Skills = ({ startDate, endDate, enterpriseId }) => { const intl = useIntl(); const { isLoading, isError, data, - } = useEnterpriseSkillsAnalytics( - enterpriseId, + } = useEnterpriseAnalyticsData({ + enterpriseCustomerUUID: enterpriseId, + key: ANALYTICS_TABS.SKILLS, startDate, endDate, - ); + }); return (
@@ -42,37 +41,37 @@ const Skills = ({ startDate, endDate, enterpriseId }) => { enterpriseId={enterpriseId} isDownloadCSV /> - {(isLoading || isError) ? ( - - ) : ( - - )} + }), + markerSizeKey: 'completions', + customDataKeys: ['skillName', 'skillType'], + hovertemplate: 'Skill: %{customdata[0]}
Enrolls: %{x}
Completions: %{y}', + }} + loadingMessage={intl.formatMessage({ + id: 'advance.analytics.skills.tab.chart.top.skills.loading.message', + defaultMessage: 'Loading top skills chart data', + description: 'Loading message for the top skills chart.', + })} + />
@@ -84,30 +83,29 @@ const Skills = ({ startDate, endDate, enterpriseId }) => { description: 'Title for the top skills by enrollment chart.', })} /> - {(isLoading || isError) ? ( - - ) : ( - - )} + }), + hovertemplate: 'Skill: %{x}
Enrolls: %{y}', + }} + loadingMessage={intl.formatMessage({ + id: 'advance.analytics.skills.tab.chart.top.skills.by.enrollment.loading.message', + defaultMessage: 'Loading top skills by enrollment chart data', + description: 'Loading message for the top skills by enrollment chart.', + })} + />
@@ -119,31 +117,29 @@ const Skills = ({ startDate, endDate, enterpriseId }) => { description: 'Title for the top skills by completion chart.', })} /> - {(isLoading || isError) ? ( - - ) : ( - - )} + }), + hovertemplate: 'Skill: %{x}
Completions: %{y}', + }} + loadingMessage={intl.formatMessage({ + id: 'advance.analytics.skills.tab.chart.top.skills.by.completion.loading.message', + defaultMessage: 'Loading top skills by completions chart data', + description: 'Loading message for the top skills by completions chart.', + })} + />
diff --git a/src/components/AdvanceAnalyticsV2/tabs/Skills.test.jsx b/src/components/AdvanceAnalyticsV2/tabs/Skills.test.jsx index 97372f5be5..557db1ffe4 100644 --- a/src/components/AdvanceAnalyticsV2/tabs/Skills.test.jsx +++ b/src/components/AdvanceAnalyticsV2/tabs/Skills.test.jsx @@ -24,13 +24,13 @@ jest.mock('../charts/BarChart', () => { }); jest.mock('../../../data/services/EnterpriseDataApiService', () => ({ - fetchAdminAnalyticsSkills: jest.fn(), + fetchAdminAnalyticsData: jest.fn(), })); describe('Skills Tab', () => { describe('renders static text', () => { test('renders all sections with correct classes and content', () => { - hooks.useEnterpriseSkillsAnalytics.mockReturnValue({ + hooks.useEnterpriseAnalyticsData.mockReturnValue({ isLoading: true, data: null, isError: false, @@ -78,7 +78,7 @@ describe('Skills Tab', () => { describe('when loading data from API', () => { test('renders correct messages', () => { - hooks.useEnterpriseSkillsAnalytics.mockReturnValue({ + hooks.useEnterpriseAnalyticsData.mockReturnValue({ isLoading: true, data: null, isError: false, @@ -106,7 +106,7 @@ describe('Skills Tab', () => { describe('when data successfully loaded from API', () => { test('renders charts', () => { - hooks.useEnterpriseSkillsAnalytics.mockReturnValue({ + hooks.useEnterpriseAnalyticsData.mockReturnValue({ isLoading: false, data: mockAnalyticsSkillsData, isError: false, diff --git a/src/data/services/EnterpriseDataApiService.js b/src/data/services/EnterpriseDataApiService.js index 2fe3447f3e..a64a26ae7a 100644 --- a/src/data/services/EnterpriseDataApiService.js +++ b/src/data/services/EnterpriseDataApiService.js @@ -1,7 +1,6 @@ import { getAuthenticatedHttpClient } from '@edx/frontend-platform/auth'; import { snakeCaseObject, camelCaseObject } from '@edx/frontend-platform/utils'; import omitBy from 'lodash/omitBy'; -import isEmpty from 'lodash/isEmpty'; import { isFalsy } from '../../utils'; @@ -18,12 +17,19 @@ class EnterpriseDataApiService { static enterpriseAdminAnalyticsV2BaseUrl = `${configuration.DATA_API_BASE_URL}/enterprise/api/v1/admin/analytics/`; - static constructDataTableURL(tableKey, baseURL) { - const dataTableURLsMap = { + static constructAnalyticsDataURL(key, baseURL) { + const dataURLsMap = { + skills: `${baseURL}/skills/stats`, + completions: `${baseURL}/completions/stats`, + engagements: `${baseURL}/engagements/stats`, + enrollments: `${baseURL}/enrollments/stats`, leaderboardTable: `${baseURL}/leaderboard`, + completionsTable: `${baseURL}/completions`, + engagementsTable: `${baseURL}/engagements`, + enrollmentsTable: `${baseURL}/enrollments`, }; - return dataTableURLsMap[tableKey]; + return dataURLsMap[key]; } static getEnterpriseUUID(enterpriseId) { @@ -151,22 +157,14 @@ class EnterpriseDataApiService { return EnterpriseDataApiService.apiClient().get(url); } - static fetchAdminAnalyticsSkills(enterpriseCustomerUUID, options) { - const enterpriseUUID = EnterpriseDataApiService.getEnterpriseUUID(enterpriseCustomerUUID); - const transformOptions = omitBy(snakeCaseObject(options), isEmpty); - const queryParams = new URLSearchParams(transformOptions); - const url = `${EnterpriseDataApiService.enterpriseAdminAnalyticsV2BaseUrl}${enterpriseUUID}/skills/stats?${queryParams.toString()}`; - return EnterpriseDataApiService.apiClient().get(url).then((response) => camelCaseObject(response.data)); - } - - static fetchAdminAnalyticsTableData(enterpriseCustomerUUID, tableKey, options) { + static fetchAdminAnalyticsData(enterpriseCustomerUUID, key, options) { const baseURL = EnterpriseDataApiService.enterpriseAdminAnalyticsV2BaseUrl; const enterpriseUUID = EnterpriseDataApiService.getEnterpriseUUID(enterpriseCustomerUUID); const transformOptions = omitBy(snakeCaseObject(options), isFalsy); const queryParams = new URLSearchParams(transformOptions); - const tableURL = EnterpriseDataApiService.constructDataTableURL(tableKey, `${baseURL}${enterpriseUUID}`); + const tableURL = EnterpriseDataApiService.constructAnalyticsDataURL(key, `${baseURL}${enterpriseUUID}`); const url = `${tableURL}?${queryParams.toString()}`; - return EnterpriseDataApiService.apiClient().get(url); + return EnterpriseDataApiService.apiClient().get(url).then((response) => camelCaseObject(response.data)); } static fetchDashboardInsights(enterpriseId) { diff --git a/src/data/services/tests/EnterpriseDataApiService.test.js b/src/data/services/tests/EnterpriseDataApiService.test.js index 02e2d0f4e6..318099d753 100644 --- a/src/data/services/tests/EnterpriseDataApiService.test.js +++ b/src/data/services/tests/EnterpriseDataApiService.test.js @@ -15,8 +15,16 @@ const mockAnalyticsSkillsData = { top_skills_by_completions: [], }; -axiosMock.onAny().reply(200); -axios.get = jest.fn(() => Promise.resolve({ data: mockAnalyticsSkillsData })); +const mockAnalyticsLeaderboardTableData = [ + { + email: 'user@example.com', + dailySessions: 243, + learningTimeSeconds: 1111, + learningTimeHours: 3.4, + averageSessionLength: 1.6, + courseCompletions: 4, + }, +]; const mockEnterpriseUUID = '33ce6562-95e0-4ecf-a2a7-7d407eb96f69'; @@ -24,21 +32,36 @@ describe('EnterpriseDataApiService', () => { beforeEach(() => { jest.clearAllMocks(); }); + afterEach(() => { + axiosMock.reset(); + }); - test('fetchAdminAnalyticsSkills calls correct API endpoint', async () => { + test('fetchAdminAnalyticsData calls correct chart data API endpoint', async () => { const requestOptions = { startDate: '2021-01-01', endDate: '2021-12-31' }; const queryParams = new URLSearchParams(snakeCaseObject(requestOptions)); const baseURL = `${EnterpriseDataApiService.enterpriseAdminAnalyticsV2BaseUrl}${mockEnterpriseUUID}`; const analyticsSkillsURL = `${baseURL}/skills/stats?${queryParams.toString()}`; - const response = await EnterpriseDataApiService.fetchAdminAnalyticsSkills(mockEnterpriseUUID, requestOptions); - expect(axios.get).toBeCalledWith(analyticsSkillsURL); + axiosMock.onGet(`${analyticsSkillsURL}`).reply(200, mockAnalyticsSkillsData); + const response = await EnterpriseDataApiService.fetchAdminAnalyticsData(mockEnterpriseUUID, 'skills', requestOptions); + expect(axiosMock.history.get[0].url).toBe(analyticsSkillsURL); expect(response).toEqual(camelCaseObject(mockAnalyticsSkillsData)); }); - test('fetchAdminAnalyticsSkills remove falsy query params', () => { + test('fetchAdminAnalyticsData calls correct table data API endpoint', async () => { + const requestOptions = { startDate: '2021-01-01', endDate: '2021-12-31' }; + const queryParams = new URLSearchParams(snakeCaseObject(requestOptions)); + const baseURL = `${EnterpriseDataApiService.enterpriseAdminAnalyticsV2BaseUrl}${mockEnterpriseUUID}`; + const analyticsLeaderboardURL = `${baseURL}/leaderboard?${queryParams.toString()}`; + axiosMock.onGet(`${analyticsLeaderboardURL}`).reply(200, mockAnalyticsLeaderboardTableData); + const response = await EnterpriseDataApiService.fetchAdminAnalyticsData(mockEnterpriseUUID, 'leaderboardTable', requestOptions); + expect(axiosMock.history.get[0].url).toBe(analyticsLeaderboardURL); + expect(response).toEqual(camelCaseObject(mockAnalyticsLeaderboardTableData)); + }); + test('fetchAdminAnalyticsData remove falsy query params', () => { const requestOptions = { startDate: '', endDate: null, otherDate: undefined }; const baseURL = `${EnterpriseDataApiService.enterpriseAdminAnalyticsV2BaseUrl}${mockEnterpriseUUID}`; - const analyticsSkillsURL = `${baseURL}/skills/stats?`; - EnterpriseDataApiService.fetchAdminAnalyticsSkills(mockEnterpriseUUID, requestOptions); - expect(axios.get).toBeCalledWith(analyticsSkillsURL); + const analyticsEnrollmentsURL = `${baseURL}/enrollments/stats?`; + axiosMock.onGet(`${analyticsEnrollmentsURL}`).reply(200, []); + EnterpriseDataApiService.fetchAdminAnalyticsData(mockEnterpriseUUID, 'enrollments', requestOptions); + expect(axiosMock.history.get[0].url).toBe(analyticsEnrollmentsURL); }); }); diff --git a/src/setupTest.js b/src/setupTest.js index 6e3b5eed17..d9420260d0 100644 --- a/src/setupTest.js +++ b/src/setupTest.js @@ -7,6 +7,7 @@ import MockAdapter from 'axios-mock-adapter'; import ResizeObserverPolyfill from 'resize-observer-polyfill'; import { getAuthenticatedHttpClient } from '@edx/frontend-platform/auth'; import 'jest-localstorage-mock'; +import 'jest-canvas-mock'; Enzyme.configure({ adapter: new Adapter() }); From 3f786ef544140bd820c67878c773087887a71467 Mon Sep 17 00:00:00 2001 From: muhammad-ammar Date: Thu, 5 Sep 2024 09:32:09 +0500 Subject: [PATCH 2/2] feat: show transparent overlay over rendered chart while re-fetching data --- .../AdvanceAnalyticsV2/ProgressOverlay.jsx | 2 +- .../charts/ChartWrapper.jsx | 42 ++++++++++--------- .../AdvanceAnalyticsV2/data/hooks.js | 4 +- .../AdvanceAnalyticsV2/data/hooks.test.jsx | 8 ++-- .../AdvanceAnalyticsV2/styles/index.scss | 24 +++++++++++ .../tabs/AnalyticsTable.jsx | 4 +- .../AdvanceAnalyticsV2/tabs/Completions.jsx | 8 ++-- .../tabs/Completions.test.jsx | 23 ++++++---- .../AdvanceAnalyticsV2/tabs/Engagements.jsx | 10 ++--- .../tabs/Engagements.test.jsx | 25 +++++++---- .../AdvanceAnalyticsV2/tabs/Enrollments.jsx | 8 ++-- .../tabs/Enrollments.test.jsx | 33 +++++++++++---- .../AdvanceAnalyticsV2/tabs/Skills.jsx | 8 ++-- .../AdvanceAnalyticsV2/tabs/Skills.test.jsx | 9 ++-- 14 files changed, 133 insertions(+), 75 deletions(-) diff --git a/src/components/AdvanceAnalyticsV2/ProgressOverlay.jsx b/src/components/AdvanceAnalyticsV2/ProgressOverlay.jsx index 3068061e10..31e7ca30b2 100644 --- a/src/components/AdvanceAnalyticsV2/ProgressOverlay.jsx +++ b/src/components/AdvanceAnalyticsV2/ProgressOverlay.jsx @@ -14,7 +14,7 @@ const ProgressOverlay = ({ isError, message }) => ( ProgressOverlay.propTypes = { isError: PropTypes.bool.isRequired, - message: PropTypes.string.isRequired, + message: PropTypes.string, }; export default ProgressOverlay; diff --git a/src/components/AdvanceAnalyticsV2/charts/ChartWrapper.jsx b/src/components/AdvanceAnalyticsV2/charts/ChartWrapper.jsx index e61a04b420..e41c0454c0 100644 --- a/src/components/AdvanceAnalyticsV2/charts/ChartWrapper.jsx +++ b/src/components/AdvanceAnalyticsV2/charts/ChartWrapper.jsx @@ -1,41 +1,45 @@ import React from 'react'; import PropTypes from 'prop-types'; -import ProgressOverlay from '../ProgressOverlay'; +import classNames from 'classnames'; +import { + Spinner, +} from '@openedx/paragon'; import ScatterChart from './ScatterChart'; import LineChart from './LineChart'; import BarChart from './BarChart'; +import EmptyChart from './EmptyChart'; const ChartWrapper = ({ - isLoading, + isFetching, isError, chartType, chartProps, loadingMessage, }) => { - if (isLoading || isError) { - return ( - - ); + if (isError) { + return ; } - const renderChart = () => { - const chartMap = { - ScatterChart: , - LineChart: , - BarChart: , - }; - - return chartMap[chartType]; + const chartMap = { + ScatterChart: , + LineChart: , + BarChart: , }; - return renderChart(); + return ( +
+ {isFetching && ( +
+ +
+ )} + {chartProps.data && chartMap[chartType]} +
+ ); }; ChartWrapper.propTypes = { - isLoading: PropTypes.bool.isRequired, + isFetching: PropTypes.bool.isRequired, isError: PropTypes.bool.isRequired, chartType: PropTypes.oneOf(['ScatterChart', 'LineChart', 'BarChart']).isRequired, chartProps: PropTypes.object.isRequired, diff --git a/src/components/AdvanceAnalyticsV2/data/hooks.js b/src/components/AdvanceAnalyticsV2/data/hooks.js index c64ef19910..fd8ffdb77f 100644 --- a/src/components/AdvanceAnalyticsV2/data/hooks.js +++ b/src/components/AdvanceAnalyticsV2/data/hooks.js @@ -24,8 +24,8 @@ export const useEnterpriseAnalyticsData = ({ key, requestOptions, ), - staleTime: 0.5 * (1000 * 60 * 60), // 30 minutes. Length of time before your data becomes stale - cacheTime: 0.75 * (1000 * 60 * 60), // 45 minutes. Length of time before inactive data gets removed from the cache + staleTime: 0.5 * (1000 * 60 * 60), // 30 minutes. The time in milliseconds after data is considered stale. + cacheTime: 0.75 * (1000 * 60 * 60), // 45 minutes. Cache data will be garbage collected after this duration. keepPreviousData: true, ...queryOptions, }); diff --git a/src/components/AdvanceAnalyticsV2/data/hooks.test.jsx b/src/components/AdvanceAnalyticsV2/data/hooks.test.jsx index 9d4514c618..ff3037155b 100644 --- a/src/components/AdvanceAnalyticsV2/data/hooks.test.jsx +++ b/src/components/AdvanceAnalyticsV2/data/hooks.test.jsx @@ -68,7 +68,7 @@ describe('useEnterpriseAnalyticsData', () => { expect(result.current).toEqual( expect.objectContaining({ - isLoading: true, + isFetching: true, error: null, data: undefined, }), @@ -89,7 +89,7 @@ describe('useEnterpriseAnalyticsData', () => { }, ); expect(result.current).toEqual(expect.objectContaining({ - isLoading: false, + isFetching: false, error: null, data: camelCaseObject(mockAnalyticsCompletionsChartsData), })); @@ -115,7 +115,7 @@ describe('useEnterpriseAnalyticsData', () => { expect(result.current).toEqual( expect.objectContaining({ - isLoading: true, + isFetching: true, error: null, data: undefined, }), @@ -136,7 +136,7 @@ describe('useEnterpriseAnalyticsData', () => { }, ); expect(result.current).toEqual(expect.objectContaining({ - isLoading: false, + isFetching: false, error: null, data: camelCaseObject(mockAnalyticsLeaderboardTableData), })); diff --git a/src/components/AdvanceAnalyticsV2/styles/index.scss b/src/components/AdvanceAnalyticsV2/styles/index.scss index 8d94f85652..21748bd525 100644 --- a/src/components/AdvanceAnalyticsV2/styles/index.scss +++ b/src/components/AdvanceAnalyticsV2/styles/index.scss @@ -1,3 +1,27 @@ .analytics-stat-number { font-size: 2.5rem; } + +.analytics-chart-container { + position: relative; + min-height: 40vh; + + &.is-fetching::before { + content: ""; + position: absolute; + top: 0; + left: 0; + width: 100%; + height: 100%; + background-color: rgba($white, .7); + z-index: 1; + } + + .spinner-centered { + position: absolute; + top: 50%; + left: 50%; + transform: translate(-50%, -50%); + z-index: 2; + } +} diff --git a/src/components/AdvanceAnalyticsV2/tabs/AnalyticsTable.jsx b/src/components/AdvanceAnalyticsV2/tabs/AnalyticsTable.jsx index cda64dc68d..ae34b94635 100644 --- a/src/components/AdvanceAnalyticsV2/tabs/AnalyticsTable.jsx +++ b/src/components/AdvanceAnalyticsV2/tabs/AnalyticsTable.jsx @@ -21,7 +21,7 @@ const AnalyticsTable = ({ const [currentPage, setCurrentPage] = useState(0); const { - isLoading, data, isPreviousData, + isFetching, data, } = useEnterpriseAnalyticsData({ enterpriseCustomerUUID: enterpriseId, key: analyticsDataTableKeys[name], @@ -55,7 +55,7 @@ const AnalyticsTable = ({ isDownloadCSV={enableCSVDownload} /> Promise.resolve({ data: mockAnalyticsData })); jest.mock('../charts/LineChart', () => { const MockedLineChart = () =>
Mocked LineChart
; @@ -50,7 +53,14 @@ jest.mock('../charts/BarChart', () => { }); describe('Completions Component', () => { + afterEach(() => { + axiosMock.reset(); + }); + test('renders all charts correctly', async () => { + axiosMock.onGet(/\/completions\/stats(\?.*)/).reply(200, mockAnalyticsChartsData); + axiosMock.onGet(/\/completions(\?.*)/).reply(200, mockAnalyticsTableData); + const { container } = render( @@ -103,7 +113,7 @@ describe('Completions Component', () => { // ensure the correct number of rows are rendered (including header row) const rows = screen.getAllByRole('row'); - expect(rows).toHaveLength(mockAnalyticsData.count + 1); // +1 for header row + expect(rows).toHaveLength(mockAnalyticsTableData.count + 1); // +1 for header row // validate header row const columnHeaders = within(rows[0]).getAllByRole('columnheader'); @@ -112,7 +122,7 @@ describe('Completions Component', () => { }); // validate content of each data row - mockAnalyticsData.results.forEach((user, index) => { + mockAnalyticsTableData.results.forEach((user, index) => { const rowCells = within(rows[index + 1]).getAllByRole('cell'); // Skip header row expect(rowCells[0]).toHaveTextContent(user.email); expect(rowCells[1]).toHaveTextContent(user.course_title); @@ -124,10 +134,9 @@ describe('Completions Component', () => { test('renders charts with correct loading messages', () => { jest.mock('../data/hooks', () => ({ useEnterpriseAnalyticsTableData: jest.fn().mockReturnValue({ - isLoading: true, + isFetching: true, data: null, isError: false, - isFetching: false, error: null, }), })); diff --git a/src/components/AdvanceAnalyticsV2/tabs/Engagements.jsx b/src/components/AdvanceAnalyticsV2/tabs/Engagements.jsx index fcf78fd535..6d933e48c7 100644 --- a/src/components/AdvanceAnalyticsV2/tabs/Engagements.jsx +++ b/src/components/AdvanceAnalyticsV2/tabs/Engagements.jsx @@ -12,7 +12,7 @@ const Engagements = ({ }) => { const intl = useIntl(); const { - isLoading, isError, data, + isFetching, isError, data, } = useEnterpriseAnalyticsData({ enterpriseCustomerUUID: enterpriseId, key: ANALYTICS_TABS.ENGAGEMENTS, @@ -44,7 +44,7 @@ const Engagements = ({ isDownloadCSV /> Promise.resolve({ data: mockAnalyticsData })); jest.mock('../charts/LineChart', () => { const MockedLineChart = () =>
Mocked LineChart
; @@ -52,7 +55,14 @@ jest.mock('../charts/BarChart', () => { }); describe('Engagements Component', () => { - test('renders all sections with correct classes and content', async () => { + afterEach(() => { + axiosMock.reset(); + }); + + test('renders all charts correctly', async () => { + axiosMock.onGet(/\/engagements\/stats(\?.*)/).reply(200, mockAnalyticsChartsData); + axiosMock.onGet(/\/engagements(\?.*)/).reply(200, mockAnalyticsTableData); + const { container } = render( @@ -105,7 +115,7 @@ describe('Engagements Component', () => { // ensure the correct number of rows are rendered (including header row) const rows = screen.getAllByRole('row'); - expect(rows).toHaveLength(mockAnalyticsData.count + 1); // +1 for header row + expect(rows).toHaveLength(mockAnalyticsTableData.count + 1); // +1 for header row // validate header row const columnHeaders = within(rows[0]).getAllByRole('columnheader'); @@ -114,7 +124,7 @@ describe('Engagements Component', () => { }); // validate content of each data row - mockAnalyticsData.results.forEach((user, index) => { + mockAnalyticsTableData.results.forEach((user, index) => { const rowCells = within(rows[index + 1]).getAllByRole('cell'); // Skip header row expect(rowCells[0]).toHaveTextContent(user.email); expect(rowCells[1]).toHaveTextContent(user.course_title); @@ -127,10 +137,9 @@ describe('Engagements Component', () => { test('renders charts with correct loading messages', () => { jest.mock('../data/hooks', () => ({ useEnterpriseAnalyticsTableData: jest.fn().mockReturnValue({ - isLoading: true, + isFetching: true, data: null, isError: false, - isFetching: false, error: null, }), })); diff --git a/src/components/AdvanceAnalyticsV2/tabs/Enrollments.jsx b/src/components/AdvanceAnalyticsV2/tabs/Enrollments.jsx index 7464eba1a4..02df8747f0 100644 --- a/src/components/AdvanceAnalyticsV2/tabs/Enrollments.jsx +++ b/src/components/AdvanceAnalyticsV2/tabs/Enrollments.jsx @@ -12,7 +12,7 @@ const Enrollments = ({ }) => { const intl = useIntl(); const { - isLoading, isError, data, + isFetching, isError, data, } = useEnterpriseAnalyticsData({ enterpriseCustomerUUID: enterpriseId, key: ANALYTICS_TABS.ENROLLMENTS, @@ -46,7 +46,7 @@ const Enrollments = ({ isDownloadCSV /> Promise.resolve({ data: mockAnalyticsData })); jest.mock('../charts/LineChart', () => { const MockedLineChart = () =>
Mocked LineChart
; @@ -52,11 +55,24 @@ jest.mock('../charts/BarChart', () => { }); describe('Enrollments Component', () => { - test('renders all sections with correct classes and content', async () => { + afterEach(() => { + axiosMock.reset(); + }); + + test('renders all charts correctly', async () => { + axiosMock.onGet(/\/enrollments\/stats(\?.*)/).reply(200, mockAnalyticsChartsData); + axiosMock.onGet(/\/enrollments(\?.*)/).reply(200, mockAnalyticsTableData); + const { container } = render( - + , , ); @@ -99,7 +115,7 @@ describe('Enrollments Component', () => { // ensure the correct number of rows are rendered (including header row) const rows = screen.getAllByRole('row'); - expect(rows).toHaveLength(mockAnalyticsData.count + 1); // +1 for header row + expect(rows).toHaveLength(mockAnalyticsTableData.count + 1); // +1 for header row // validate header row const columns = ['Email', 'Course Title', 'Course Subject', 'Enroll Type', 'Enterprise Enrollment Date']; @@ -109,7 +125,7 @@ describe('Enrollments Component', () => { }); // validate content of each data row - mockAnalyticsData.results.forEach((user, index) => { + mockAnalyticsTableData.results.forEach((user, index) => { const rowCells = within(rows[index + 1]).getAllByRole('cell'); // Skip header row expect(rowCells[0]).toHaveTextContent(user.email); expect(rowCells[1]).toHaveTextContent(user.course_title); @@ -122,10 +138,9 @@ describe('Enrollments Component', () => { test('renders charts with correct loading messages', () => { jest.mock('../data/hooks', () => ({ useEnterpriseAnalyticsTableData: jest.fn().mockReturnValue({ - isLoading: true, + isFetching: true, data: null, isError: false, - isFetching: false, error: null, }), })); diff --git a/src/components/AdvanceAnalyticsV2/tabs/Skills.jsx b/src/components/AdvanceAnalyticsV2/tabs/Skills.jsx index 68f3289a79..1cd58463ff 100644 --- a/src/components/AdvanceAnalyticsV2/tabs/Skills.jsx +++ b/src/components/AdvanceAnalyticsV2/tabs/Skills.jsx @@ -12,7 +12,7 @@ const Skills = ({ startDate, endDate, enterpriseId }) => { const intl = useIntl(); const { - isLoading, isError, data, + isFetching, isError, data, } = useEnterpriseAnalyticsData({ enterpriseCustomerUUID: enterpriseId, key: ANALYTICS_TABS.SKILLS, @@ -43,7 +43,7 @@ const Skills = ({ startDate, endDate, enterpriseId }) => { /> { })} /> { })} /> { describe('renders static text', () => { test('renders all sections with correct classes and content', () => { hooks.useEnterpriseAnalyticsData.mockReturnValue({ - isLoading: true, + isFetching: true, data: null, isError: false, - isFetching: false, error: null, }); @@ -79,10 +78,9 @@ describe('Skills Tab', () => { describe('when loading data from API', () => { test('renders correct messages', () => { hooks.useEnterpriseAnalyticsData.mockReturnValue({ - isLoading: true, + isFetching: true, data: null, isError: false, - isFetching: false, error: null, }); @@ -107,10 +105,9 @@ describe('Skills Tab', () => { describe('when data successfully loaded from API', () => { test('renders charts', () => { hooks.useEnterpriseAnalyticsData.mockReturnValue({ - isLoading: false, + isFetching: false, data: mockAnalyticsSkillsData, isError: false, - isFetching: false, error: null, }); render(