Skip to content

Commit

Permalink
Merge pull request #1282 from openedx/ammar/add-leaderboard-datatable
Browse files Browse the repository at this point in the history
add leaderboard datatable
  • Loading branch information
muhammad-ammar authored Aug 28, 2024
2 parents dc806a9 + 3ecc686 commit 5748e92
Show file tree
Hide file tree
Showing 6 changed files with 288 additions and 11 deletions.
22 changes: 19 additions & 3 deletions src/components/AdvanceAnalyticsV2/data/constants.js
Original file line number Diff line number Diff line change
Expand Up @@ -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)
),
};

Expand Down
42 changes: 42 additions & 0 deletions src/components/AdvanceAnalyticsV2/data/hooks.js
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
import { useMemo } from 'react';
import { useQuery } from '@tanstack/react-query';

import { advanceAnalyticsQueryKeys } from './constants';
Expand All @@ -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]);
102 changes: 98 additions & 4 deletions src/components/AdvanceAnalyticsV2/tabs/Leaderboard.jsx
Original file line number Diff line number Diff line change
@@ -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 (
<div className="tab-leaderboard mt-4">
Expand All @@ -28,7 +53,76 @@ const Leaderboard = ({ startDate, endDate, enterpriseId }) => {
enterpriseId={enterpriseId}
isDownloadCSV
/>
<EmptyChart />
<DataTable
isLoading={isLoading || isPreviousData}
isPaginated
manualPagination
initialState={{
pageSize: 50,
pageIndex: 0,
}}
itemCount={paginatedData.itemCount}
pageCount={paginatedData.pageCount}
fetchData={fetchData}
data={paginatedData.data}
columns={[
{
Header: intl.formatMessage({
id: 'advance.analytics.leaderboard.tab.table.header.username',
defaultMessage: 'Email',
description: 'Header for the email column in leaderboard table',
}),
accessor: 'email',
},
{
Header: intl.formatMessage({
id: 'advance.analytics.leaderboard.tab.table.header.course',
defaultMessage: 'Learning Hours',
description: 'Header for the learning hours column in the leaderboard table',
}),
accessor: 'learning_time_hours',
},
{
Header: intl.formatMessage({
id: 'advance.analytics.leaderboard.tab.table.header.module',
defaultMessage: 'Daily Sessions',
description: 'Header for the daily sessions column in the leaderboard table',

}),
accessor: 'daily_sessions',
},
{
Header: intl.formatMessage({
id: 'advance.analytics.leaderboard.tab.table.header.moduleGrade',
defaultMessage: 'Average Session Length (Hours)',
description: 'Header for the average session length column in the leaderboard table',

}),
accessor: 'average_session_length',
},
{
Header: intl.formatMessage({
id: 'advance.analytics.leaderboard.tab.table.header.percentageCompletedActivities',
defaultMessage: 'Course Completions',
description: 'Header for the course completions column in the leaderboard table',
}),
accessor: 'course_completions',
},
]}
>
<DataTable.TableControlBar />
<DataTable.Table />
<DataTable.EmptyTable
content={intl.formatMessage({
id: 'advance.analytics.leaderboard.tab.table.empty',
defaultMessage: 'No results found.',
description: 'Message displayed when the module activity report table is empty',
})}
/>
<DataTable.TableFooter>
<TablePaginationMinimal />
</DataTable.TableFooter>
</DataTable>
</div>
</div>
);
Expand Down
103 changes: 99 additions & 4 deletions src/components/AdvanceAnalyticsV2/tabs/Leaderboard.test.jsx
Original file line number Diff line number Diff line change
@@ -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';

Check failure on line 8 in src/components/AdvanceAnalyticsV2/tabs/Leaderboard.test.jsx

View workflow job for this annotation

GitHub Actions / tests (18)

'axios' should be listed in the project's dependencies. Run 'npm i -S axios' to add it

Check failure on line 8 in src/components/AdvanceAnalyticsV2/tabs/Leaderboard.test.jsx

View workflow job for this annotation

GitHub Actions / tests (20)

'axios' should be listed in the project's dependencies. Run 'npm i -S axios' to add it
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: '[email protected]',
daily_sessions: 74,
learning_time_hours: 13.1,
average_session_length: 1.8,
course_completions: 3,
},
{
email: '[email protected]',
daily_sessions: 48,
learning_time_hours: 131.9,
average_session_length: 2.7,
course_completions: 1,
},
{
email: '[email protected]',
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(
<IntlProvider locale="en">
<Leaderboard />
</IntlProvider>,
<QueryClientProvider client={queryClient()}>
<IntlProvider locale="en">
<Leaderboard
enterpriseId={TEST_ENTERPRISE_ID}
startDate="2021-01-01"
endDate="2021-12-31"
/>
</IntlProvider>,
</QueryClientProvider>,
);

const sections = [
Expand All @@ -25,4 +80,44 @@ describe('Leaderboard Component', () => {
expect(section).toHaveTextContent(subtitle);
});
});
test('renders the table rows with correct values', async () => {
render(
<QueryClientProvider client={queryClient()}>
<IntlProvider locale="en">
<Leaderboard
enterpriseId={TEST_ENTERPRISE_ID}
startDate="2021-01-01"
endDate="2021-12-31"
/>
</IntlProvider>,
</QueryClientProvider>,
);

// 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);
});
});
});
});
20 changes: 20 additions & 0 deletions src/data/services/EnterpriseDataApiService.js
Original file line number Diff line number Diff line change
Expand Up @@ -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';

Expand All @@ -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;
Expand Down Expand Up @@ -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}`;
Expand Down
10 changes: 10 additions & 0 deletions src/utils.js
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -628,4 +637,5 @@ export {
i18nFormatProgressStatus,
isTodayWithinDateThreshold,
isTodayBetweenDates,
isFalsy,
};

0 comments on commit 5748e92

Please sign in to comment.