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/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
new file mode 100644
index 0000000000..e41c0454c0
--- /dev/null
+++ b/src/components/AdvanceAnalyticsV2/charts/ChartWrapper.jsx
@@ -0,0 +1,49 @@
+import React from 'react';
+import PropTypes from 'prop-types';
+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 = ({
+ isFetching,
+ isError,
+ chartType,
+ chartProps,
+ loadingMessage,
+}) => {
+ if (isError) {
+ return ;
+ }
+
+ const chartMap = {
+ ScatterChart: ,
+ LineChart: ,
+ BarChart: ,
+ };
+
+ return (
+
+ {isFetching && (
+
+
+
+ )}
+ {chartProps.data && chartMap[chartType]}
+
+ );
+};
+
+ChartWrapper.propTypes = {
+ isFetching: 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..fd8ffdb77f 100644
--- a/src/components/AdvanceAnalyticsV2/data/hooks.js
+++ b/src/components/AdvanceAnalyticsV2/data/hooks.js
@@ -4,36 +4,28 @@ 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
+ 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,
});
@@ -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..ff3037155b 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,41 +13,109 @@ 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({
+ isFetching: 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({
+ isFetching: 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 },
);
expect(result.current).toEqual(
expect.objectContaining({
- isLoading: true,
+ isFetching: true,
error: null,
data: undefined,
}),
@@ -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,
+ isFetching: false,
error: null,
- data: camelCaseObject(mockAnalyticsSkillsData),
+ data: camelCaseObject(mockAnalyticsLeaderboardTableData),
}));
+ expect(axiosMock.history.get[0].url).toBe(analyticsLeaderboardURL);
});
});
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
new file mode 100644
index 0000000000..ae34b94635
--- /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 {
+ isFetching, data,
+ } = 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..58a02fbdbe 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 {
+ isFetching, 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..f08446f4d4 100644
--- a/src/components/AdvanceAnalyticsV2/tabs/Completions.test.jsx
+++ b/src/components/AdvanceAnalyticsV2/tabs/Completions.test.jsx
@@ -1,14 +1,78 @@
-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 mockAnalyticsTableData = {
+ 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',
+ },
+ ],
+};
+const mockAnalyticsChartsData = {
+ completionsOverTime: [],
+ topCoursesByCompletions: [],
+ topSubjectsByCompletions: [],
+};
+
+jest.spyOn(EnterpriseDataApiService, 'fetchAdminAnalyticsData');
+const axiosMock = new MockAdapter(axios);
+getAuthenticatedHttpClient.mockReturnValue(axios);
+
+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', () => {
+ 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(
-
-
- ,
+
+
+
+ ,
+ ,
);
const sections = [
@@ -39,5 +103,60 @@ 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(mockAnalyticsTableData.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
+ 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);
+ 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({
+ isFetching: true,
+ data: null,
+ isError: 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..6d933e48c7 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 {
+ isFetching, 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..700b538fe8 100644
--- a/src/components/AdvanceAnalyticsV2/tabs/Engagements.test.jsx
+++ b/src/components/AdvanceAnalyticsV2/tabs/Engagements.test.jsx
@@ -1,14 +1,80 @@
-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 mockAnalyticsTableData = {
+ 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,
+ },
+ ],
+};
+const mockAnalyticsChartsData = {
+ engagementsOverTime: [],
+ topCoursesByEngagement: [],
+ topSubjectsByEngagement: [],
+};
+
+jest.spyOn(EnterpriseDataApiService, 'fetchAdminAnalyticsData');
+const axiosMock = new MockAdapter(axios);
+getAuthenticatedHttpClient.mockReturnValue(axios);
+
+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', () => {
+ 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(
-
-
- ,
+
+
+
+ ,
+ ,
);
const sections = [
@@ -39,5 +105,61 @@ 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(mockAnalyticsTableData.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
+ 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);
+ 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({
+ isFetching: true,
+ data: null,
+ isError: 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..02df8747f0 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 {
+ isFetching, 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..95e9c16095 100644
--- a/src/components/AdvanceAnalyticsV2/tabs/Enrollments.test.jsx
+++ b/src/components/AdvanceAnalyticsV2/tabs/Enrollments.test.jsx
@@ -1,14 +1,80 @@
-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 mockAnalyticsTableData = {
+ 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',
+ },
+ ],
+};
+const mockAnalyticsChartsData = {
+ enrollmentsOverTime: [],
+ topCoursesByEnrollments: [],
+ topSubjectsByEnrollments: [],
+};
+
+jest.spyOn(EnterpriseDataApiService, 'fetchAdminAnalyticsData');
+const axiosMock = new MockAdapter(axios);
+getAuthenticatedHttpClient.mockReturnValue(axios);
+
+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', () => {
+ 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(
-
-
- ,
+
+
+
+ ,
+ ,
);
const sections = [
@@ -39,5 +105,62 @@ 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(mockAnalyticsTableData.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
+ 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);
+ 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({
+ isFetching: true,
+ data: null,
+ isError: 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..1cd58463ff 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,
+ isFetching, isError, data,
+ } = 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..8ddd66cce5 100644
--- a/src/components/AdvanceAnalyticsV2/tabs/Skills.test.jsx
+++ b/src/components/AdvanceAnalyticsV2/tabs/Skills.test.jsx
@@ -24,17 +24,16 @@ 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({
- isLoading: true,
+ hooks.useEnterpriseAnalyticsData.mockReturnValue({
+ isFetching: true,
data: null,
isError: false,
- isFetching: false,
error: null,
});
@@ -78,11 +77,10 @@ describe('Skills Tab', () => {
describe('when loading data from API', () => {
test('renders correct messages', () => {
- hooks.useEnterpriseSkillsAnalytics.mockReturnValue({
- isLoading: true,
+ hooks.useEnterpriseAnalyticsData.mockReturnValue({
+ isFetching: true,
data: null,
isError: false,
- isFetching: false,
error: null,
});
@@ -106,11 +104,10 @@ describe('Skills Tab', () => {
describe('when data successfully loaded from API', () => {
test('renders charts', () => {
- hooks.useEnterpriseSkillsAnalytics.mockReturnValue({
- isLoading: false,
+ hooks.useEnterpriseAnalyticsData.mockReturnValue({
+ isFetching: false,
data: mockAnalyticsSkillsData,
isError: false,
- isFetching: false,
error: null,
});
render(
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() });