Skip to content

Commit

Permalink
Merge branch 'master' into repo-tools/salman/add-dependabot-file-115c430
Browse files Browse the repository at this point in the history
  • Loading branch information
macdiesel committed Sep 16, 2024
2 parents 8858aa3 + 152488a commit 7bbffbe
Show file tree
Hide file tree
Showing 21 changed files with 923 additions and 223 deletions.
17 changes: 9 additions & 8 deletions src/components/AdvanceAnalyticsV2/AnalyticsV2Page.jsx
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,7 @@ import Completions from './tabs/Completions';
import Leaderboard from './tabs/Leaderboard';
import Skills from './tabs/Skills';
import { useEnterpriseAnalyticsAggregatesData } from './data/hooks';
import { GRANULARITY, CALCULATION } from './data/constants';

const PAGE_TITLE = 'AnalyticsV2';

Expand Down Expand Up @@ -96,28 +97,28 @@ const AnalyticsV2Page = ({ enterpriseId }) => {
value={granularity}
onChange={(e) => setGranularity(e.target.value)}
>
<option value="Daily">
<option value={GRANULARITY.DAILY}>
{intl.formatMessage({
id: 'advance.analytics.filter.granularity.option.daily',
defaultMessage: 'Daily',
description: 'Advance analytics granularity filter daily option',
})}
</option>
<option value="Weekly">
<option value={GRANULARITY.WEEKLY}>
{intl.formatMessage({
id: 'advance.analytics.filter.granularity.option.weekly',
defaultMessage: 'Weekly',
description: 'Advance analytics granularity filter weekly option',
})}
</option>
<option value="Monthly">
<option value={GRANULARITY.MONTHLY}>
{intl.formatMessage({
id: 'advance.analytics.filter.granularity.option.monthly',
defaultMessage: 'Monthly',
description: 'Advance analytics granularity filter monthly option',
})}
</option>
<option value="Quarterly">
<option value={GRANULARITY.QUARTERLY}>
{intl.formatMessage({
id: 'advance.analytics.filter.granularity.option.quarterly',
defaultMessage: 'Quarterly',
Expand All @@ -141,28 +142,28 @@ const AnalyticsV2Page = ({ enterpriseId }) => {
value={calculation}
onChange={(e) => setCalculation(e.target.value)}
>
<option value="Total">
<option value={CALCULATION.TOTAL}>
{intl.formatMessage({
id: 'advance.analytics.filter.calculation.option.total',
defaultMessage: 'Total',
description: 'Advance analytics calculation filter total option',
})}
</option>
<option value="Running Total">
<option value={CALCULATION.RUNNING_TOTAL}>
{intl.formatMessage({
id: 'advance.analytics.filter.calculation.option.running.total',
defaultMessage: 'Running Total',
description: 'Advance analytics calculation filter running total option',
})}
</option>
<option value="Moving Average (3 Period)">
<option value={CALCULATION.MOVING_AVERAGE_3_PERIODS}>
{intl.formatMessage({
id: 'advance.analytics.filter.calculation.option.average.3',
defaultMessage: 'Moving Average (3 Period)',
description: 'Advance analytics calculation filter moving average 3 period option',
})}
</option>
<option value="Moving Average (7 Period)">
<option value={CALCULATION.MOVING_AVERAGE_7_PERIODS}>
{intl.formatMessage({
id: 'advance.analytics.filter.calculation.option.average.7',
defaultMessage: 'Moving Average (7 Period)',
Expand Down
101 changes: 101 additions & 0 deletions src/components/AdvanceAnalyticsV2/DownloadCSVButton.jsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,101 @@
import React, { useEffect, useState } from 'react';
import { saveAs } from 'file-saver';
import { useIntl } from '@edx/frontend-platform/i18n';
import PropTypes from 'prop-types';
import {
Toast, StatefulButton, Icon, Spinner, useToggle,
} from '@openedx/paragon';
import { Download, Check, Close } from '@openedx/paragon/icons';

const DownloadCSVButton = ({
jsonData, csvFileName,
}) => {
const [buttonState, setButtonState] = useState('disabled');
const [isToastShowing, showToast, hideToast] = useToggle(false);
const intl = useIntl();

useEffect(() => {
if (jsonData.length > 0) {
setButtonState('default');
}
}, [jsonData]);

const jsonToCSV = (json) => {
const fields = Object.keys(json[0]);
const replacer = (key, value) => (value === null ? '' : value);
const csv = json.map(
(row) => fields.map(
(fieldName) => JSON.stringify(row[fieldName], replacer),
).join(','),
);
csv.unshift(fields.join(',')); // add header column
return csv.join('\r\n');
};

const downloadCsv = () => {
setButtonState('pending');
const csv = jsonToCSV(jsonData);
const blob = new Blob([csv], { type: 'text/csv' });
saveAs(blob, csvFileName);
showToast();
setButtonState('complete');
};

const toastText = intl.formatMessage({
id: 'adminPortal.LPRV2.downloadCSV.toast',
defaultMessage: 'CSV Downloaded',
description: 'Toast message for the download button in the LPR V2 page.',
});
return (
<div className="d-flex justify-content-end">
{ isToastShowing
&& (
<Toast onClose={hideToast} show={showToast}>
{toastText}
</Toast>
)}
<StatefulButton
state={buttonState}
variant={buttonState === 'error' ? 'danger' : 'primary'}
data-testid="plotly-charts-download-csv-button"
disabledStates={['disabled', 'pending']}
labels={{
default: intl.formatMessage({
id: 'adminPortal.LPRV2.downloadCSV.button.default',
defaultMessage: 'Download CSV',
description: 'Label for the download button in the module activity report page.',
}),
pending: intl.formatMessage({
id: 'adminPortal.LPRV2.downloadCSV.button.pending',
defaultMessage: 'Downloading CSV',
description: 'Label for the download button in the module activity report page when the download is in progress.',
}),
complete: intl.formatMessage({
id: 'adminPortal.LPRV2.downloadCSV.button.complete',
defaultMessage: 'CSV Downloaded',
description: 'Label for the download button in the module activity report page when the download is complete.',
}),
error: intl.formatMessage({
id: 'adminPortal.LPRV2.downloadCSV.button.error',
defaultMessage: 'Error',
description: 'Label for the download button in the module activity report page when the download fails.',
}),
}}
icons={{
default: <Icon src={Download} />,
pending: <Spinner animation="border" variant="light" size="sm" />,
complete: <Icon src={Check} />,
error: <Icon src={Close} variant="light" size="sm" />,
}}
onClick={downloadCsv}
/>
</div>
);
};

DownloadCSVButton.propTypes = {
jsonData: PropTypes.arrayOf(PropTypes.shape({})).isRequired,
csvFileName: PropTypes.string.isRequired,
};

export default DownloadCSVButton;
68 changes: 68 additions & 0 deletions src/components/AdvanceAnalyticsV2/DownloadCSVButton.test.jsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,68 @@
import React from 'react';
import { IntlProvider } from '@edx/frontend-platform/i18n';
import { render, screen } from '@testing-library/react';
import userEvent from '@testing-library/user-event';
import { saveAs } from 'file-saver';
import DownloadCSVButton from './DownloadCSVButton';
import '@testing-library/jest-dom/extend-expect';

jest.mock('file-saver', () => ({
...jest.requireActual('file-saver'),
saveAs: jest.fn(),
}));

jest.mock('@edx/frontend-platform/logging', () => ({
...jest.requireActual('@edx/frontend-platform/logging'),
logError: jest.fn(),
}));
const mockJsonData = [
{ date: '2024-01-01', count: 10, enroll_type: 'verified' },
{ date: '2024-01-02', count: 20, enroll_type: 'certificate' },
{ date: '2024-01-03', count: 30, enroll_type: 'verified' },
{ date: '2024-01-04', count: 40, enroll_type: 'audit' },
{ date: '2024-01-05', count: 50, enroll_type: 'verified' },
{ date: '2024-01-06', count: 60, enroll_type: 'verified' },
{ date: '2024-01-07', count: 70, enroll_type: 'certificate' },
{ date: '2024-01-08', count: 80, enroll_type: 'verified' },
{ date: '2024-01-09', count: 90, enroll_type: 'certificate' },
{ date: '2024-01-10', count: 100, enroll_type: 'certificate' },
];
let mockJsonAsCSV = 'date,count,enroll_type\n';
for (let i = 0; i < mockJsonData.length; i++) {
mockJsonAsCSV += `${mockJsonData[i].date},${mockJsonData[i].count},${mockJsonData[i].enroll_type}\n`;
}

const DEFAULT_PROPS = {
jsonData: mockJsonData,
csvFileName: 'completions.csv',
};
describe('DownloadCSVButton', () => {
const flushPromises = () => new Promise(setImmediate);

it('renders download csv button correctly', async () => {
render(
<IntlProvider locale="en">
<DownloadCSVButton {...DEFAULT_PROPS} />
</IntlProvider>,
);

expect(screen.getByTestId('plotly-charts-download-csv-button')).toBeInTheDocument();
});

it('handles successful CSV download', async () => {
render(
<IntlProvider locale="en">
<DownloadCSVButton {...DEFAULT_PROPS} />
</IntlProvider>,
);

// Click the download button.
userEvent.click(screen.getByTestId('plotly-charts-download-csv-button'));
await flushPromises();

expect(saveAs).toHaveBeenCalledWith(
new Blob([mockJsonAsCSV], { type: 'text/csv' }),
'completions.csv',
);
});
});
28 changes: 3 additions & 25 deletions src/components/AdvanceAnalyticsV2/Header.jsx
Original file line number Diff line number Diff line change
@@ -1,50 +1,28 @@
import React from 'react';
import PropTypes from 'prop-types';
import DownloadCSV from './DownloadCSV';

const Header = ({
title, subtitle, startDate, endDate, isDownloadCSV, activeTab, granularity, calculation, chartType, enterpriseId,
title, subtitle, DownloadCSVComponent,
}) => (
<div className="analytics-header d-flex justify-content-between row">
<div className="col-8">
<h2 className="analytics-header-title">{title}</h2>
{subtitle && <p className="analytics-header-subtitle">{subtitle}</p>}
</div>
{isDownloadCSV && (
<div className="col-3 mr-0">
<DownloadCSV
enterpriseId={enterpriseId}
startDate={startDate}
endDate={endDate}
activeTab={activeTab}
granularity={granularity}
calculation={calculation}
chartType={chartType}
/>
{DownloadCSVComponent}
</div>
)}
</div>
);

Header.defaultProps = {
subtitle: undefined,
isDownloadCSV: false,
granularity: 'Daily',
calculation: 'Total',
chartType: '',
};

Header.propTypes = {
title: PropTypes.string.isRequired,
subtitle: PropTypes.string,
isDownloadCSV: PropTypes.bool,
startDate: PropTypes.string.isRequired,
endDate: PropTypes.string.isRequired,
activeTab: PropTypes.string.isRequired,
enterpriseId: PropTypes.string.isRequired,
chartType: PropTypes.string,
granularity: PropTypes.string,
calculation: PropTypes.string,
DownloadCSVComponent: PropTypes.element.isRequired,
};

export default Header;
2 changes: 1 addition & 1 deletion src/components/AdvanceAnalyticsV2/charts/ChartWrapper.jsx
Original file line number Diff line number Diff line change
Expand Up @@ -42,7 +42,7 @@ ChartWrapper.propTypes = {
isFetching: PropTypes.bool.isRequired,
isError: PropTypes.bool.isRequired,
chartType: PropTypes.oneOf(['ScatterChart', 'LineChart', 'BarChart']).isRequired,
chartProps: PropTypes.object.isRequired,
chartProps: PropTypes.shape({ data: PropTypes.shape({}) }).isRequired,
loadingMessage: PropTypes.string.isRequired,
};

Expand Down
16 changes: 15 additions & 1 deletion src/components/AdvanceAnalyticsV2/data/constants.js
Original file line number Diff line number Diff line change
Expand Up @@ -43,7 +43,7 @@ export const analyticsDataTableKeys = {

const analyticsDefaultKeys = ['admin-analytics'];

const generateKey = (key, enterpriseUUID, requestOptions) => [
export const generateKey = (key, enterpriseUUID, requestOptions) => [
...analyticsDefaultKeys,
key,
enterpriseUUID,
Expand Down Expand Up @@ -100,3 +100,17 @@ export const skillsTypeColorMap = {
};

export const chartColorMap = { certificate: '#3669C9', audit: '#06262B' };

export const GRANULARITY = {
DAILY: 'day',
WEEKLY: 'week',
MONTHLY: 'month',
QUARTERLY: 'quarter',
YEARLY: 'year',
};
export const CALCULATION = {
TOTAL: 'total',
RUNNING_TOTAL: 'running-total',
MOVING_AVERAGE_3_PERIODS: 'moving-average-3-period',
MOVING_AVERAGE_7_PERIODS: 'moving-average-7-period',
};
Original file line number Diff line number Diff line change
@@ -1,8 +1,10 @@
import { useMemo } from 'react';
import { useQuery } from '@tanstack/react-query';

import { advanceAnalyticsQueryKeys } from './constants';
import EnterpriseDataApiService from '../../../data/services/EnterpriseDataApiService';
import { advanceAnalyticsQueryKeys } from '../constants';
import EnterpriseDataApiService from '../../../../data/services/EnterpriseDataApiService';

export { default as useEnterpriseEnrollmentsData } from './useEnterpriseEnrollmentsData';

export const useEnterpriseAnalyticsData = ({
enterpriseCustomerUUID,
Expand All @@ -12,10 +14,11 @@ export const useEnterpriseAnalyticsData = ({
granularity = undefined,
calculation = undefined,
currentPage = undefined,
pageSize = undefined,
queryOptions = {},
}) => {
const requestOptions = {
startDate, endDate, granularity, calculation, page: currentPage,
startDate, endDate, granularity, calculation, page: currentPage, pageSize,
};
return useQuery({
queryKey: advanceAnalyticsQueryKeys[key](enterpriseCustomerUUID, requestOptions),
Expand Down
Loading

0 comments on commit 7bbffbe

Please sign in to comment.