Skip to content

Commit

Permalink
feat: Add download csv button to people management
Browse files Browse the repository at this point in the history
test: Add DownloadCsvButton unit test
  • Loading branch information
marlonkeating committed Dec 20, 2024
1 parent 383989c commit 2524094
Show file tree
Hide file tree
Showing 6 changed files with 297 additions and 1 deletion.
113 changes: 113 additions & 0 deletions src/components/PeopleManagement/DownloadCSVButton.jsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,113 @@
import React, { useState, useEffect } from 'react';
import PropTypes from 'prop-types';
import { useIntl } from '@edx/frontend-platform/i18n';

import {
Toast, StatefulButton, Icon, Spinner, useToggle,
} from '@openedx/paragon';
import { Download, Check } from '@openedx/paragon/icons';
import { logError } from '@edx/frontend-platform/logging';
import { downloadCsv } from '../../utils';

const csvHeaders = ['Name', 'Email', 'Joined Organization', 'Enrollments'];

const dataEntryToRow = (entry) => {
const { enterpriseCustomerUser: { name, email, joinedOrg }, enrollments } = entry;
return [name, email, joinedOrg, enrollments];

Check warning on line 16 in src/components/PeopleManagement/DownloadCSVButton.jsx

View check run for this annotation

Codecov / codecov/patch

src/components/PeopleManagement/DownloadCSVButton.jsx#L15-L16

Added lines #L15 - L16 were not covered by tests
};

const getCsvFileName = () => {
const padTwoZeros = (num) => num.toString().padStart(2, '0');
const currentDate = new Date();
const year = currentDate.getUTCFullYear();
const month = padTwoZeros(currentDate.getUTCMonth() + 1);
const day = padTwoZeros(currentDate.getUTCDate());
return `${year}-${month}-${day}-people-report.csv`;
};

const DownloadCsvButton = ({ testId, fetchData, totalCt }) => {
const [buttonState, setButtonState] = useState('pageLoading');
const [isOpen, open, close] = useToggle(false);
const intl = useIntl();

useEffect(() => {
if (fetchData) {
setButtonState('default');
}
}, [fetchData]);

const handleClick = async () => {
setButtonState('pending');
fetchData().then((response) => {
downloadCsv(getCsvFileName(), response.results, csvHeaders, dataEntryToRow);
open();
setButtonState('complete');
}).catch((err) => {
logError(err);
});
};

const toastText = intl.formatMessage({
id: 'adminPortal.peopleManagement.dataTable.download.toast',
defaultMessage: 'Successfully downloaded',
description: 'Toast message for the people management download button.',
});
return (
<>
{ isOpen
&& (
<Toast onClose={close} show={isOpen}>
{toastText}
</Toast>
)}
<StatefulButton
state={buttonState}
className="download-button"
data-testid={testId}
labels={{
default: intl.formatMessage({
id: 'adminPortal.peopleManagement.dataTable.download.button',
defaultMessage: `Download all (${totalCt})`,
description: 'Label for the people management download button',
}),
pending: intl.formatMessage({
id: 'adminPortal.peopleManagement.dataTable.download.button.pending',
defaultMessage: 'Downloading',
description: 'Label for the people management download button when the download is in progress.',
}),
complete: intl.formatMessage({
id: 'adminPortal.peopleManagement.dataTable.download.button.complete',
defaultMessage: 'Downloaded',
description: 'Label for the people management download button when the download is complete.',
}),
pageLoading: intl.formatMessage({
id: 'adminPortal.peopleManagement.dataTable.download.button.loading',
defaultMessage: 'Download module activity',
description: 'Label for the people management download button when the page is loading.',
}),
}}
icons={{
default: <Icon src={Download} />,
pending: <Spinner animation="border" variant="light" size="sm" />,
complete: <Icon src={Check} />,
pageLoading: <Icon src={Download} variant="light" />,
}}
disabledStates={['pending', 'pageLoading']}
onClick={handleClick}
/>
</>
);
};

DownloadCsvButton.defaultProps = {
testId: 'download-csv-button',
};

DownloadCsvButton.propTypes = {
// eslint-disable-next-line react/forbid-prop-types
fetchData: PropTypes.func.isRequired,
totalCt: PropTypes.number,
testId: PropTypes.string,
};

export default DownloadCsvButton;
10 changes: 9 additions & 1 deletion src/components/PeopleManagement/PeopleManagementTable.jsx
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@ import TableTextFilter from '../learner-credit-management/TableTextFilter';
import CustomDataTableEmptyState from '../learner-credit-management/CustomDataTableEmptyState';
import OrgMemberCard from './OrgMemberCard';
import useEnterpriseMembersTableData from './data/hooks/useEnterpriseMembersTableData';
import DownloadCsvButton from './DownloadCSVButton';

