From 252409450dab07dbe62720103e1761ca27b80550 Mon Sep 17 00:00:00 2001 From: Marlon Keating Date: Fri, 20 Dec 2024 15:38:47 +0000 Subject: [PATCH] feat: Add download csv button to people management test: Add DownloadCsvButton unit test --- .../PeopleManagement/DownloadCSVButton.jsx | 113 ++++++++++++++++++ .../PeopleManagementTable.jsx | 10 +- .../hooks/useEnterpriseMembersTableData.js | 11 ++ .../tests/DownloadCsvButton.test.jsx | 99 +++++++++++++++ src/utils.js | 29 +++++ src/utils.test.js | 36 ++++++ 6 files changed, 297 insertions(+), 1 deletion(-) create mode 100644 src/components/PeopleManagement/DownloadCSVButton.jsx create mode 100644 src/components/PeopleManagement/tests/DownloadCsvButton.test.jsx diff --git a/src/components/PeopleManagement/DownloadCSVButton.jsx b/src/components/PeopleManagement/DownloadCSVButton.jsx new file mode 100644 index 0000000000..fff0a6613b --- /dev/null +++ b/src/components/PeopleManagement/DownloadCSVButton.jsx @@ -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]; +}; + +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 + && ( + + {toastText} + + )} + , + pending: , + complete: , + pageLoading: , + }} + 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; diff --git a/src/components/PeopleManagement/PeopleManagementTable.jsx b/src/components/PeopleManagement/PeopleManagementTable.jsx index 62d0d5745b..3602e9b797 100644 --- a/src/components/PeopleManagement/PeopleManagementTable.jsx +++ b/src/components/PeopleManagement/PeopleManagementTable.jsx @@ -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) => ; @@ -15,10 +16,10 @@ const PeopleManagementTable = ({ enterpriseId }) => { isLoading: isTableLoading, enterpriseMembersTableData, fetchEnterpriseMembersTableData, + fetchAllEnterpriseMembersData, } = useEnterpriseMembersTableData({ enterpriseId }); const tableColumns = [{ Header: 'Name', accessor: 'name' }]; - return ( { itemCount={enterpriseMembersTableData.itemCount} pageCount={enterpriseMembersTableData.pageCount} EmptyTableComponent={CustomDataTableEmptyState} + tableActions={[ + , + ]} > { pageCount: 0, results: [], }); + + const fetchAllEnterpriseMembersData = useCallback(async () => { + const { options, itemCount } = enterpriseMembersTableData; + // 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); + }, [enterpriseId, enterpriseMembersTableData]); + const fetchEnterpriseMembersData = useCallback((args) => { const fetch = async () => { try { @@ -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); @@ -56,6 +66,7 @@ const useEnterpriseMembersTableData = ({ enterpriseId }) => { isLoading, enterpriseMembersTableData, fetchEnterpriseMembersTableData: debouncedFetchEnterpriseMembersData, + fetchAllEnterpriseMembersData, }; }; diff --git a/src/components/PeopleManagement/tests/DownloadCsvButton.test.jsx b/src/components/PeopleManagement/tests/DownloadCsvButton.test.jsx new file mode 100644 index 0000000000..16a33ef94f --- /dev/null +++ b/src/components/PeopleManagement/tests/DownloadCsvButton.test.jsx @@ -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 => ( + + + +); + +describe('DownloadCSVButton', () => { + const flushPromises = () => new Promise(setImmediate); + + it('renders download csv button correctly.', async () => { + render(); + 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(); + 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(); + }); +}); diff --git a/src/utils.js b/src/utils.js index fd034b75c4..857fe4b3ac 100644 --- a/src/utils.js +++ b/src/utils.js @@ -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'; @@ -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} data + * Data to transform to csv format + * @param {Array} headers + * Text headers for the file + * @param {(object) => Array} 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, @@ -656,4 +684,5 @@ export { isTodayWithinDateThreshold, isTodayBetweenDates, isFalsy, + downloadCsv, }; diff --git a/src/utils.test.js b/src/utils.test.js index 0a36bbe47f..0e84bb3b7a 100644 --- a/src/utils.test.js +++ b/src/utils.test.js @@ -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, @@ -13,6 +14,7 @@ import { queryCacheOnErrorHandler, i18nFormatPassedTimestamp, i18nFormatProgressStatus, + downloadCsv, } from './utils'; jest.mock('@edx/frontend-platform/logging', () => ({ @@ -20,6 +22,13 @@ jest.mock('@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: {}, @@ -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); + }); + }); });