diff --git a/src/components/PeopleManagement/CreateGroupModal.jsx b/src/components/PeopleManagement/CreateGroupModal.jsx index ecf7e9aae3..212053809a 100644 --- a/src/components/PeopleManagement/CreateGroupModal.jsx +++ b/src/components/PeopleManagement/CreateGroupModal.jsx @@ -11,7 +11,7 @@ import { import LmsApiService from '../../data/services/LmsApiService'; import SystemErrorAlertModal from '../learner-credit-management/cards/assignment-allocation-status-modals/SystemErrorAlertModal'; import CreateGroupModalContent from './CreateGroupModalContent'; -import { learnerCreditManagementQueryKeys } from '../learner-credit-management/data'; +import { peopleManagementQueryKeys } from './constants'; const CreateGroupModal = ({ isModalOpen, @@ -53,7 +53,7 @@ const CreateGroupModal = ({ }); await LmsApiService.inviteEnterpriseLearnersToGroup(groupCreationResponse.data.uuid, requestBody); queryClient.invalidateQueries({ - queryKey: learnerCreditManagementQueryKeys.group(enterpriseUUID), + queryKey: peopleManagementQueryKeys.group(enterpriseUUID), }); setCreateButtonState('complete'); handleCloseCreateGroupModal(); diff --git a/src/components/PeopleManagement/EnterpriseCustomerUserDatatable.jsx b/src/components/PeopleManagement/EnterpriseCustomerUserDatatable.jsx index ff2f93bd76..32b517baea 100644 --- a/src/components/PeopleManagement/EnterpriseCustomerUserDatatable.jsx +++ b/src/components/PeopleManagement/EnterpriseCustomerUserDatatable.jsx @@ -7,7 +7,7 @@ import { } from '@openedx/paragon'; import { GROUP_MEMBERS_TABLE_DEFAULT_PAGE, GROUP_MEMBERS_TABLE_PAGE_SIZE } from './constants'; import MemberDetailsCell from './MemberDetailsCell'; -import AddMembersBulkAction from './AddMembersBulkAction'; +import AddMembersBulkAction from './GroupDetailPage/AddMembersBulkAction'; import RemoveMembersBulkAction from './RemoveMembersBulkAction'; import MemberJoinedDateCell from './MemberJoinedDateCell'; import { useEnterpriseMembersTableData } from './data/hooks'; diff --git a/src/components/PeopleManagement/AddMemberTableAction.jsx b/src/components/PeopleManagement/GroupDetailPage/AddMemberTableAction.jsx similarity index 68% rename from src/components/PeopleManagement/AddMemberTableAction.jsx rename to src/components/PeopleManagement/GroupDetailPage/AddMemberTableAction.jsx index ffbce51dd3..6e846598f0 100644 --- a/src/components/PeopleManagement/AddMemberTableAction.jsx +++ b/src/components/PeopleManagement/GroupDetailPage/AddMemberTableAction.jsx @@ -3,7 +3,13 @@ import { Add } from '@openedx/paragon/icons'; import PropTypes from 'prop-types'; const AddMemberTableAction = ({ openModal }) => ( - + ); AddMemberTableAction.propTypes = { diff --git a/src/components/PeopleManagement/AddMembersBulkAction.jsx b/src/components/PeopleManagement/GroupDetailPage/AddMembersBulkAction.jsx similarity index 94% rename from src/components/PeopleManagement/AddMembersBulkAction.jsx rename to src/components/PeopleManagement/GroupDetailPage/AddMembersBulkAction.jsx index 33ed1e4f84..cd1d6a4d95 100644 --- a/src/components/PeopleManagement/AddMembersBulkAction.jsx +++ b/src/components/PeopleManagement/GroupDetailPage/AddMembersBulkAction.jsx @@ -1,8 +1,8 @@ import PropTypes from 'prop-types'; import { StatefulButton } from '@openedx/paragon'; import { useIntl } from '@edx/frontend-platform/i18n'; -import { useGetAllEnterpriseLearnerEmails } from './data/hooks/useEnterpriseLearnersTableData'; -import { getSelectedEmailsByRow } from './utils'; +import { useGetAllEnterpriseLearnerEmails } from '../data/hooks/useEnterpriseLearnersTableData'; +import { getSelectedEmailsByRow } from '../utils'; const AddMembersBulkAction = ({ isEntireTableSelected, diff --git a/src/components/PeopleManagement/GroupDetailPage/DownloadCsvIconButton.jsx b/src/components/PeopleManagement/GroupDetailPage/DownloadCsvIconButton.jsx new file mode 100644 index 0000000000..57bc5b7893 --- /dev/null +++ b/src/components/PeopleManagement/GroupDetailPage/DownloadCsvIconButton.jsx @@ -0,0 +1,83 @@ +import React from 'react'; +import PropTypes from 'prop-types'; +import { defineMessages, useIntl } from '@edx/frontend-platform/i18n'; + +import { + Icon, IconButtonWithTooltip, Toast, useToggle, +} from '@openedx/paragon'; +import { Download } from '@openedx/paragon/icons'; +import { logError } from '@edx/frontend-platform/logging'; +import GeneralErrorModal from '../GeneralErrorModal'; +import { downloadCsv, getTimeStampedFilename } from '../../../utils'; + +const csvHeaders = ['Name', 'Email', 'Recent action', 'Enrollments']; + +const DownloadCsvIconButton = ({ fetchAllData, dataCount, testId }) => { + const [isToastOpen, openToast, closeToast] = useToggle(false); + const [isErrorModalOpen, openErrorModal, closeErrorModal] = useToggle(false); + const intl = useIntl(); + const messages = defineMessages({ + downloadToastText: { + id: 'adminPortal.peopleManagement.groupDetail.downloadCsv.toast', + defaultMessage: 'Downloaded group members', + description: 'Toast message for the download button on the group detail page.', + }, + downloadHoverText: { + id: 'adminPortal.peopleManagement.groupDetail.downloadCsv.hoverTooltip', + defaultMessage: `Download (${dataCount})`, + description: 'Tooltip message for the download button on the group detail page.', + }, + }); + + const dataEntryToRow = (entry) => { + const { memberDetails: { userEmail, userName }, recentAction, enrollments } = entry; + return [userName, userEmail, recentAction, enrollments]; + }; + + const handleClick = async () => { + fetchAllData().then((response) => { + const fileName = getTimeStampedFilename('group-report.csv'); + downloadCsv(fileName, response.results, csvHeaders, dataEntryToRow); + openToast(); + }).catch((err) => { + logError(err); + openErrorModal(); + }); + }; + + return ( + <> + { isToastOpen + && ( + + {intl.formatMessage(messages.downloadToastText)} + + )} + + + + ); +}; + +DownloadCsvIconButton.defaultProps = { + testId: 'download-csv-icon-button', +}; + +DownloadCsvIconButton.propTypes = { + fetchAllData: PropTypes.func.isRequired, + dataCount: PropTypes.number.isRequired, + testId: PropTypes.string, +}; + +export default DownloadCsvIconButton; diff --git a/src/components/PeopleManagement/GroupDetailPage/GroupDetailPage.jsx b/src/components/PeopleManagement/GroupDetailPage/GroupDetailPage.jsx index 97449859a6..8da3181588 100644 --- a/src/components/PeopleManagement/GroupDetailPage/GroupDetailPage.jsx +++ b/src/components/PeopleManagement/GroupDetailPage/GroupDetailPage.jsx @@ -11,7 +11,7 @@ import { ROUTE_NAMES } from '../../EnterpriseApp/data/constants'; import DeleteGroupModal from './DeleteGroupModal'; import EditGroupNameModal from './EditGroupNameModal'; import formatDates from '../utils'; -import GroupMembersTable from '../GroupMembersTable'; +import GroupMembersTable from './GroupMembersTable'; import AddMembersModal from '../AddMembersModal/AddMembersModal'; const GroupDetailPage = () => { @@ -27,6 +27,9 @@ const GroupDetailPage = () => { isLoading: isTableLoading, enterpriseGroupLearnersTableData, fetchEnterpriseGroupLearnersTableData, + fetchAllEnterpriseGroupLearnersData, + refresh, + setRefresh, } = useEnterpriseGroupLearnersTableData({ groupUuid, isAddMembersModalOpen }); const handleNameUpdate = (name) => { setGroupName(name); @@ -108,7 +111,6 @@ const GroupDetailPage = () => { { isLoading={isTableLoading} tableData={enterpriseGroupLearnersTableData} fetchTableData={fetchEnterpriseGroupLearnersTableData} + fetchAllData={fetchAllEnterpriseGroupLearnersData} + dataCount={enterpriseGroupLearnersTableData.itemCount} groupUuid={groupUuid} + refresh={refresh} + setRefresh={setRefresh} openAddMembersModal={openAddMembersModal} /> ; -const KabobMenu = () => ( - - - - - - { + const [isRemoveModalOpen, openRemoveModal, closeRemoveModal] = useToggle(false); + const [isErrorModalOpen, openErrorModal, closeErrorModal] = useToggle(false); + return ( + <> + + + + - - - -); + + + + + + + + + ); +}; + +KabobMenu.propTypes = { + row: PropTypes.shape({}).isRequired, + groupUuid: PropTypes.string.isRequired, + refresh: PropTypes.bool.isRequired, + setRefresh: PropTypes.func.isRequired, +}; const selectColumn = { id: 'selection', @@ -49,7 +84,11 @@ const GroupMembersTable = ({ isLoading, tableData, fetchTableData, + fetchAllData, + dataCount, groupUuid, + refresh, + setRefresh, openAddMembersModal, }) => { const intl = useIntl(); @@ -69,9 +108,6 @@ const GroupMembersTable = ({ defaultColumnValues={{ Filter: TableTextFilter }} FilterStatusComponent={FilterStatus} numBreakoutFilters={2} - tableActions={[ - , - ]} columns={[ { Header: intl.formatMessage({ @@ -106,9 +142,7 @@ const GroupMembersTable = ({ initialState={{ pageSize: GROUP_MEMBERS_TABLE_PAGE_SIZE, pageIndex: GROUP_MEMBERS_TABLE_DEFAULT_PAGE, - sortBy: [ - { id: 'memberDetails', desc: true }, - ], + sortBy: [{ id: 'memberDetails', desc: true }], filters: [], }} additionalColumns={[ @@ -117,10 +151,23 @@ const GroupMembersTable = ({ Header: '', // eslint-disable-next-line react/no-unstable-nested-components Cell: (props) => ( - + ), }, ]} + tableActions={[ + , + , + ]} fetchData={fetchTableData} data={tableData.results} itemCount={tableData.itemCount} @@ -134,13 +181,16 @@ const GroupMembersTable = ({ GroupMembersTable.propTypes = { isLoading: PropTypes.bool.isRequired, tableData: PropTypes.shape({ - results: PropTypes.arrayOf(PropTypes.shape({ - })), + results: PropTypes.arrayOf(PropTypes.shape({})), itemCount: PropTypes.number.isRequired, pageCount: PropTypes.number.isRequired, }).isRequired, fetchTableData: PropTypes.func.isRequired, + fetchAllData: PropTypes.func.isRequired, + dataCount: PropTypes.number.isRequired, groupUuid: PropTypes.string.isRequired, + refresh: PropTypes.bool.isRequired, + setRefresh: PropTypes.func.isRequired, openAddMembersModal: PropTypes.func.isRequired, }; diff --git a/src/components/PeopleManagement/GroupDetailPage/RemoveMemberModal.jsx b/src/components/PeopleManagement/GroupDetailPage/RemoveMemberModal.jsx new file mode 100644 index 0000000000..7ed1d09463 --- /dev/null +++ b/src/components/PeopleManagement/GroupDetailPage/RemoveMemberModal.jsx @@ -0,0 +1,88 @@ +import { FormattedMessage } from '@edx/frontend-platform/i18n'; +import PropTypes from 'prop-types'; +import { + ActionRow, Button, ModalDialog, +} from '@openedx/paragon'; +import { RemoveCircle } from '@openedx/paragon/icons'; +import { logError } from '@edx/frontend-platform/logging'; + +import LmsApiService from '../../../data/services/LmsApiService'; + +const RemoveMemberModal = ({ + groupUuid, row, isOpen, close, openError, refresh, setRefresh, +}) => { + const removeEnterpriseGroupMember = async () => { + try { + const rowEmail = row.id; + const formData = new FormData(); + formData.append('learner_emails', rowEmail); + await LmsApiService.removeEnterpriseLearnersFromGroup(groupUuid, formData); + setRefresh(!refresh); + close(); + } catch (error) { + close(); + logError(error); + openError(); + } + }; + return ( + + + + Remove member? + + + +

+ +

+

+ +

+
+ + + + + Go back + + + + +
+ ); +}; + +RemoveMemberModal.propTypes = { + groupUuid: PropTypes.string.isRequired, + row: PropTypes.shape({ + id: PropTypes.string.isRequired, + }).isRequired, + isOpen: PropTypes.bool.isRequired, + close: PropTypes.func.isRequired, + openError: PropTypes.func.isRequired, + refresh: PropTypes.bool.isRequired, + setRefresh: PropTypes.func.isRequired, +}; + +export default RemoveMemberModal; diff --git a/src/components/PeopleManagement/constants.js b/src/components/PeopleManagement/constants.js index 5c9802d610..08fddb3637 100644 --- a/src/components/PeopleManagement/constants.js +++ b/src/components/PeopleManagement/constants.js @@ -12,7 +12,9 @@ export const GROUP_MEMBERS_TABLE_DEFAULT_PAGE = 0; // `DataTable` uses zero-inde // Inspired by https://tkdodo.eu/blog/effective-react-query-keys#use-query-key-factories. export const peopleManagementQueryKeys = { all: ['people-management'], + group: (groupUuid) => [...peopleManagementQueryKeys.all, 'group', groupUuid], members: (enterpriseUuid) => [...peopleManagementQueryKeys.all, 'members', enterpriseUuid], + removeMember: (groupUuid) => [...peopleManagementQueryKeys.all, 'removeMember', groupUuid], }; export const MAX_INITIAL_LEARNER_EMAILS_DISPLAYED_COUNT = 15; diff --git a/src/components/PeopleManagement/data/hooks/useEnterpriseGroupLearnersTableData.js b/src/components/PeopleManagement/data/hooks/useEnterpriseGroupLearnersTableData.js index d3161ef3fd..d04805dc02 100644 --- a/src/components/PeopleManagement/data/hooks/useEnterpriseGroupLearnersTableData.js +++ b/src/components/PeopleManagement/data/hooks/useEnterpriseGroupLearnersTableData.js @@ -10,6 +10,7 @@ import LmsApiService from '../../../../data/services/LmsApiService'; const useEnterpriseGroupLearnersTableData = ({ groupUuid, isAddMembersModalOpen }) => { const [isLoading, setIsLoading] = useState(true); + const [refresh, setRefresh] = useState(false); const [enterpriseGroupLearnersTableData, setEnterpriseGroupLearnersTableData] = useState({ itemCount: 0, pageCount: 0, @@ -44,6 +45,7 @@ const useEnterpriseGroupLearnersTableData = ({ groupUuid, isAddMembersModalOpen itemCount: data.count, pageCount: data.numPages ?? Math.floor(data.count / options.pageSize), results: data.results.filter(result => result.activatedAt), + options, }); } catch (error) { logError(error); @@ -57,14 +59,24 @@ const useEnterpriseGroupLearnersTableData = ({ groupUuid, isAddMembersModalOpen const debouncedFetchEnterpriseGroupLearnersData = useMemo( () => debounce(fetchEnterpriseGroupLearnersData, 300), - [fetchEnterpriseGroupLearnersData], + // eslint-disable-next-line react-hooks/exhaustive-deps + [fetchEnterpriseGroupLearnersData, refresh], ); + const fetchAllEnterpriseGroupLearnersData = useCallback(async () => { + const { options, itemCount } = enterpriseGroupLearnersTableData; + const fetchAllOptions = { ...options, page: 1, page_size: itemCount }; + const response = await LmsApiService.fetchEnterpriseGroupLearners(groupUuid, fetchAllOptions); + return camelCaseObject(response.data); + }, [groupUuid, enterpriseGroupLearnersTableData]); + return { isLoading, enterpriseGroupLearnersTableData, fetchEnterpriseGroupLearnersTableData: debouncedFetchEnterpriseGroupLearnersData, + fetchAllEnterpriseGroupLearnersData, + refresh, + setRefresh, }; }; - export default useEnterpriseGroupLearnersTableData; diff --git a/src/components/PeopleManagement/data/hooks/useEnterpriseGroupUuid.js b/src/components/PeopleManagement/data/hooks/useEnterpriseGroupUuid.js index a8e97e495f..a7956c84e5 100644 --- a/src/components/PeopleManagement/data/hooks/useEnterpriseGroupUuid.js +++ b/src/components/PeopleManagement/data/hooks/useEnterpriseGroupUuid.js @@ -1,8 +1,8 @@ import { useQuery } from '@tanstack/react-query'; import { camelCaseObject } from '@edx/frontend-platform/utils'; -import { learnerCreditManagementQueryKeys } from '../../../learner-credit-management/data/constants'; import LmsApiService from '../../../../data/services/LmsApiService'; +import { peopleManagementQueryKeys } from '../../constants'; /** * Retrieves an enterprise group by the group UUID from the API. @@ -17,7 +17,7 @@ const getEnterpriseGroupUuid = async ({ groupUuid }) => { }; const useEnterpriseGroupUuid = (groupUuid, { queryOptions } = {}) => useQuery({ - queryKey: learnerCreditManagementQueryKeys.group(groupUuid), + queryKey: peopleManagementQueryKeys.group(groupUuid), queryFn: () => getEnterpriseGroupUuid({ groupUuid }), ...queryOptions, }); diff --git a/src/components/PeopleManagement/tests/DownloadCsvIconButton.test.jsx b/src/components/PeopleManagement/tests/DownloadCsvIconButton.test.jsx new file mode 100644 index 0000000000..f5fc01800d --- /dev/null +++ b/src/components/PeopleManagement/tests/DownloadCsvIconButton.test.jsx @@ -0,0 +1,106 @@ +import React from 'react'; +import { IntlProvider } from '@edx/frontend-platform/i18n'; +import { logError } from '@edx/frontend-platform/logging'; +import { + act, fireEvent, render, screen, waitFor, +} from '@testing-library/react'; + +import '@testing-library/jest-dom/extend-expect'; + +import userEvent from '@testing-library/user-event'; +import DownloadCsvIconButton from '../GroupDetailPage/DownloadCsvIconButton'; +import { downloadCsv } from '../../../utils'; + +jest.mock('file-saver', () => ({ + ...jest.requireActual('file-saver'), + saveAs: jest.fn(), +})); + +jest.mock('../../../utils', () => ({ + downloadCsv: jest.fn(), + getTimeStampedFilename: (suffix) => `2024-04-20-${suffix}`, +})); + +jest.mock('@edx/frontend-platform/logging', () => ({ + ...jest.requireActual('@edx/frontend-platform/logging'), + logError: jest.fn(), +})); + +const mockData = { + results: [ + { + memberDetails: { + userEmail: 'ga-linda@oz.com', + userName: 'Galinda Upland', + }, + recentAction: 'Accepted: January 06, 2021', + enrollments: 0, + }, + { + memberDetails: { + userEmail: 'elphaba@oz.com', + userName: 'Elphaba Throppe', + }, + recentAction: 'Accepted: January 05, 2021', + enrollments: 3, + }, + ], +}; + +const testId = 'test-id-1'; +const DEFAULT_PROPS = { + fetchAllData: jest.fn(() => Promise.resolve(mockData)), + dataCount: mockData.results.length, + testId, +}; + +const DownloadCsvIconButtonWrapper = props => ( + + + +); + +describe('DownloadCsvIconButton', () => { + const flushPromises = () => new Promise(setImmediate); + + it('renders download csv button correctly.', async () => { + render(); + const downloadIcon = screen.getByTestId(testId); + expect(downloadIcon).toBeInTheDocument(); + + // show download tooltip + act(() => { + fireEvent.mouseOver(downloadIcon); + }); + await waitFor(() => { + expect(screen.getByText('Download (2)')).toBeInTheDocument(); + }); + + // Click the download button + screen.getByTestId(testId).click(); + await flushPromises(); + + expect(DEFAULT_PROPS.fetchAllData).toHaveBeenCalled(); + const expectedFileName = '2024-04-20-group-report.csv'; + const expectedHeaders = ['Name', 'Email', 'Recent action', '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, + fetchAllData: jest.fn(() => Promise.reject(new Error('Error fetching data'))), + }; + render(); + const downloadIcon = screen.getByTestId(testId); + + expect(downloadIcon).toBeInTheDocument(); + + act(() => { + userEvent.click(downloadIcon); + }); + await flushPromises(); + + expect(DEFAULT_PROPS.fetchAllData).toHaveBeenCalled(); + expect(logError).toHaveBeenCalled(); + }); +}); diff --git a/src/components/PeopleManagement/tests/GroupDetailPage.test.jsx b/src/components/PeopleManagement/tests/GroupDetailPage.test.jsx index 9d6c3bd052..2145898477 100644 --- a/src/components/PeopleManagement/tests/GroupDetailPage.test.jsx +++ b/src/components/PeopleManagement/tests/GroupDetailPage.test.jsx @@ -158,9 +158,17 @@ describe('', () => { LmsApiService.removeEnterpriseGroup.mockResolvedValueOnce({ status: 204 }); render(); const deleteGroupIcon = screen.getByTestId('delete-group-icon'); + // Open tooltip + expect(screen.queryByRole('tooltip')).toBeNull(); + fireEvent.mouseOver(deleteGroupIcon); + await waitFor(() => { + expect(screen.queryByRole('tooltip')).not.toBeNull(); + expect(screen.getAllByText('Delete group')).toHaveLength(1); + }); deleteGroupIcon.click(); - expect(screen.getByText('Delete group')).toBeInTheDocument(); + // Open delete group modal + expect(screen.getByText('Delete group?')).toBeInTheDocument(); expect(screen.getByText('This action cannot be undone.')); const deleteGroupButton = screen.getByTestId('delete-group-button'); deleteGroupButton.click(); diff --git a/src/components/learner-credit-management/data/constants.js b/src/components/learner-credit-management/data/constants.js index 195b3e7b99..5e9d91abe6 100644 --- a/src/components/learner-credit-management/data/constants.js +++ b/src/components/learner-credit-management/data/constants.js @@ -121,10 +121,10 @@ export const learnerCreditManagementQueryKeys = { budgetEnterpriseOffer: (budgetId) => [...learnerCreditManagementQueryKeys.budget(budgetId), 'ecommerce'], budgetActivity: (budgetId) => [...learnerCreditManagementQueryKeys.budget(budgetId), 'activity'], budgetActivityOverview: (budgetId) => [...learnerCreditManagementQueryKeys.budgetActivity(budgetId), 'overview'], - group: (groupUuid) => [...learnerCreditManagementQueryKeys.all, 'group', groupUuid], budgetGroupLearners: (budgetId) => [...learnerCreditManagementQueryKeys.budget(budgetId), 'group learners'], enterpriseCustomer: (enterpriseId) => [...learnerCreditManagementQueryKeys.all, 'enterpriseCustomer', enterpriseId], flexGroup: (enterpriseId) => [...learnerCreditManagementQueryKeys.enterpriseCustomer(enterpriseId), 'flexGroup'], + group: (groupUuid) => [...learnerCreditManagementQueryKeys.all, 'group', groupUuid], catalog: (catalog) => [...learnerCreditManagementQueryKeys.all, 'catalog', catalog], catalogContainsContentItem: (catalogUuid, contentKey) => [ ...learnerCreditManagementQueryKeys.catalog(catalogUuid),