diff --git a/src/components/AdvanceAnalyticsV2/data/constants.js b/src/components/AdvanceAnalyticsV2/data/constants.js
index c813144ed7..505e4f21f8 100644
--- a/src/components/AdvanceAnalyticsV2/data/constants.js
+++ b/src/components/AdvanceAnalyticsV2/data/constants.js
@@ -34,12 +34,28 @@ export const ANALYTICS_TABS = {
ENGAGEMENTS: 'engagements',
};
+export const analyticsDataTableKeys = {
+ leaderboard: 'leaderboardTable',
+ enrollments: 'enrollmentsTable',
+ engagements: 'engagementsTable',
+ completions: 'completionsTable',
+};
+
+const analyticsDefaultKeys = ['admin-analytics'];
+
+const generateKey = (key, enterpriseUUID, requestOptions) => [
+ ...analyticsDefaultKeys,
+ key,
+ enterpriseUUID,
+].concat(Object.values(requestOptions));
+
// Query Key factory for the admin analytics module, intended to be used with `@tanstack/react-query`.
// Inspired by https://tkdodo.eu/blog/effective-react-query-keys#use-query-key-factories.
export const advanceAnalyticsQueryKeys = {
- all: ['admin-analytics'],
- skills: (enterpriseUUID, requestOptions) => [...advanceAnalyticsQueryKeys.all, 'skills', enterpriseUUID].concat(
- Object.values(requestOptions),
+ all: analyticsDefaultKeys,
+ skills: (enterpriseUUID, requestOptions) => generateKey('skills', enterpriseUUID, requestOptions),
+ leaderboardTable: (enterpriseUUID, requestOptions) => (
+ generateKey(analyticsDataTableKeys.leaderboard, enterpriseUUID, requestOptions)
),
};
diff --git a/src/components/AdvanceAnalyticsV2/data/hooks.js b/src/components/AdvanceAnalyticsV2/data/hooks.js
index e38982819e..45fee4dce0 100644
--- a/src/components/AdvanceAnalyticsV2/data/hooks.js
+++ b/src/components/AdvanceAnalyticsV2/data/hooks.js
@@ -1,3 +1,4 @@
+import { useMemo } from 'react';
import { useQuery } from '@tanstack/react-query';
import { advanceAnalyticsQueryKeys } from './constants';
@@ -13,3 +14,44 @@ export const useEnterpriseSkillsAnalytics = (enterpriseCustomerUUID, startDate,
...queryOptions,
});
};
+
+export const useEnterpriseAnalyticsTableData = (
+ enterpriseCustomerUUID,
+ tableKey,
+ startDate,
+ endDate,
+ currentPage,
+ queryOptions = {},
+) => {
+ const requestOptions = { startDate, endDate, page: currentPage };
+ return useQuery({
+ queryKey: advanceAnalyticsQueryKeys[tableKey](enterpriseCustomerUUID, requestOptions),
+ queryFn: () => EnterpriseDataApiService.fetchAdminAnalyticsTableData(
+ enterpriseCustomerUUID,
+ tableKey,
+ 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,
+ ...queryOptions,
+ });
+};
+
+export const usePaginatedData = (data) => useMemo(() => {
+ if (data) {
+ return {
+ data: data.results,
+ pageCount: data.num_pages,
+ itemCount: data.count,
+ currentPage: data.current_page,
+ };
+ }
+
+ return {
+ itemCount: 0,
+ pageCount: 0,
+ data: [],
+ };
+}, [data]);
diff --git a/src/components/AdvanceAnalyticsV2/tabs/Leaderboard.jsx b/src/components/AdvanceAnalyticsV2/tabs/Leaderboard.jsx
index 0eee4b8180..d350b6e4c1 100644
--- a/src/components/AdvanceAnalyticsV2/tabs/Leaderboard.jsx
+++ b/src/components/AdvanceAnalyticsV2/tabs/Leaderboard.jsx
@@ -1,12 +1,37 @@
-import React from 'react';
+import React, { useState, useCallback } from 'react';
import { useIntl } from '@edx/frontend-platform/i18n';
import PropTypes from 'prop-types';
-import EmptyChart from '../charts/EmptyChart';
+import { DataTable, TablePaginationMinimal } from '@openedx/paragon';
import Header from '../Header';
-import { ANALYTICS_TABS } from '../data/constants';
+import { ANALYTICS_TABS, analyticsDataTableKeys } from '../data/constants';
+
+import { useEnterpriseAnalyticsTableData, usePaginatedData } from '../data/hooks';
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 (
@@ -28,7 +53,76 @@ const Leaderboard = ({ startDate, endDate, enterpriseId }) => {
enterpriseId={enterpriseId}
isDownloadCSV
/>
-
+
+
+
+
+
+
+
+
);
diff --git a/src/components/AdvanceAnalyticsV2/tabs/Leaderboard.test.jsx b/src/components/AdvanceAnalyticsV2/tabs/Leaderboard.test.jsx
index fe0a5848ca..6e75e455ad 100644
--- a/src/components/AdvanceAnalyticsV2/tabs/Leaderboard.test.jsx
+++ b/src/components/AdvanceAnalyticsV2/tabs/Leaderboard.test.jsx
@@ -1,14 +1,69 @@
-import { render } from '@testing-library/react';
+import {
+ render, screen, waitFor, within,
+} from '@testing-library/react';
import '@testing-library/jest-dom';
import { IntlProvider } from '@edx/frontend-platform/i18n';
+import { QueryClientProvider } from '@tanstack/react-query';
+import MockAdapter from 'axios-mock-adapter';
+import axios from 'axios';
+import { getAuthenticatedHttpClient } from '@edx/frontend-platform/auth';
import Leaderboard from './Leaderboard';
+import { queryClient } from '../../test/testUtils';
+import EnterpriseDataApiService from '../../../data/services/EnterpriseDataApiService';
+
+jest.spyOn(EnterpriseDataApiService, 'fetchAdminAnalyticsTableData');
+
+const axiosMock = new MockAdapter(axios);
+getAuthenticatedHttpClient.mockReturnValue(axios);
+
+const mockLeaderboardData = {
+ next: null,
+ previous: null,
+ count: 3,
+ num_pages: 1,
+ current_page: 1,
+ results: [
+ {
+ email: 'user100@example.com',
+ daily_sessions: 74,
+ learning_time_hours: 13.1,
+ average_session_length: 1.8,
+ course_completions: 3,
+ },
+ {
+ email: 'user200@example.com',
+ daily_sessions: 48,
+ learning_time_hours: 131.9,
+ average_session_length: 2.7,
+ course_completions: 1,
+ },
+ {
+ email: 'user300@example.com',
+ daily_sessions: 92,
+ learning_time_hours: 130,
+ average_session_length: 1.4,
+ course_completions: 3,
+ },
+ ],
+};
+
+axiosMock.onAny().reply(200);
+axios.get = jest.fn(() => Promise.resolve({ data: mockLeaderboardData }));
+
+const TEST_ENTERPRISE_ID = '33ce6562-95e0-4ecf-a2a7-7d407eb96f69';
describe('Leaderboard Component', () => {
test('renders all sections with correct classes and content', () => {
const { container } = render(
-
-
- ,
+
+
+
+ ,
+ ,
);
const sections = [
@@ -25,4 +80,44 @@ describe('Leaderboard Component', () => {
expect(section).toHaveTextContent(subtitle);
});
});
+ test('renders the table rows with correct values', async () => {
+ render(
+
+
+
+ ,
+ ,
+ );
+
+ // validate the header row
+ const headers = screen.getAllByRole('columnheader');
+ expect(headers).toHaveLength(5);
+ expect(headers[0]).toHaveTextContent('Email');
+ expect(headers[1]).toHaveTextContent('Learning Hours');
+ expect(headers[2]).toHaveTextContent('Daily Sessions');
+ expect(headers[3]).toHaveTextContent('Average Session Length (Hours)');
+ expect(headers[4]).toHaveTextContent('Course Completions');
+
+ await waitFor(() => {
+ expect(EnterpriseDataApiService.fetchAdminAnalyticsTableData).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 content of each data row
+ mockLeaderboardData.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.learning_time_hours);
+ expect(rowCells[2]).toHaveTextContent(user.daily_sessions);
+ expect(rowCells[3]).toHaveTextContent(user.average_session_length);
+ expect(rowCells[4]).toHaveTextContent(user.course_completions);
+ });
+ });
+ });
});
diff --git a/src/data/services/EnterpriseDataApiService.js b/src/data/services/EnterpriseDataApiService.js
index c5a6e29868..2fe3447f3e 100644
--- a/src/data/services/EnterpriseDataApiService.js
+++ b/src/data/services/EnterpriseDataApiService.js
@@ -3,6 +3,8 @@ import { snakeCaseObject, camelCaseObject } from '@edx/frontend-platform/utils';
import omitBy from 'lodash/omitBy';
import isEmpty from 'lodash/isEmpty';
+import { isFalsy } from '../../utils';
+
import store from '../store';
import { configuration } from '../../config';
@@ -16,6 +18,14 @@ class EnterpriseDataApiService {
static enterpriseAdminAnalyticsV2BaseUrl = `${configuration.DATA_API_BASE_URL}/enterprise/api/v1/admin/analytics/`;
+ static constructDataTableURL(tableKey, baseURL) {
+ const dataTableURLsMap = {
+ leaderboardTable: `${baseURL}/leaderboard`,
+ };
+
+ return dataTableURLsMap[tableKey];
+ }
+
static getEnterpriseUUID(enterpriseId) {
const { enableDemoData } = store.getState().portalConfiguration;
return enableDemoData ? configuration.DEMO_ENTEPRISE_UUID : enterpriseId;
@@ -149,6 +159,16 @@ class EnterpriseDataApiService {
return EnterpriseDataApiService.apiClient().get(url).then((response) => camelCaseObject(response.data));
}
+ static fetchAdminAnalyticsTableData(enterpriseCustomerUUID, tableKey, 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 url = `${tableURL}?${queryParams.toString()}`;
+ return EnterpriseDataApiService.apiClient().get(url);
+ }
+
static fetchDashboardInsights(enterpriseId) {
const enterpriseUUID = EnterpriseDataApiService.getEnterpriseUUID(enterpriseId);
const url = `${EnterpriseDataApiService.enterpriseAdminBaseUrl}insights/${enterpriseUUID}`;
diff --git a/src/utils.js b/src/utils.js
index 78fcb6cd46..9c30c76f2b 100644
--- a/src/utils.js
+++ b/src/utils.js
@@ -583,6 +583,15 @@ function isTodayBetweenDates({ startDate, endDate }) {
return today.isBetween(formattedStartDate, formattedEndDate);
}
+/**
+ * Helper function to determine if a value is falsy.
+ * Returns true if value is "", null, or undefined
+ *
+ * @param value
+ * @returns {boolean}
+ */
+const isFalsy = (value) => value == null || value === '';
+
export {
camelCaseDict,
camelCaseDictArray,
@@ -628,4 +637,5 @@ export {
i18nFormatProgressStatus,
isTodayWithinDateThreshold,
isTodayBetweenDates,
+ isFalsy,
};