Skip to content

Commit

Permalink
feat: Add download csv button to people management
Browse files Browse the repository at this point in the history
  • Loading branch information
marlonkeating committed Dec 20, 2024
1 parent 383989c commit b15b211
Show file tree
Hide file tree
Showing 5 changed files with 194 additions and 1 deletion.
112 changes: 112 additions & 0 deletions src/components/PeopleManagement/DownloadCSVButton.jsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,112 @@
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 currentDate = new Date();
const year = currentDate.getUTCFullYear();
const month = currentDate.getUTCMonth() + 1;
const day = currentDate.getUTCDate();
return `${year}-${month}-${day}-people-report.csv`;

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

View check run for this annotation

Codecov / codecov/patch

src/components/PeopleManagement/DownloadCSVButton.jsx#L20-L24

Added lines #L20 - L24 were not covered by tests
};

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);

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

View check run for this annotation

Codecov / codecov/patch

src/components/PeopleManagement/DownloadCSVButton.jsx#L39-L45

Added lines #L39 - L45 were not covered by tests
});
};

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

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

View check run for this annotation

Codecov / codecov/patch

src/components/PeopleManagement/DownloadCSVButton.jsx#L58

Added line #L58 was not covered by tests
{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,14 @@ const useEnterpriseMembersTableData = ({ enterpriseId }) => {
pageCount: 0,
results: [],
});

const fetchAllEnterpriseMembersData = useCallback(async () => {
const { options, itemCount } = enterpriseMembersTableData;
const fetchAllOptions = { ...options, page: 1, page_size: itemCount };
const response = await LmsApiService.fetchEnterpriseCustomerMembers(enterpriseId, fetchAllOptions);
return camelCaseObject(response.data);

Check warning on line 22 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-L22

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

const fetchEnterpriseMembersData = useCallback((args) => {
const fetch = async () => {
try {
Expand All @@ -33,6 +41,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 +65,7 @@ const useEnterpriseMembersTableData = ({ enterpriseId }) => {
isLoading,
enterpriseMembersTableData,
fetchEnterpriseMembersTableData: debouncedFetchEnterpriseMembersData,
fetchAllEnterpriseMembersData,
};
};

Expand Down
27 changes: 27 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,31 @@ 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
* Note: Quotes will be added to any string fields containing commas
*/
function downloadCsv(fileName, data, headers, dataEntryToRow) {
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 +682,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 b15b211

Please sign in to comment.