const FilterStatus = (rest) => <DataTable.FilterStatus showFilteredFields={false} {...rest} />;

Expand All @@ -15,10 +16,10 @@ const PeopleManagementTable = ({ enterpriseId }) => {
isLoading: isTableLoading,
enterpriseMembersTableData,
fetchEnterpriseMembersTableData,
fetchAllEnterpriseMembersData,
} = useEnterpriseMembersTableData({ enterpriseId });

const tableColumns = [{ Header: 'Name', accessor: 'name' }];

return (
<DataTable
isSortable
Expand All @@ -45,6 +46,13 @@ const PeopleManagementTable = ({ enterpriseId }) => {
itemCount={enterpriseMembersTableData.itemCount}
pageCount={enterpriseMembersTableData.pageCount}
EmptyTableComponent={CustomDataTableEmptyState}
tableActions={[
<DownloadCsvButton
fetchData={fetchAllEnterpriseMembersData}
totalCt={enterpriseMembersTableData.itemCount}
testId="people-report-download"
/>,
]}
>
<DataTable.TableControlBar />
<CardView
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,15 @@ const useEnterpriseMembersTableData = ({ enterpriseId }) => {
pageCount: 0,
results: [],
});

const fetchAllEnterpriseMembersData = useCallback(async () => {
const { options, itemCount } = enterpriseMembersTableData;

Check warning on line 19 in src/components/PeopleManagement/data/hooks/useEnterpriseMembersTableData.js

View check run for this annotation

Codecov / codecov/patch

src/components/PeopleManagement/data/hooks/useEnterpriseMembersTableData.js#L19

Added line #L19 was not covered by tests
// Take the existing filters but specify we're taking all results on one page
const fetchAllOptions = { ...options, page: 1, page_size: itemCount };
const response = await LmsApiService.fetchEnterpriseCustomerMembers(enterpriseId, fetchAllOptions);
return camelCaseObject(response.data);

Check warning on line 23 in src/components/PeopleManagement/data/hooks/useEnterpriseMembersTableData.js

View check run for this annotation

Codecov / codecov/patch

src/components/PeopleManagement/data/hooks/useEnterpriseMembersTableData.js#L21-L23

Added lines #L21 - L23 were not covered by tests
}, [enterpriseId, enterpriseMembersTableData]);

const fetchEnterpriseMembersData = useCallback((args) => {
const fetch = async () => {
try {
Expand All @@ -33,6 +42,7 @@ const useEnterpriseMembersTableData = ({ enterpriseId }) => {
itemCount: data.count,
pageCount: data.numPages ?? Math.floor(data.count / options.pageSize),
results: data.results,
options,
});
} catch (error) {
logError(error);
Expand All @@ -56,6 +66,7 @@ const useEnterpriseMembersTableData = ({ enterpriseId }) => {
isLoading,
enterpriseMembersTableData,
fetchEnterpriseMembersTableData: debouncedFetchEnterpriseMembersData,
fetchAllEnterpriseMembersData,
};
};

Expand Down
99 changes: 99 additions & 0 deletions src/components/PeopleManagement/tests/DownloadCsvButton.test.jsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,99 @@
import React from 'react';
import { IntlProvider } from '@edx/frontend-platform/i18n';
import { logError } from '@edx/frontend-platform/logging';
import { act, render, screen } from '@testing-library/react';

import '@testing-library/jest-dom/extend-expect';

import userEvent from '@testing-library/user-event';
import DownloadCsvButton from '../DownloadCSVButton';
import { downloadCsv } from '../../../utils';

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

jest.mock('../../../utils', () => ({
downloadCsv: jest.fn(),
}));

jest.mock('@edx/frontend-platform/logging', () => ({
...jest.requireActual('@edx/frontend-platform/logging'),
logError: jest.fn(),
}));

jest.useFakeTimers({ advanceTimers: true }).setSystemTime(new Date('2024-01-20'));

const mockData = {
results: [
{
enterprise_customer_user: {
email: 'a@letter.com',
joined_org: 'Apr 07, 2024',
name: 'A',
},
enrollments: 3,
},
{
enterprise_customer_user: {
email: 'b@letter.com',
joined_org: 'Apr 08, 2024',
name: 'B',
},
enrollments: 4,
},
],
};

const testId = 'test-id-1';
const DEFAULT_PROPS = {
totalCt: mockData.results.length,
fetchData: jest.fn(() => Promise.resolve(mockData)),
testId,
};

const DownloadCSVButtonWrapper = props => (
<IntlProvider locale="en">
<DownloadCsvButton {...props} />
</IntlProvider>
);

describe('DownloadCSVButton', () => {
const flushPromises = () => new Promise(setImmediate);

it('renders download csv button correctly.', async () => {
render(<DownloadCSVButtonWrapper {...DEFAULT_PROPS} />);
expect(screen.getByTestId(testId)).toBeInTheDocument();

// Validate button text
expect(screen.getByText('Download all (2)')).toBeInTheDocument();

// Click the download button.
screen.getByTestId(testId).click();
await flushPromises();

expect(DEFAULT_PROPS.fetchData).toHaveBeenCalled();
const expectedFileName = '2024-01-20-people-report.csv';
const expectedHeaders = ['Name', 'Email', 'Joined Organization', 'Enrollments'];
expect(downloadCsv).toHaveBeenCalledWith(expectedFileName, mockData.results, expectedHeaders, expect.any(Function));
});
it('download button should handle error returned by the API endpoint.', async () => {
const props = {
...DEFAULT_PROPS,
fetchData: jest.fn(() => Promise.reject(new Error('Error fetching data'))),
};
render(<DownloadCSVButtonWrapper {...props} />);
expect(screen.getByTestId(testId)).toBeInTheDocument();

act(() => {
// Click the download button.
userEvent.click(screen.getByTestId(testId));
});

await flushPromises();

expect(DEFAULT_PROPS.fetchData).toHaveBeenCalled();
expect(logError).toHaveBeenCalled();
});
});
29 changes: 29 additions & 0 deletions src/utils.js
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
import dayjs from 'dayjs';
import { saveAs } from 'file-saver';
import camelCase from 'lodash/camelCase';
import snakeCase from 'lodash/snakeCase';
import isArray from 'lodash/isArray';
Expand Down Expand Up @@ -609,6 +610,33 @@ function isTodayBetweenDates({ startDate, endDate }) {
*/
const isFalsy = (value) => value == null || value === '';

/**
* Transform data to csv format and save to file
*
* @param {string} fileName
* Name of the file to save to
* @param {Array<object>} data
* Data to transform to csv format
* @param {Array<string>} headers
* Text headers for the file
* @param {(object) => Array<string|number>} dataEntryToRow
* Transform function, taking a single data entry and converting it to array of string or numeric values
* that will represent a row of data in the csv document
* Note: Enclosing quotes will be added to any string fields containing commas
*/
function downloadCsv(fileName, data, headers, dataEntryToRow) {
// If a cell in a csv document contains commas, we need to enclose cell in quotes
const escapeCommas = (cell) => (isString(cell) && cell.includes(',') ? `"${cell}"` : cell);
const generateCsvRow = (entry) => dataEntryToRow(entry).map(escapeCommas);

const body = data.map(generateCsvRow).join('\n');
const csvText = `${headers}\n${body}`;
const blob = new Blob([csvText], {
type: 'text/csv',
});
saveAs(blob, fileName);
}

export {
camelCaseDict,
camelCaseDictArray,
Expand Down Expand Up @@ -656,4 +684,5 @@ export {
isTodayWithinDateThreshold,
isTodayBetweenDates,
isFalsy,
downloadCsv,
};
36 changes: 36 additions & 0 deletions src/utils.test.js
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
import { logError } from '@edx/frontend-platform/logging';
import { createIntl } from '@edx/frontend-platform/i18n';
import { saveAs } from 'file-saver';

import {
camelCaseDict,
Expand All @@ -13,13 +14,21 @@ import {
queryCacheOnErrorHandler,
i18nFormatPassedTimestamp,
i18nFormatProgressStatus,
downloadCsv,
} from './utils';

jest.mock('@edx/frontend-platform/logging', () => ({
...jest.requireActual('@edx/frontend-platform/logging'),
logError: jest.fn(),
}));

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

global.Blob = jest.fn();

const intl = createIntl({
locale: 'en',
messages: {},
Expand Down Expand Up @@ -166,4 +175,31 @@ describe('utils', () => {
});
});
});
describe('downloadCsv', () => {
it('downloads properly formatted csv', () => {
const fileName = 'somefile.csv';
const data = [
{
a: 1, b: 2, c: 3, d: 4,
},
{
a: 'apple', b: 'banana', c: 'comma, please', d: 'donut',
},
];
const headers = ['a', 'b', 'c', 'd'];
const dataEntryToRow = (entry) => {
const changeItUp = (field) => (isValidNumber(field) ? field + 1 : field);
const {
a, b, c, d,
} = entry;
return [a, b, c, d].map(changeItUp);
};
downloadCsv(fileName, data, headers, dataEntryToRow);
const expectedBlob = ['a,b,c,d\n2,3,4,5\napple,banana,"comma, please",donut'];
expect(global.Blob).toHaveBeenCalledWith(expectedBlob, {
type: 'text/csv',
});
expect(saveAs).toHaveBeenCalledWith({}, fileName);
});
});
});

0 comments on commit 2524094

Please sign in to comment.