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, };