Skip to content

Commit

Permalink
Merge pull request #1206 from openedx/asheehan-edx/ENT-8562-members-d…
Browse files Browse the repository at this point in the history
…ownload-csv

feat: subsidy group members download csv functionality
  • Loading branch information
alex-sheehan-edx authored May 3, 2024
2 parents 3bf24df + 0f4ed2c commit c3fca43
Show file tree
Hide file tree
Showing 9 changed files with 270 additions and 83 deletions.
Original file line number Diff line number Diff line change
Expand Up @@ -6,13 +6,11 @@ import { camelCaseObject } from '@edx/frontend-platform/utils';
import { logError } from '@edx/frontend-platform/logging';
import debounce from 'lodash.debounce';

import LmsApiService from '../../../../data/services/LmsApiService';
import EnterpriseAccessApiService from '../../../../data/services/EnterpriseAccessApiService';
import { transformGroupMembersTableResults } from '../utils';

const useEnterpriseGroupMembersTableData = ({ groupId, refresh }) => {
const useEnterpriseGroupMembersTableData = ({ policyUuid, groupId, refresh }) => {
const [isLoading, setIsLoading] = useState(true);
const [showRemoved, setShowRemoved] = useState(false);
const handleSwitchChange = e => setShowRemoved(e.target.checked);
const [enterpriseGroupMembersTableData, setEnterpriseGroupMembersTableData] = useState({
itemCount: 0,
pageCount: 0,
Expand All @@ -22,28 +20,30 @@ const useEnterpriseGroupMembersTableData = ({ groupId, refresh }) => {
const fetch = async () => {
try {
setIsLoading(true);
const options = {};
if (args?.filters.length > 0) {
options.user_query = args.filters[0].value;
}
const options = { group_uuid: groupId };
if (args?.sortBy.length > 0) {
const sortByValue = args.sortBy[0].id;
options.sort_by = _.snakeCase(sortByValue);
if (!args.sortBy[0].desc) {
options.is_reversed = args.sortBy[0].desc;
options.is_reversed = !args.sortBy[0].desc;
}
} if (showRemoved) {
options.show_removed = true;
}
args.filters.forEach((filter) => {
const { id, value } = filter;
if (id === 'status') {
options.show_removed = value;
} else if (id === 'memberDetails') {
options.user_query = value;
}
});

options.page = args.pageIndex + 1;
const response = await LmsApiService.fetchEnterpriseGroupLearners(groupId, options);
const response = await EnterpriseAccessApiService.fetchSubsidyHydratedGroupMembersData(policyUuid, options);
const data = camelCaseObject(response.data);
const transformedTableResults = transformGroupMembersTableResults(data.results);

setEnterpriseGroupMembersTableData({
itemCount: data.count,
// If the data comes from the subsidy transactions endpoint, the number of pages is calculated
// TODO: https://2u-internal.atlassian.net/browse/ENT-8106
pageCount: data.numPages ?? Math.floor(data.count / options.pageSize),
results: transformedTableResults,
});
Expand All @@ -53,10 +53,10 @@ const useEnterpriseGroupMembersTableData = ({ groupId, refresh }) => {
setIsLoading(false);
}
};
if (groupId) {
if (policyUuid) {
fetch();
}
}, [groupId, showRemoved]);
}, [groupId, policyUuid]);

const debouncedFetchEnterpriseGroupMembersData = useMemo(
() => debounce(fetchEnterpriseGroupMembersData, 300),
Expand All @@ -66,8 +66,6 @@ const useEnterpriseGroupMembersTableData = ({ groupId, refresh }) => {

return {
isLoading,
showRemoved,
handleSwitchChange,
enterpriseGroupMembersTableData,
fetchEnterpriseGroupMembersTableData: debouncedFetchEnterpriseGroupMembersData,
};
Expand Down
1 change: 1 addition & 0 deletions src/components/learner-credit-management/data/utils.js
Original file line number Diff line number Diff line change
Expand Up @@ -107,6 +107,7 @@ export const transformGroupMembersTableResults = results => results.map(result =
status: result.status,
recentAction: result.recentAction,
memberEnrollments: result.memberEnrollments,
enrollmentCount: result.enrollmentCount,
}));

/**
Expand Down
Original file line number Diff line number Diff line change
@@ -1,7 +1,6 @@
import React from 'react';
import PropTypes from 'prop-types';
import { connect } from 'react-redux';
import { Form } from '@edx/paragon';

import LearnerCreditGroupMembersTable from './LearnerCreditGroupMembersTable';
import { useEnterpriseGroupMembersTableData, useBudgetId, useSubsidyAccessPolicy } from '../data';
Expand All @@ -12,13 +11,12 @@ const BudgetDetailMembersTabContents = ({ enterpriseUUID, refresh, setRefresh })
const groupId = subsidyAccessPolicy.groupAssociations[0];
const {
isLoading,
showRemoved,
handleSwitchChange,
enterpriseGroupMembersTableData,
fetchEnterpriseGroupMembersTableData,
} = useEnterpriseGroupMembersTableData({
enterpriseUUID,
subsidyAccessPolicyId,
policyUuid: subsidyAccessPolicy.uuid,
groupId,
refresh,
});
Expand All @@ -31,14 +29,6 @@ const BudgetDetailMembersTabContents = ({ enterpriseUUID, refresh, setRefresh })
Members choose what to learn from the catalog and spend from the budget to enroll.
</p>
</div>
<Form.Switch
className="ml-2.5"
checked={showRemoved}
onChange={handleSwitchChange}
data-testid="show-removed-toggle"
>
Show removed
</Form.Switch>
<LearnerCreditGroupMembersTable
isLoading={isLoading}
tableData={enterpriseGroupMembersTableData}
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,132 @@
import React, { useState } from 'react';
import PropTypes from 'prop-types';
import { ActionRow, AlertModal, Button } from '@edx/paragon';
import { Download } from '@edx/paragon/icons';
import { logError } from '@edx/frontend-platform/logging';
import snakeCase from 'lodash/snakeCase';
import { saveAs } from 'file-saver';
import EnterpriseAccessApiService from '../../../data/services/EnterpriseAccessApiService';
import { useBudgetId, useSubsidyAccessPolicy } from '../data';

const GroupMembersCsvDownloadTableAction = ({
tableInstance,
}) => {
const [alertModalOpen, setAlertModalOpen] = useState(false);
const [alertModalExc, setAlertModalException] = useState('');
const { subsidyAccessPolicyId } = useBudgetId();
const { data: subsidyAccessPolicy } = useSubsidyAccessPolicy(subsidyAccessPolicyId);
const groupId = subsidyAccessPolicy.groupAssociations[0];

const getCsvFileName = () => {
const titleNoWhitespace = subsidyAccessPolicy.displayName.replace(/\s+/g, '');
const currentDate = new Date();
const year = currentDate.getUTCFullYear();
const month = currentDate.getUTCMonth() + 1;
const day = currentDate.getUTCDate();
return `${titleNoWhitespace}-${year}-${month}-${day}.csv`;
};

const csvDownloadOnClick = () => {
const options = {
format_csv: true,
traverse_pagination: true,
group_uuid: groupId,
};
// Apply the table state to the request args
// sortBy can support multiple values, the members table will only ever have one applied
// so we can grab the data from the first index should it exist
if (tableInstance.state.sortBy[0]) {
options.sort_by = snakeCase(tableInstance.state.sortBy[0].id);
// IFF we're doing sorting, check if it's in reverse order
if (!tableInstance.state.sortBy[0].desc) {
options.is_reversed = !tableInstance.state.sortBy[0].desc;
}
}
tableInstance.state.filters.forEach((filter) => {
if (filter.id === 'status') {
options.show_removed = filter.value;
} else if (filter.id === 'memberDetails') {
options.user_query = snakeCase(filter.value);
}
});

EnterpriseAccessApiService.fetchSubsidyHydratedGroupMembersData(
subsidyAccessPolicyId,
options,
).then(response => {
// download CSV
const blob = new Blob([response.data], {
type: 'text/csv',
});
saveAs(blob, getCsvFileName());
}).catch(err => {
logError(err);
setAlertModalOpen(true);
setAlertModalException(err.message);
});
};

return (
<>
<AlertModal
title="Something went wrong"
isOpen={alertModalOpen}
onClose={() => setAlertModalOpen(false)}
footerNode={(
<ActionRow>
<Button
variant="tertiary"
onClick={() => setAlertModalOpen(false)}
>
Close
</Button>
</ActionRow>
)}
>
<p>
We&apos;re sorry but something went wrong while downloading your CSV.
Please refer to the error below and try again later.
</p>
<p>{alertModalExc}</p>
</AlertModal>
<Button
onClick={csvDownloadOnClick}
iconBefore={Download}
variant="inverse-primary"
className="border rounded-0 border-dark-500"
disabled={tableInstance.itemCount === 0}
>
Download all ({tableInstance.itemCount})
</Button>
</>
);
};

GroupMembersCsvDownloadTableAction.propTypes = {
tableInstance: PropTypes.shape({
itemCount: PropTypes.number,
state: PropTypes.shape({
filters: PropTypes.arrayOf(PropTypes.shape({
id: PropTypes.string,
// Can be a string for user queries or bool for show removed toggle
value: PropTypes.oneOfType([
PropTypes.string,
PropTypes.bool,
]),
})),
sortBy: PropTypes.arrayOf(PropTypes.shape({
id: PropTypes.string,
desc: PropTypes.bool,
})),
}),
}),
};

GroupMembersCsvDownloadTableAction.defaultProps = {
tableInstance: {
itemCount: 0,
state: {},
},
};

export default GroupMembersCsvDownloadTableAction;
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,8 @@ import MemberRemoveAction from './bulk-actions/MemberRemoveAction';
import MemberRemoveModal from './bulk-actions/MemberRemoveModal';
import { DEFAULT_PAGE, MEMBERS_TABLE_PAGE_SIZE } from '../data';
import useRemoveMember from '../data/hooks/useRemoveMember';
import GroupMembersCsvDownloadTableAction from './GroupMembersCsvDownloadTableAction';
import MembersTableSwitchFilter from './MembersTableSwitchFilter';

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

Expand Down Expand Up @@ -75,6 +77,8 @@ const LearnerCreditGroupMembersTable = ({
isLoading={isLoading}
defaultColumnValues={{ Filter: TableTextFilter }}
FilterStatusComponent={FilterStatus}
numBreakoutFilters={2}
tableActions={[<GroupMembersCsvDownloadTableAction />]}
columns={[
{
Header: 'Member Details',
Expand All @@ -85,7 +89,8 @@ const LearnerCreditGroupMembersTable = ({
Header: MemberStatusTableColumnHeader,
accessor: 'status',
Cell: MemberStatusTableCell,
disableFilters: true,
Filter: MembersTableSwitchFilter,
filter: 'status',
},
{
Header: 'Recent action',
Expand All @@ -95,14 +100,15 @@ const LearnerCreditGroupMembersTable = ({
},
{
Header: MemberEnrollmentsTableColumnHeader,
accessor: 'memberEnrollment',
// TODO:
Cell: () => ('0'),
accessor: 'enrollmentCount',
Cell: ({ row }) => row.original.enrollmentCount,
disableFilters: true,
disableSortBy: true,
},
]}
initialTableOptions={{
getRowId: row => row?.memberDetails.userEmail,
autoResetPage: true,
}}
initialState={{
pageSize: MEMBERS_TABLE_PAGE_SIZE,
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,32 @@
import { Form } from '@edx/paragon';
import PropTypes from 'prop-types';

const MembersTableSwitchFilter = ({ column: { filterValue, setFilter } }) => (
<Form.Switch
className="ml-2.5 mt-2.5"
checked={filterValue || false}
onChange={() => {
setFilter(!filterValue || false); // Set undefined to remove the filter entirely
}}
data-testid="show-removed-toggle"
>
Show removed
</Form.Switch>
);

MembersTableSwitchFilter.propTypes = {
/**
* Specifies a column object.
*
* `setFilter`: Function to set the filter value.
*
* `filterValue`: Value for the filter input.
*/
column: PropTypes.shape({
setFilter: PropTypes.func.isRequired,
Header: PropTypes.oneOfType([PropTypes.elementType, PropTypes.node]).isRequired,
filterValue: PropTypes.bool,
}).isRequired,
};

export default MembersTableSwitchFilter;
Loading

0 comments on commit c3fca43

Please sign in to comment.