diff --git a/src/components/learner-credit-management/data/hooks/useEnterpriseGroupMembersTableData.js b/src/components/learner-credit-management/data/hooks/useEnterpriseGroupMembersTableData.js index 33074a5a19..0afd433e10 100644 --- a/src/components/learner-credit-management/data/hooks/useEnterpriseGroupMembersTableData.js +++ b/src/components/learner-credit-management/data/hooks/useEnterpriseGroupMembersTableData.js @@ -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, @@ -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, }); @@ -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), @@ -66,8 +66,6 @@ const useEnterpriseGroupMembersTableData = ({ groupId, refresh }) => { return { isLoading, - showRemoved, - handleSwitchChange, enterpriseGroupMembersTableData, fetchEnterpriseGroupMembersTableData: debouncedFetchEnterpriseGroupMembersData, }; diff --git a/src/components/learner-credit-management/data/utils.js b/src/components/learner-credit-management/data/utils.js index fca740d7fe..55071c12a5 100644 --- a/src/components/learner-credit-management/data/utils.js +++ b/src/components/learner-credit-management/data/utils.js @@ -107,6 +107,7 @@ export const transformGroupMembersTableResults = results => results.map(result = status: result.status, recentAction: result.recentAction, memberEnrollments: result.memberEnrollments, + enrollmentCount: result.enrollmentCount, })); /** diff --git a/src/components/learner-credit-management/members-tab/BudgetDetailMembersTabContents.jsx b/src/components/learner-credit-management/members-tab/BudgetDetailMembersTabContents.jsx index df9fb9d184..c044cf54cf 100644 --- a/src/components/learner-credit-management/members-tab/BudgetDetailMembersTabContents.jsx +++ b/src/components/learner-credit-management/members-tab/BudgetDetailMembersTabContents.jsx @@ -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'; @@ -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, }); @@ -31,14 +29,6 @@ const BudgetDetailMembersTabContents = ({ enterpriseUUID, refresh, setRefresh }) Members choose what to learn from the catalog and spend from the budget to enroll.

- - Show removed - { + 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 ( + <> + setAlertModalOpen(false)} + footerNode={( + + + + )} + > +

+ We're sorry but something went wrong while downloading your CSV. + Please refer to the error below and try again later. +

+

{alertModalExc}

+
+ + + ); +}; + +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; diff --git a/src/components/learner-credit-management/members-tab/LearnerCreditGroupMembersTable.jsx b/src/components/learner-credit-management/members-tab/LearnerCreditGroupMembersTable.jsx index 6a51b77fe3..c524b12b0a 100644 --- a/src/components/learner-credit-management/members-tab/LearnerCreditGroupMembersTable.jsx +++ b/src/components/learner-credit-management/members-tab/LearnerCreditGroupMembersTable.jsx @@ -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) => ; @@ -75,6 +77,8 @@ const LearnerCreditGroupMembersTable = ({ isLoading={isLoading} defaultColumnValues={{ Filter: TableTextFilter }} FilterStatusComponent={FilterStatus} + numBreakoutFilters={2} + tableActions={[]} columns={[ { Header: 'Member Details', @@ -85,7 +89,8 @@ const LearnerCreditGroupMembersTable = ({ Header: MemberStatusTableColumnHeader, accessor: 'status', Cell: MemberStatusTableCell, - disableFilters: true, + Filter: MembersTableSwitchFilter, + filter: 'status', }, { Header: 'Recent action', @@ -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, diff --git a/src/components/learner-credit-management/members-tab/MembersTableSwitchFilter.jsx b/src/components/learner-credit-management/members-tab/MembersTableSwitchFilter.jsx new file mode 100644 index 0000000000..31e29278a8 --- /dev/null +++ b/src/components/learner-credit-management/members-tab/MembersTableSwitchFilter.jsx @@ -0,0 +1,32 @@ +import { Form } from '@edx/paragon'; +import PropTypes from 'prop-types'; + +const MembersTableSwitchFilter = ({ column: { filterValue, setFilter } }) => ( + { + setFilter(!filterValue || false); // Set undefined to remove the filter entirely + }} + data-testid="show-removed-toggle" + > + Show removed + +); + +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; diff --git a/src/components/learner-credit-management/members-tab/tests/MembersTab.test.jsx b/src/components/learner-credit-management/members-tab/tests/MembersTab.test.jsx index 66964da452..7386c510d8 100644 --- a/src/components/learner-credit-management/members-tab/tests/MembersTab.test.jsx +++ b/src/components/learner-credit-management/members-tab/tests/MembersTab.test.jsx @@ -26,6 +26,7 @@ import { } from '../../data/tests/constants'; import { queryClient } from '../../../test/testUtils'; import LmsApiService from '../../../../data/services/LmsApiService'; +import EnterpriseAccessApiService from '../../../../data/services/EnterpriseAccessApiService'; jest.mock('@edx/frontend-enterprise-utils', () => ({ ...jest.requireActual('@edx/frontend-enterprise-utils'), @@ -54,6 +55,11 @@ jest.mock('../../data', () => ({ jest.mock('../../../../data/services/EnterpriseAccessApiService'); jest.mock('../../../../data/services/LmsApiService'); +jest.mock('file-saver', () => ({ + ...jest.requireActual('react-router-dom'), + saveAs: jest.fn(), +})); + const mockStore = configureMockStore([thunk]); const getMockStore = store => mockStore(store); const enterpriseSlug = 'test-enterprise'; @@ -250,7 +256,7 @@ describe('', () => { memberDetails: { userEmail: 'foobar@test.com', userName: 'ayy lmao' }, status: 'pending', recentAction: 'Pending: April 02, 2024', - memberEnrollments: 0, + enrollmentCount: 0, }], }, fetchEnterpriseGroupMembersTableData: jest.fn(), @@ -308,36 +314,49 @@ describe('', () => { memberDetails: { userEmail: 'foobar@test.com', userName: 'ayy lmao' }, status: 'pending', recentAction: 'Pending: April 02, 2024', - memberEnrollments: 0, + enrollmentCount: 0, }], }, fetchEnterpriseGroupMembersTableData: mockFetchEnterpriseGroupMembersTableData, }); renderWithRouter(); - userEvent.click(screen.getByTestId('members-table-status-column-header')); + await userEvent.type(screen.getByText('Search by member details'), 'foobar'); await waitFor(() => expect(mockFetchEnterpriseGroupMembersTableData).toHaveBeenCalledWith({ - filters: [], + filters: [{ id: 'memberDetails', value: 'foobar' }], pageIndex: 0, pageSize: 10, - sortBy: [{ desc: false, id: 'status' }], + sortBy: [{ desc: true, id: 'memberDetails' }], })); - userEvent.click(screen.getByTestId('members-table-enrollments-column-header')); + userEvent.click(screen.getByTestId('members-table-status-column-header')); await waitFor(() => expect(mockFetchEnterpriseGroupMembersTableData).toHaveBeenCalledWith({ - filters: [], + filters: [{ id: 'memberDetails', value: 'foobar' }], pageIndex: 0, pageSize: 10, - sortBy: [{ desc: false, id: 'memberEnrollment' }], + sortBy: [{ desc: false, id: 'status' }], })); - userEvent.type(screen.getByText('Search by member details'), 'foobar'); + const removeToggle = screen.getByTestId('show-removed-toggle'); + userEvent.click(removeToggle); await waitFor(() => expect(mockFetchEnterpriseGroupMembersTableData).toHaveBeenCalledWith({ - filters: [{ id: 'memberDetails', value: 'foobar' }], + filters: [ + { id: 'memberDetails', value: 'foobar' }, + { id: 'status', value: true }, + ], pageIndex: 0, pageSize: 10, - sortBy: [{ desc: false, id: 'memberEnrollment' }], + sortBy: [{ desc: false, id: 'status' }], })); + + // TODO Sorting by enrollment count is currently not supported by the backend + // userEvent.click(screen.getByTestId('members-table-enrollments-column-header')); + // await waitFor(() => expect(mockFetchEnterpriseGroupMembersTableData).toHaveBeenCalledWith({ + // filters: [], + // pageIndex: 0, + // pageSize: 10, + // sortBy: [{ desc: false, id: 'enrollmentCount' }], + // })); }); it('remove learner flow', async () => { const initialState = { @@ -388,7 +407,7 @@ describe('', () => { memberDetails: { userEmail: 'dukesilver@test.com', userName: 'duke silver' }, status: 'pending', recentAction: 'Pending: April 02, 2024', - memberEnrollments: 0, + enrollmentCount: 0, }], }, fetchEnterpriseGroupMembersTableData: jest.fn(), @@ -464,13 +483,13 @@ describe('', () => { memberDetails: { userEmail: 'dukesilver@test.com', userName: 'duke silver' }, status: 'pending', recentAction: 'Pending: April 02, 2024', - memberEnrollments: 0, + enrollmentCount: 0, }, { memberDetails: { userEmail: 'tammy2@test.com', userName: 'tammy 2' }, status: 'pending', recentAction: 'Pending: April 02, 2024', - memberEnrollments: 0, + enrollmentCount: 0, }], }, fetchEnterpriseGroupMembersTableData: jest.fn(), @@ -543,7 +562,7 @@ describe('', () => { memberDetails: { userEmail: 'dukesilver@test.com', userName: 'duke silver' }, status: 'pending', recentAction: 'Pending: April 02, 2024', - memberEnrollments: 0, + enrollmentCount: 0, }], }, fetchEnterpriseGroupMembersTableData: jest.fn(), @@ -612,7 +631,7 @@ describe('', () => { memberDetails: { userEmail: 'dukesilver@test.com', userName: 'duke silver' }, status: 'pending', recentAction: 'Pending: April 02, 2024', - memberEnrollments: 0, + enrollmentCount: 0, }], }, fetchEnterpriseGroupMembersTableData: jest.fn(), @@ -633,7 +652,7 @@ describe('', () => { await waitForElementToBeRemoved(() => screen.queryByText('Removing (1)')); await waitFor(() => expect(screen.queryByText('There was an error with your request. Please try again.')).toBeInTheDocument()); }); - it('Remove toggle shows removed members', async () => { + it('displays members download button that makes requests to fetch member data with queries', async () => { const initialState = { portalConfiguration: { ...initialStoreState.portalConfiguration, @@ -646,6 +665,7 @@ describe('', () => { enterpriseSlug: 'test-enterprise-slug', enterpriseAppPage: 'test-enterprise-page', activeTabKey: 'members', + budgetId: mockAssignableSubsidyAccessPolicy.uuid, }); useSubsidyAccessPolicy.mockReturnValue({ isInitialLoading: false, @@ -660,53 +680,55 @@ describe('', () => { budgetRedemptions: mockEmptyBudgetRedemptions, fetchBudgetRedemptions: jest.fn(), }); - useEnterpriseGroupLearners.mockReturnValueOnce({ + useEnterpriseGroupLearners.mockReturnValue({ data: { count: 1, currentPage: 1, next: null, numPages: 1, - results: [ - { - enterpriseGroupMembershipUuid: 'cde2e374-032f-4c08-8c0d-bf3205fa7c7e', - learnerId: 4382, - memberDetails: { userEmail: 'dukesilver@test.com', userName: 'duke silver' }, - status: 'pending', - }, - { - enterpriseGroupMembershipUuid: 'cde2e374-032f-4c08-8c0d-bf3205fa7c7d', - learnerId: 4382, - memberDetails: { userEmail: 'tammy2@example.com', userName: 'tammy 2' }, - status: 'removed', - }, - ], + results: { + enterpriseGroupMembershipUuid: 'cde2e374-032f-4c08-8c0d-bf3205fa7c7e', + learnerId: 4382, + memberDetails: { userEmail: 'foobar@test.com', userName: 'ayy lmao' }, + }, }, }); - useEnterpriseGroupMembersTableData.mockReturnValue({ + const mockFetchEnterpriseGroupMembersTableData = jest.fn(); + const mockGroupData = { isLoading: false, - showRemoved: true, enterpriseGroupMembersTableData: { - itemCount: 2, + itemCount: 1, pageCount: 1, results: [{ - memberDetails: { userEmail: 'dukesilver@test.com', userName: 'duke silver' }, + memberDetails: { userEmail: 'foobar@test.com', userName: 'ayy lmao' }, status: 'pending', recentAction: 'Pending: April 02, 2024', - memberEnrollments: 0, - }, { - memberDetails: { userEmail: 'tammy2@example.com', userName: 'tammy 2' }, - status: 'removed', - recentAction: 'Removed: April 02, 2024', - memberEnrollments: 0, + enrollmentCount: 1, }], }, - fetchEnterpriseGroupMembersTableData: jest.fn(), - }); - // when we pass in showRemoved=true, we should see removed members + fetchEnterpriseGroupMembersTableData: mockFetchEnterpriseGroupMembersTableData, + }; + useEnterpriseGroupMembersTableData.mockReturnValue(mockGroupData); + EnterpriseAccessApiService.fetchSubsidyHydratedGroupMembersData.mockResolvedValue('a,b,c,\nd,e,f'); renderWithRouter(); + userEvent.type(screen.getByText('Search by member details'), 'foobar'); + userEvent.click(screen.getByTestId('members-table-enrollments-column-header')); const removeToggle = screen.getByTestId('show-removed-toggle'); - expect(removeToggle).toHaveAttribute('checked', ''); - expect(screen.queryByText('Former member')).toBeInTheDocument(); - expect(screen.queryByText('tammy2@example.com')).toBeInTheDocument(); + userEvent.click(removeToggle); + + const downloadButton = screen.getByText('Download all (1)'); + expect(downloadButton).toBeInTheDocument(); + userEvent.click(downloadButton); + expect(EnterpriseAccessApiService.fetchSubsidyHydratedGroupMembersData).toHaveBeenCalledWith( + mockAssignableSubsidyAccessPolicy.uuid, + { + format_csv: true, + traverse_pagination: true, + group_uuid: mockAssignableSubsidyAccessPolicy.groupAssociations[0], + user_query: 'foobar', + sort_by: 'member_details', + show_removed: true, + }, + ); }); }); diff --git a/src/components/learner-credit-management/tests/BudgetDetailPage.test.jsx b/src/components/learner-credit-management/tests/BudgetDetailPage.test.jsx index abc3042c4c..aa61bc92d0 100644 --- a/src/components/learner-credit-management/tests/BudgetDetailPage.test.jsx +++ b/src/components/learner-credit-management/tests/BudgetDetailPage.test.jsx @@ -695,10 +695,10 @@ describe('', () => { const spentSection = within(screen.getByTestId('spent-section')); expect(spentSection.getByText('No results found')).toBeInTheDocument(); expect(spentSection.getByText('Spent activity is driven by completed enrollments.', { exact: false })).toBeInTheDocument(); - const isSubsidyAccessPolicyWithAnalyicsApi = ( + const isSubsidyAccessPolicyWithAnalyticsApi = ( budgetId === mockSubsidyAccessPolicyUUID && !isTopDownAssignmentEnabled ); - if (budgetId === mockEnterpriseOfferId || isSubsidyAccessPolicyWithAnalyicsApi) { + if (budgetId === mockEnterpriseOfferId || isSubsidyAccessPolicyWithAnalyticsApi) { // This copy is only present when the "Spent" table is backed by the // analytics API (i.e., budget is an enterprise offer or a subsidy access // policy with the LC2 feature flag disabled). diff --git a/src/data/services/EnterpriseAccessApiService.js b/src/data/services/EnterpriseAccessApiService.js index 84f4bab6ce..fe58d1078e 100644 --- a/src/data/services/EnterpriseAccessApiService.js +++ b/src/data/services/EnterpriseAccessApiService.js @@ -253,6 +253,12 @@ class EnterpriseAccessApiService { const url = `${EnterpriseAccessApiService.baseUrl}/policy-allocation/${subsidyAccessPolicyUUID}/allocate/`; return EnterpriseAccessApiService.apiClient().post(url, payload); } + + static fetchSubsidyHydratedGroupMembersData(subsidyAccessPolicyUUID, options) { + const queryParams = new URLSearchParams(options); + const subsidyHydratedGroupLearnersEndpoint = `${EnterpriseAccessApiService.baseUrl}/subsidy-access-policies/${subsidyAccessPolicyUUID}/group-members?${queryParams.toString()}`; + return EnterpriseAccessApiService.apiClient().get(subsidyHydratedGroupLearnersEndpoint); + } } export default EnterpriseAccessApiService;