From 35f8f190a8c5841f0c7d1fbaab5c59b7d32c8727 Mon Sep 17 00:00:00 2001 From: Katrina Nguyen Date: Wed, 4 Dec 2024 16:19:04 +0000 Subject: [PATCH 1/8] feat: creates a modal to add members --- .../PeopleManagement/AddGroupModalContent.jsx | 143 ++++++++++++++++++ .../PeopleManagement/AddMembersModal.jsx | 139 +++++++++++++++++ .../PeopleManagement/GroupDetailPage.jsx | 18 ++- 3 files changed, 297 insertions(+), 3 deletions(-) create mode 100644 src/components/PeopleManagement/AddGroupModalContent.jsx create mode 100644 src/components/PeopleManagement/AddMembersModal.jsx diff --git a/src/components/PeopleManagement/AddGroupModalContent.jsx b/src/components/PeopleManagement/AddGroupModalContent.jsx new file mode 100644 index 0000000000..81465c4741 --- /dev/null +++ b/src/components/PeopleManagement/AddGroupModalContent.jsx @@ -0,0 +1,143 @@ +import React, { + useCallback, useEffect, useMemo, useState, +} from 'react'; +import PropTypes from 'prop-types'; +import debounce from 'lodash.debounce'; +import { + Col, Container, Form, Row, +} from '@openedx/paragon'; +import { FormattedMessage } from '@edx/frontend-platform/i18n'; + +import InviteModalSummary from '../learner-credit-management/invite-modal/InviteModalSummary'; +import InviteSummaryCount from '../learner-credit-management/invite-modal/InviteSummaryCount'; +import FileUpload from '../learner-credit-management/invite-modal/FileUpload'; +import { EMAIL_ADDRESSES_INPUT_VALUE_DEBOUNCE_DELAY, isInviteEmailAddressesInputValueValid } from '../learner-credit-management/cards/data'; +import { MAX_LENGTH_GROUP_NAME } from './constants'; +import EnterpriseCustomerUserDatatable from '../learner-credit-management/invite-modal/EnterpriseCustomerUserDatatable'; +import { useEnterpriseLearners } from '../learner-credit-management/data'; + +const AddGroupModalContent = ({ + onEmailAddressesChange, + isGroupInvite, + enterpriseUUID, + groupName, +}) => { + const [learnerEmails, setLearnerEmails] = useState([]); + const [emailAddressesInputValue, setEmailAddressesInputValue] = useState(''); + const [memberInviteMetadata, setMemberInviteMetadata] = useState({ + isValidInput: null, + lowerCasedEmails: [], + duplicateEmails: [], + emailsNotInOrg: [], + }); + const { allEnterpriseLearners } = useEnterpriseLearners({ enterpriseUUID }); + + + const handleAddMembersBulkAction = useCallback((value) => { + if (!value) { + setLearnerEmails([]); + onEmailAddressesChange([]); + return; + } + setLearnerEmails(prev => [...prev, ...value]); + }, [onEmailAddressesChange]); + + const handleRemoveMembersBulkAction = useCallback((value) => { + if (!value) { + setLearnerEmails([]); + onEmailAddressesChange([]); + return; + } + setLearnerEmails(prev => prev.filter((el) => !value.includes(el))); + }, [onEmailAddressesChange]); + + const handleEmailAddressesChanged = useCallback((value) => { + if (!value) { + setLearnerEmails([]); + onEmailAddressesChange([]); + return; + } + // handles csv upload value and formats emails into an array of strings + const emails = value.split('\n').map((email) => email.trim()).filter((email) => email.length > 0); + setLearnerEmails(emails); + }, [onEmailAddressesChange]); + + const debouncedHandleEmailAddressesChanged = useMemo( + () => debounce(handleEmailAddressesChanged, EMAIL_ADDRESSES_INPUT_VALUE_DEBOUNCE_DELAY), + [handleEmailAddressesChanged], + ); + + useEffect(() => { + debouncedHandleEmailAddressesChanged(emailAddressesInputValue); + }, [emailAddressesInputValue, debouncedHandleEmailAddressesChanged]); + + // Validate the learner emails emails from user input whenever it changes + useEffect(() => { + const inviteMetadata = isInviteEmailAddressesInputValueValid({ + learnerEmails, + allEnterpriseLearners, + }); + setMemberInviteMetadata(inviteMetadata); + if (inviteMetadata.canInvite) { + onEmailAddressesChange(learnerEmails, { canInvite: true }); + } else { + onEmailAddressesChange([]); + } + }, [onEmailAddressesChange, learnerEmails, allEnterpriseLearners]); + + return ( + +

+ +

+ + +

Add new members to your group

+

Only members registered with your organization can be added to your group. Learn more

+

Group Name

+

{groupName}

+ + +
+ + +

Select group members

+

+ +

+ + + +

Details

+ + +
+ +
+ +
+ ); +}; + +AddGroupModalContent.propTypes = { + onEmailAddressesChange: PropTypes.func.isRequired, + isGroupInvite: PropTypes.bool, + enterpriseUUID: PropTypes.string.isRequired, +}; + +export default AddGroupModalContent; diff --git a/src/components/PeopleManagement/AddMembersModal.jsx b/src/components/PeopleManagement/AddMembersModal.jsx new file mode 100644 index 0000000000..639db12a59 --- /dev/null +++ b/src/components/PeopleManagement/AddMembersModal.jsx @@ -0,0 +1,139 @@ +import React, { useCallback, useState, useEffect } from 'react'; +import { logError } from '@edx/frontend-platform/logging'; +import PropTypes from 'prop-types'; +import { connect } from 'react-redux'; +import { useQueryClient } from '@tanstack/react-query'; +import { useIntl } from '@edx/frontend-platform/i18n'; +import { snakeCaseObject } from '@edx/frontend-platform/utils'; +import { + ActionRow, Button, FullscreenModal, StatefulButton, useToggle, +} from '@openedx/paragon'; +import LmsApiService from '../../data/services/LmsApiService'; +import SystemErrorAlertModal from '../learner-credit-management/cards/assignment-allocation-status-modals/SystemErrorAlertModal'; +import AddGroupModalContent from './AddGroupModalContent'; +import { learnerCreditManagementQueryKeys } from '../learner-credit-management/data'; + +const AddMembersModal = ({ + isModalOpen, + closeModal, + enterpriseUUID, + groupName, +}) => { + const intl = useIntl(); + const [learnerEmails, setLearnerEmails] = useState([]); + const [createButtonState, setCreateButtonState] = useState('default'); + const [canCreateGroup, setCanCreateGroup] = useState(false); + const [canInviteMembers, setCanInviteMembers] = useState(false); + const [isSystemErrorModalOpen, openSystemErrorModal, closeSystemErrorModal] = useToggle(false); + const handleCloseCreateGroupModal = () => { + closeModal(); + setCreateButtonState('default'); + }; + const queryClient = useQueryClient(); + + const handleCreateGroup = async () => { + setCreateButtonState('pending'); + const options = { + enterpriseUUID, + groupName, + }; + let groupCreationResponse; + + try { + groupCreationResponse = await LmsApiService.createEnterpriseGroup(options); + } catch (err) { + logError(err); + setCreateButtonState('error'); + openSystemErrorModal(); + } + + try { + const requestBody = snakeCaseObject({ + learnerEmails, + }); + await LmsApiService.inviteEnterpriseLearnersToGroup(groupCreationResponse.data.uuid, requestBody); + queryClient.invalidateQueries({ + queryKey: learnerCreditManagementQueryKeys.group(enterpriseUUID), + }); + setCreateButtonState('complete'); + handleCloseCreateGroupModal(); + } catch (err) { + logError(err); + setCreateButtonState('error'); + openSystemErrorModal(); + } + }; + + const handleEmailAddressesChange = useCallback(( + value, + { canInvite = false } = {}, + ) => { + setLearnerEmails(value); + setCanInviteMembers(canInvite); + }, []); + + useEffect(() => { + setCanCreateGroup(false); + if (canInviteMembers) { + setCanCreateGroup(true); + } + }, [canInviteMembers]); + + return ( + <> + + + + + + )} + > + + + + + ); +}; + +const mapStateToProps = state => ({ + enterpriseUUID: state.portalConfiguration.enterpriseId, +}); + +AddMembersModal.propTypes = { + enterpriseUUID: PropTypes.string.isRequired, + isModalOpen: PropTypes.bool.isRequired, + closeModal: PropTypes.func.isRequired, +}; + +export default connect(mapStateToProps)(AddMembersModal); diff --git a/src/components/PeopleManagement/GroupDetailPage.jsx b/src/components/PeopleManagement/GroupDetailPage.jsx index 9f02b81e93..b0a099280d 100644 --- a/src/components/PeopleManagement/GroupDetailPage.jsx +++ b/src/components/PeopleManagement/GroupDetailPage.jsx @@ -11,6 +11,7 @@ import { ROUTE_NAMES } from '../EnterpriseApp/data/constants'; import DeleteGroupModal from './DeleteGroupModal'; import EditGroupNameModal from './EditGroupNameModal'; import formatDates from './utils'; +import AddMembersModal from './AddMembersModal'; const GroupDetailPage = () => { const intl = useIntl(); @@ -20,7 +21,7 @@ const GroupDetailPage = () => { const [isEditModalOpen, openEditModal, closeEditModal] = useToggle(false); const [isLoading, setIsLoading] = useState(true); const [groupName, setGroupName] = useState(enterpriseGroup?.name); - + const [isAddMembersModalOpen, openAddMembersModal, closeAddMembersModal] = useToggle(false); const handleNameUpdate = (name) => { setGroupName(name); }; @@ -87,7 +88,7 @@ const GroupDetailPage = () => { data-testid="edit-modal-icon" /> - )} + )} subtitle={`${enterpriseGroup.acceptedMembersCount} accepted members`} /> @@ -116,10 +117,21 @@ const GroupDetailPage = () => { > View group progress + + - ) : } + ) : } ); }; From 368dfe242dffa71a19686e80c77084da1064ff84 Mon Sep 17 00:00:00 2001 From: Katrina Nguyen Date: Tue, 10 Dec 2024 17:00:21 +0000 Subject: [PATCH 2/8] fix: fixed failing tests --- .../PeopleManagement/tests/CreateGroupModal.test.jsx | 6 +++--- .../PeopleManagement/tests/GroupDetailPage.test.jsx | 10 +++++++++- 2 files changed, 12 insertions(+), 4 deletions(-) diff --git a/src/components/PeopleManagement/tests/CreateGroupModal.test.jsx b/src/components/PeopleManagement/tests/CreateGroupModal.test.jsx index 384b2917ed..2fc23d40c2 100644 --- a/src/components/PeopleManagement/tests/CreateGroupModal.test.jsx +++ b/src/components/PeopleManagement/tests/CreateGroupModal.test.jsx @@ -24,8 +24,8 @@ jest.mock('@tanstack/react-query', () => ({ useQueryClient: jest.fn(), })); jest.mock('../../../data/services/LmsApiService'); -jest.mock('../../learner-credit-management/data/hooks/useEnterpriseLearnersTableData', () => ({ - ...jest.requireActual('../../learner-credit-management/data/hooks/useEnterpriseLearnersTableData'), +jest.mock('../data/hooks/useEnterpriseLearnersTableData', () => ({ + ...jest.requireActual('../data/hooks/useEnterpriseLearnersTableData'), useEnterpriseLearnersTableData: jest.fn(), useGetAllEnterpriseLearnerEmails: jest.fn(), })); @@ -176,7 +176,7 @@ describe('', () => { }, { timeout: EMAIL_ADDRESSES_INPUT_VALUE_DEBOUNCE_DELAY + 1000 }); // testing interaction with adding members from the datatable - const membersCheckbox = screen.getAllByTitle('Toggle Row Selected'); + const membersCheckbox = screen.getAllByTitle('Toggle row selected'); userEvent.click(membersCheckbox[0]); userEvent.click(membersCheckbox[1]); const addMembersButton = screen.getByText('Add'); diff --git a/src/components/PeopleManagement/tests/GroupDetailPage.test.jsx b/src/components/PeopleManagement/tests/GroupDetailPage.test.jsx index d93a063440..9d6c3bd052 100644 --- a/src/components/PeopleManagement/tests/GroupDetailPage.test.jsx +++ b/src/components/PeopleManagement/tests/GroupDetailPage.test.jsx @@ -6,11 +6,13 @@ import thunk from 'redux-thunk'; import configureMockStore from 'redux-mock-store'; import { Provider } from 'react-redux'; import userEvent from '@testing-library/user-event'; +import { QueryClientProvider } from '@tanstack/react-query'; import { IntlProvider } from '@edx/frontend-platform/i18n'; import { useEnterpriseGroupUuid, useEnterpriseGroupLearnersTableData } from '../data/hooks'; import GroupDetailPage from '../GroupDetailPage/GroupDetailPage'; import LmsApiService from '../../../data/services/LmsApiService'; +import { queryClient } from '../../test/testUtils'; const TEST_ENTERPRISE_SLUG = 'test-enterprise'; const enterpriseUUID = '1234'; @@ -23,6 +25,10 @@ const TEST_GROUP = { const mockStore = configureMockStore([thunk]); const getMockStore = store => mockStore(store); +jest.mock('@tanstack/react-query', () => ({ + ...jest.requireActual('@tanstack/react-query'), + useQueryClient: jest.fn(), +})); jest.mock('../data/hooks', () => ({ ...jest.requireActual('../data/hooks'), useEnterpriseGroupUuid: jest.fn(), @@ -52,7 +58,9 @@ const GroupDetailPageWrapper = ({ return ( - + + + ); From 72362bf6968184c2aff2582fb22175b6a3099aaf Mon Sep 17 00:00:00 2001 From: Katrina Nguyen Date: Thu, 2 Jan 2025 21:08:23 +0000 Subject: [PATCH 3/8] chore: updates tests and organized files --- .../AddMemberModalSummaryDuplicate.jsx | 15 + .../AddMemberModalSummaryEmptyState.jsx | 10 + .../AddMemberModalSummaryErrorState.jsx | 15 + .../AddMemberModalSummaryLearnerList.jsx | 68 +++++ .../{ => AddMembersModal}/AddMembersModal.jsx | 11 +- .../AddMembersModalContent.jsx | 36 ++- .../AddMembersModalSummary.jsx | 86 ++++++ .../GroupDetailPage/GroupDetailPage.jsx | 2 +- src/components/PeopleManagement/constants.js | 2 + .../tests/AddMembersModal.test.jsx | 288 ++++++++++++++++++ src/components/PeopleManagement/utils.js | 11 + 11 files changed, 521 insertions(+), 23 deletions(-) create mode 100644 src/components/PeopleManagement/AddMembersModal/AddMemberModalSummaryDuplicate.jsx create mode 100644 src/components/PeopleManagement/AddMembersModal/AddMemberModalSummaryEmptyState.jsx create mode 100644 src/components/PeopleManagement/AddMembersModal/AddMemberModalSummaryErrorState.jsx create mode 100644 src/components/PeopleManagement/AddMembersModal/AddMemberModalSummaryLearnerList.jsx rename src/components/PeopleManagement/{ => AddMembersModal}/AddMembersModal.jsx (91%) rename src/components/PeopleManagement/{ => AddMembersModal}/AddMembersModalContent.jsx (80%) create mode 100644 src/components/PeopleManagement/AddMembersModal/AddMembersModalSummary.jsx create mode 100644 src/components/PeopleManagement/tests/AddMembersModal.test.jsx diff --git a/src/components/PeopleManagement/AddMembersModal/AddMemberModalSummaryDuplicate.jsx b/src/components/PeopleManagement/AddMembersModal/AddMemberModalSummaryDuplicate.jsx new file mode 100644 index 0000000000..29d355e9b0 --- /dev/null +++ b/src/components/PeopleManagement/AddMembersModal/AddMemberModalSummaryDuplicate.jsx @@ -0,0 +1,15 @@ +import React from 'react'; +import { Stack, Icon } from '@openedx/paragon'; +import { Error } from '@openedx/paragon/icons'; + +const AddMemberModalSummaryDuplicate = () => ( + + +
+
Only 1 invite per email address will be sent.
+ One or more duplicate emails were detected. Ensure that your entry is correct before proceeding. +
+
+); + +export default AddMemberModalSummaryDuplicate; diff --git a/src/components/PeopleManagement/AddMembersModal/AddMemberModalSummaryEmptyState.jsx b/src/components/PeopleManagement/AddMembersModal/AddMemberModalSummaryEmptyState.jsx new file mode 100644 index 0000000000..f9157bfab2 --- /dev/null +++ b/src/components/PeopleManagement/AddMembersModal/AddMemberModalSummaryEmptyState.jsx @@ -0,0 +1,10 @@ +import React from 'react'; + +const AddMemberModalSummaryEmptyState = () => ( + <> +
You haven't uploaded any members yet.
+ Upload a CSV file or select members to get started. + +); + +export default AddMemberModalSummaryEmptyState; diff --git a/src/components/PeopleManagement/AddMembersModal/AddMemberModalSummaryErrorState.jsx b/src/components/PeopleManagement/AddMembersModal/AddMemberModalSummaryErrorState.jsx new file mode 100644 index 0000000000..9ffc2256ed --- /dev/null +++ b/src/components/PeopleManagement/AddMembersModal/AddMemberModalSummaryErrorState.jsx @@ -0,0 +1,15 @@ +import React from 'react'; +import { Stack, Icon } from '@openedx/paragon'; +import { Error } from '@openedx/paragon/icons'; + +const AddMemberModalSummaryErrorState = () => ( + + +
+
Members can't be added as entered.
+ Please check your file and try again. +
+
+); + +export default AddMemberModalSummaryErrorState; diff --git a/src/components/PeopleManagement/AddMembersModal/AddMemberModalSummaryLearnerList.jsx b/src/components/PeopleManagement/AddMembersModal/AddMemberModalSummaryLearnerList.jsx new file mode 100644 index 0000000000..9349533315 --- /dev/null +++ b/src/components/PeopleManagement/AddMembersModal/AddMemberModalSummaryLearnerList.jsx @@ -0,0 +1,68 @@ +import React, { useEffect, useState } from 'react'; +import PropTypes from 'prop-types'; +import { v4 as uuidv4 } from 'uuid'; +import { + Button, Stack, Icon, +} from '@openedx/paragon'; +import { Person } from '@openedx/paragon/icons'; + +import { MAX_INITIAL_LEARNER_EMAILS_DISPLAYED_COUNT } from '../constants'; +import { hasLearnerEmailsSummaryListTruncation } from '../utils'; + +const AddMemberModalSummaryLearnerList = ({ + learnerEmails, +}) => { + const [isTruncated, setIsTruncated] = useState(hasLearnerEmailsSummaryListTruncation(learnerEmails)); + const truncatedLearnerEmails = learnerEmails.slice(0, MAX_INITIAL_LEARNER_EMAILS_DISPLAYED_COUNT); + const displayedLearnerEmails = isTruncated ? truncatedLearnerEmails : learnerEmails; + + useEffect(() => { + setIsTruncated(hasLearnerEmailsSummaryListTruncation(learnerEmails)); + }, [learnerEmails]); + + const expandCollapseMessage = isTruncated + ? `Show ${learnerEmails.length - MAX_INITIAL_LEARNER_EMAILS_DISPLAYED_COUNT} more` + : 'Show less'; + + return ( +
    + + {displayedLearnerEmails.map((emailAddress) => ( +
  • +
    +
    + + +
    + {emailAddress} +
    +
    +
    +
    +
  • + ))} +
    + {hasLearnerEmailsSummaryListTruncation(learnerEmails) && ( + + )} +
+ ); +}; + +AddMemberModalSummaryLearnerList.propTypes = { + learnerEmails: PropTypes.arrayOf(PropTypes.string).isRequired, +}; + +export default AddMemberModalSummaryLearnerList; diff --git a/src/components/PeopleManagement/AddMembersModal.jsx b/src/components/PeopleManagement/AddMembersModal/AddMembersModal.jsx similarity index 91% rename from src/components/PeopleManagement/AddMembersModal.jsx rename to src/components/PeopleManagement/AddMembersModal/AddMembersModal.jsx index 18afa8f1c5..b64c5e96e2 100644 --- a/src/components/PeopleManagement/AddMembersModal.jsx +++ b/src/components/PeopleManagement/AddMembersModal/AddMembersModal.jsx @@ -8,11 +8,11 @@ import { snakeCaseObject } from '@edx/frontend-platform/utils'; import { ActionRow, Button, FullscreenModal, StatefulButton, useToggle, } from '@openedx/paragon'; -import LmsApiService from '../../data/services/LmsApiService'; -import SystemErrorAlertModal from '../learner-credit-management/cards/assignment-allocation-status-modals/SystemErrorAlertModal'; +import LmsApiService from '../../../data/services/LmsApiService'; +import SystemErrorAlertModal from '../../learner-credit-management/cards/assignment-allocation-status-modals/SystemErrorAlertModal'; import AddMembersModalContent from './AddMembersModalContent'; -import { learnerCreditManagementQueryKeys } from '../learner-credit-management/data'; -import { useAllEnterpriseGroupLearners } from './data/hooks'; +import { learnerCreditManagementQueryKeys } from '../../learner-credit-management/data'; +import { useAllEnterpriseGroupLearners } from '../data/hooks'; const AddMembersModal = ({ isModalOpen, @@ -69,7 +69,6 @@ const AddMembersModal = ({ setCanAddMembersGroup(true); } }, [canAddMembers]); - return (
{!isLoading ? ( @@ -80,7 +79,7 @@ const AddMembersModal = ({ onClose={handleCloseAddMembersModal} title={intl.formatMessage({ id: 'peopleManagement.tab.add.members.modal.title', - defaultMessage: 'New group', + defaultMessage: 'Add members', description: 'Title for adding members modal', })} footerNode={( diff --git a/src/components/PeopleManagement/AddMembersModalContent.jsx b/src/components/PeopleManagement/AddMembersModal/AddMembersModalContent.jsx similarity index 80% rename from src/components/PeopleManagement/AddMembersModalContent.jsx rename to src/components/PeopleManagement/AddMembersModal/AddMembersModalContent.jsx index 2cfb1c802c..0380cfe51a 100644 --- a/src/components/PeopleManagement/AddMembersModalContent.jsx +++ b/src/components/PeopleManagement/AddMembersModal/AddMembersModalContent.jsx @@ -4,20 +4,19 @@ import React, { import PropTypes from 'prop-types'; import debounce from 'lodash.debounce'; import { - Col, Container, Row, + Col, Container, Row, Hyperlink, } from '@openedx/paragon'; import { FormattedMessage } from '@edx/frontend-platform/i18n'; -import InviteModalSummary from '../learner-credit-management/invite-modal/InviteModalSummary'; -import InviteSummaryCount from '../learner-credit-management/invite-modal/InviteSummaryCount'; -import FileUpload from '../learner-credit-management/invite-modal/FileUpload'; -import { EMAIL_ADDRESSES_INPUT_VALUE_DEBOUNCE_DELAY, isInviteEmailAddressesInputValueValid } from '../learner-credit-management/cards/data'; -import EnterpriseCustomerUserDatatable from './EnterpriseCustomerUserDatatable'; -import { useEnterpriseLearners } from '../learner-credit-management/data'; +import AddMembersModalSummary from './AddMembersModalSummary'; +import InviteSummaryCount from '../../learner-credit-management/invite-modal/InviteSummaryCount'; +import FileUpload from '../../learner-credit-management/invite-modal/FileUpload'; +import { EMAIL_ADDRESSES_INPUT_VALUE_DEBOUNCE_DELAY, isInviteEmailAddressesInputValueValid } from '../../learner-credit-management/cards/data'; +import EnterpriseCustomerUserDatatable from '../EnterpriseCustomerUserDatatable'; +import { useEnterpriseLearners } from '../../learner-credit-management/data'; const AddMembersModalContent = ({ onEmailAddressesChange, - isGroupInvite, enterpriseUUID, groupName, enterpriseGroupLearners, @@ -89,15 +88,21 @@ const AddMembersModalContent = ({

-

Add new members to your group

-

Only members registered with your organization can be added to your group. Learn more

-

Group Name

+

Only members registered with your organization can be added to your group.   + + Learn more. + +

+

Group Name

{groupName}

@@ -107,9 +112,9 @@ const AddMembersModalContent = ({

Select group members

Details

- +
@@ -136,7 +141,6 @@ const AddMembersModalContent = ({ AddMembersModalContent.propTypes = { onEmailAddressesChange: PropTypes.func.isRequired, - isGroupInvite: PropTypes.bool, enterpriseUUID: PropTypes.string.isRequired, groupName: PropTypes.string, enterpriseGroupLearners: PropTypes.arrayOf(PropTypes.shape({})), diff --git a/src/components/PeopleManagement/AddMembersModal/AddMembersModalSummary.jsx b/src/components/PeopleManagement/AddMembersModal/AddMembersModalSummary.jsx new file mode 100644 index 0000000000..a74df3337c --- /dev/null +++ b/src/components/PeopleManagement/AddMembersModal/AddMembersModalSummary.jsx @@ -0,0 +1,86 @@ +import React from 'react'; +import PropTypes from 'prop-types'; +import classNames from 'classnames'; +import { Card, Stack } from '@openedx/paragon'; +import isEmpty from 'lodash/isEmpty'; + +import AddMemberModalSummaryEmptyState from './AddMemberModalSummaryEmptyState'; +import AddMemberModalSummaryLearnerList from './AddMemberModalSummaryLearnerList'; +import AddMemberModalSummaryErrorState from './AddMemberModalSummaryErrorState'; +import AddMemberModalSummaryDuplicate from './AddMemberModalSummaryDuplicate'; +import LearnerNotInOrgErrorState from '../LearnerNotInOrgErrorState'; + +const AddMembersModalSummary = ({ + memberInviteMetadata, +}) => { + const { + isValidInput, + lowerCasedEmails, + duplicateEmails, + emailsNotInOrg, + } = memberInviteMetadata; + const hasEmailsNotInOrg = emailsNotInOrg.length > 0; + const renderCard = (contents, showErrorHighlight) => ( + + + + {contents} + + + + ); + + const hasLearnerEmails = lowerCasedEmails?.length > 0; + let cardSections = []; + if (hasLearnerEmails) { + cardSections = cardSections.concat( + renderCard(), + ); + } + + if (!isValidInput) { + cardSections = cardSections.concat( + renderCard(, true), + ); + } + + if (hasEmailsNotInOrg) { + cardSections = cardSections.concat( + , + ); + } + + if (isEmpty(cardSections)) { + cardSections = cardSections.concat( + renderCard(), + ); + } + + let summaryHeading = 'Summary'; + if (hasLearnerEmails) { + summaryHeading = `${summaryHeading} (${lowerCasedEmails.length})`; + } + return ( + <> +
{summaryHeading}
+ {cardSections} + {duplicateEmails?.length > 0 && } + + ); +}; + +AddMembersModalSummary.propTypes = { + memberInviteMetadata: PropTypes.shape({ + isValidInput: PropTypes.bool, + lowerCasedEmails: PropTypes.arrayOf(PropTypes.string), + duplicateEmails: PropTypes.arrayOf(PropTypes.string), + emailsNotInOrg: PropTypes.arrayOf(PropTypes.string), + }).isRequired, +}; + +export default AddMembersModalSummary; diff --git a/src/components/PeopleManagement/GroupDetailPage/GroupDetailPage.jsx b/src/components/PeopleManagement/GroupDetailPage/GroupDetailPage.jsx index ef153088f4..97449859a6 100644 --- a/src/components/PeopleManagement/GroupDetailPage/GroupDetailPage.jsx +++ b/src/components/PeopleManagement/GroupDetailPage/GroupDetailPage.jsx @@ -12,7 +12,7 @@ import DeleteGroupModal from './DeleteGroupModal'; import EditGroupNameModal from './EditGroupNameModal'; import formatDates from '../utils'; import GroupMembersTable from '../GroupMembersTable'; -import AddMembersModal from '../AddMembersModal'; +import AddMembersModal from '../AddMembersModal/AddMembersModal'; const GroupDetailPage = () => { const intl = useIntl(); diff --git a/src/components/PeopleManagement/constants.js b/src/components/PeopleManagement/constants.js index ef2d298c3f..5c9802d610 100644 --- a/src/components/PeopleManagement/constants.js +++ b/src/components/PeopleManagement/constants.js @@ -14,3 +14,5 @@ export const peopleManagementQueryKeys = { all: ['people-management'], members: (enterpriseUuid) => [...peopleManagementQueryKeys.all, 'members', enterpriseUuid], }; + +export const MAX_INITIAL_LEARNER_EMAILS_DISPLAYED_COUNT = 15; diff --git a/src/components/PeopleManagement/tests/AddMembersModal.test.jsx b/src/components/PeopleManagement/tests/AddMembersModal.test.jsx new file mode 100644 index 0000000000..9029154ab3 --- /dev/null +++ b/src/components/PeopleManagement/tests/AddMembersModal.test.jsx @@ -0,0 +1,288 @@ +import React from 'react'; +import { + fireEvent, render, screen, waitFor, +} from '@testing-library/react'; +import { Provider } from 'react-redux'; +import thunk from 'redux-thunk'; +import configureMockStore from 'redux-mock-store'; +import userEvent from '@testing-library/user-event'; +import '@testing-library/jest-dom/extend-expect'; +import { QueryClientProvider } from '@tanstack/react-query'; +import { IntlProvider } from '@edx/frontend-platform/i18n'; +import { queryClient } from '../../test/testUtils'; +import LmsApiService from '../../../data/services/LmsApiService'; +import { EMAIL_ADDRESSES_INPUT_VALUE_DEBOUNCE_DELAY } from '../../learner-credit-management/cards/data'; +import AddMembersModal from '../AddMembersModal/AddMembersModal'; +import { + useEnterpriseLearnersTableData, + useGetAllEnterpriseLearnerEmails, +} from '../data/hooks/useEnterpriseLearnersTableData'; +import { useEnterpriseLearners } from '../../learner-credit-management/data'; +import { useAllEnterpriseGroupLearners } from '../data/hooks'; + +jest.mock('@tanstack/react-query', () => ({ + ...jest.requireActual('@tanstack/react-query'), + useQueryClient: jest.fn(), +})); +jest.mock('../../../data/services/LmsApiService'); +jest.mock('../data/hooks/useEnterpriseLearnersTableData', () => ({ + ...jest.requireActual('../data/hooks/useEnterpriseLearnersTableData'), + useEnterpriseLearnersTableData: jest.fn(), + useGetAllEnterpriseLearnerEmails: jest.fn(), +})); +jest.mock('../data/hooks', () => ({ + ...jest.requireActual('../data/hooks'), + useAllEnterpriseGroupLearners: jest.fn(), +})); +jest.mock('../../learner-credit-management/data', () => ({ + ...jest.requireActual('../../learner-credit-management/data'), + useEnterpriseLearners: jest.fn(), +})); + +const mockStore = configureMockStore([thunk]); +const getMockStore = store => mockStore(store); +const TEST_GROUP = 'test-group-uuid'; +const enterpriseSlug = 'test-enterprise'; +const enterpriseUUID = '1234'; +const initialStoreState = { + portalConfiguration: { + enterpriseId: enterpriseUUID, + enterpriseSlug, + enableLearnerPortal: true, + enterpriseFeatures: { + topDownAssignmentRealTimeLcm: true, + enterpriseGroupsV1: true, + enterpriseGroupsV2: true, + }, + }, +}; + +const defaultProps = { + isModalOpen: true, + closeModal: jest.fn(), + enterpriseUUID, + groupName: 'test-group-name', + groupUuid: TEST_GROUP, +}; + +const mockTabledata = { + itemCount: 3, + pageCount: 1, + results: [ + { + id: 1, + user: { + id: 1, + username: 'testuser-1', + firstName: '', + lastName: '', + email: 'testuser-1@2u.com', + dateJoined: '2023-05-09T16:18:22Z', + }, + }, + { + id: 2, + user: { + id: 2, + username: 'testuser-2', + firstName: '', + lastName: '', + email: 'testuser-2@2u.com', + dateJoined: '2023-05-09T16:18:22Z', + }, + }, + { + id: 3, + user: { + id: 3, + username: 'testuser-3', + firstName: '', + lastName: '', + email: 'testuser-3@2u.com', + dateJoined: '2023-05-09T16:18:22Z', + }, + }, + { + id: 4, + user: { + id: 4, + username: 'testuser-4', + firstName: '', + lastName: '', + email: 'testuser-4@2u.com', + dateJoined: '2023-05-09T16:18:22Z', + }, + }, + ], +}; +const AddMembersModalWrapper = ({ + initialState = initialStoreState, +}) => { + const store = getMockStore({ ...initialState }); + return ( + + + + + + + + ); +}; + +describe('', () => { + beforeEach(() => { + useEnterpriseLearnersTableData.mockReturnValue({ + isLoading: false, + enterpriseCustomerUserTableData: mockTabledata, + fetchEnterpriseLearnersData: jest.fn(), + }); + useGetAllEnterpriseLearnerEmails.mockReturnValue({ + isLoading: false, + fetchLearnerEmails: jest.fn(), + addButtonState: 'complete', + }); + useEnterpriseLearners.mockReturnValue({ + allEnterpriseLearners: ['testuser-3@2u.com', 'testuser-3@2u.com', 'testuser-2@2u.com', 'testuser-1@2u.com', 'tomhaverford@pawnee.org'], + }); + useAllEnterpriseGroupLearners.mockReturnValue({ + isLoading: false, + enterpriseGroupLearners: [{ + activatedAt: '2024-11-06T21:01:32.953901Z', + enterprise_group_membership_uuid: TEST_GROUP, + memberDetails: { + userEmail: 'testuser-3@2u.com', + userName: '', + }, + recentAction: 'Accepted: November 06, 2024', + status: 'accepted', + enrollments: 1, + }], + }); + }); + + it('renders as expected', async () => { + render(); + expect(screen.getByText('Add new members to your group')).toBeInTheDocument(); + expect(screen.getByText('Select group members')).toBeInTheDocument(); + expect(screen.getByText('Upload a CSV or select members from the table below.')).toBeInTheDocument(); + expect(screen.getByText('You haven\'t uploaded any members yet.')).toBeInTheDocument(); + expect(screen.getByText('Upload a CSV file or select members to get started.')).toBeInTheDocument(); + expect(screen.getByText('Add')).toBeInTheDocument(); + expect(screen.getByText('Cancel')).toBeInTheDocument(); + expect(screen.getByText('test-group-name')).toBeInTheDocument(); + + // renders datatable + expect(screen.getByText('Member details')).toBeInTheDocument(); + expect(screen.getByText('Joined organization')).toBeInTheDocument(); + expect(screen.getByText('testuser-1')).toBeInTheDocument(); + expect(screen.getByText('testuser-1@2u.com')).toBeInTheDocument(); + expect(screen.getByText('testuser-2')).toBeInTheDocument(); + expect(screen.getByText('testuser-2@2u.com')).toBeInTheDocument(); + expect(screen.getByText('testuser-3')).toBeInTheDocument(); + expect(screen.getByText('testuser-3@2u.com')).toBeInTheDocument(); + }); + it('adds members to a group', async () => { + const mockInvite = jest.spyOn(LmsApiService, 'inviteEnterpriseLearnersToGroup'); + + const mockInviteData = { records_processed: 1, new_learners: 1, existing_learners: 0 }; + LmsApiService.inviteEnterpriseLearnersToGroup.mockResolvedValue(mockInviteData); + + render(); + expect(screen.getByText('You haven\'t uploaded any members yet.')).toBeInTheDocument(); + expect(screen.getByText('Upload a CSV file or select members to get started.')).toBeInTheDocument(); + const fakeFile = new File(['tomhaverford@pawnee.org'], 'emails.csv', { type: 'text/csv' }); + const dropzone = screen.getByText('Drag and drop your file here or click to upload.'); + Object.defineProperty(dropzone, 'files', { + value: [fakeFile], + }); + fireEvent.drop(dropzone); + + await waitFor(() => { + expect(screen.getByText('Summary (1)')).toBeInTheDocument(); + expect(screen.getByText('tomhaverford@pawnee.org')).toBeInTheDocument(); + }, { timeout: EMAIL_ADDRESSES_INPUT_VALUE_DEBOUNCE_DELAY + 1000 }); + + // testing interaction with adding members from the datatable + const membersCheckbox = screen.getAllByTitle('Toggle row selected'); + userEvent.click(membersCheckbox[0]); + userEvent.click(membersCheckbox[1]); + const addMembersButton = screen.getAllByText('Add')[0]; + userEvent.click(addMembersButton); + + await waitFor(() => { + expect(screen.getByText('Summary (3)')).toBeInTheDocument(); + // checking that each user appears twice, once in the datatable and once in the summary section + expect(screen.getAllByText('testuser-1@2u.com')).toHaveLength(2); + expect(screen.getAllByText('testuser-2@2u.com')).toHaveLength(2); + }); + + // testing interaction with removing members from the datatable + const removeMembersButton = screen.getByText('Remove'); + userEvent.click(removeMembersButton); + + await waitFor(() => { + expect(screen.getByText('Summary (1)')).toBeInTheDocument(); + expect(screen.getByText('emails.csv')).toBeInTheDocument(); + expect(screen.getByText('Total members to add')).toBeInTheDocument(); + expect(screen.getByText('tomhaverford@pawnee.org')).toBeInTheDocument(); + expect(screen.getAllByText('testuser-1@2u.com')).toHaveLength(1); + expect(screen.getAllByText('testuser-2@2u.com')).toHaveLength(1); + expect(screen.getAllByText('testuser-3@2u.com')).toHaveLength(1); + const formFeedbackText = 'Maximum members at a time: 1000'; + expect(screen.queryByText(formFeedbackText)).not.toBeInTheDocument(); + }, { timeout: EMAIL_ADDRESSES_INPUT_VALUE_DEBOUNCE_DELAY + 1000 }); + + const addButton = screen.getAllByText('Add')[1]; + userEvent.click(addButton); + await waitFor(() => { + expect(mockInvite).toHaveBeenCalledTimes(1); + }); + }); + it('displays error for email not belonging in an org', async () => { + const mockInviteData = { records_processed: 1, new_learners: 1, existing_learners: 0 }; + LmsApiService.inviteEnterpriseLearnersToGroup.mockResolvedValue(mockInviteData); + useEnterpriseLearners.mockReturnValue({ + allEnterpriseLearners: ['testuser-3@2u.com'], + }); + render(); + const fakeFile = new File(['tomhaverford@pawnee.org'], 'emails.csv', { type: 'text/csv' }); + const dropzone = screen.getByText('Drag and drop your file here or click to upload.'); + Object.defineProperty(dropzone, 'files', { + value: [fakeFile], + }); + fireEvent.drop(dropzone); + await waitFor(() => { + expect(screen.getByText(/Some people can't be added/i)).toBeInTheDocument(); + expect(/tomhaverford@pawnee.org email address is not available to be added to a group./i); + }, { timeout: EMAIL_ADDRESSES_INPUT_VALUE_DEBOUNCE_DELAY + 1000 }); + }); + it('displays system error modal', async () => { + const mockInvite = jest.spyOn(LmsApiService, 'inviteEnterpriseLearnersToGroup'); + const error = new Error('An error occurred'); + mockInvite.mockRejectedValueOnce(error); + + render(); + const fakeFile = new File(['tomhaverford@pawnee.org'], 'emails.csv', { type: 'text/csv' }); + const dropzone = screen.getByText('Drag and drop your file here or click to upload.'); + Object.defineProperty(dropzone, 'files', { + value: [fakeFile], + }); + fireEvent.drop(dropzone); + await waitFor(() => { + expect(screen.getByText('emails.csv')).toBeInTheDocument(); + expect(screen.getByText('Summary (1)')).toBeInTheDocument(); + expect(screen.getByText('Total members to add')).toBeInTheDocument(); + expect(screen.getByText('tomhaverford@pawnee.org')).toBeInTheDocument(); + const formFeedbackText = 'Maximum members at a time: 1000'; + expect(screen.queryByText(formFeedbackText)).not.toBeInTheDocument(); + }, { timeout: EMAIL_ADDRESSES_INPUT_VALUE_DEBOUNCE_DELAY + 1000 }); + const addButton = screen.getByRole('button', { name: 'Add' }); + userEvent.click(addButton); + await waitFor(() => { + expect(screen.getByText( + 'We\'re sorry. Something went wrong behind the scenes. Please try again, or reach out to customer support for help.', + )).toBeInTheDocument(); + }); + }); +}); diff --git a/src/components/PeopleManagement/utils.js b/src/components/PeopleManagement/utils.js index eb8cfe4f65..5012cd31c5 100644 --- a/src/components/PeopleManagement/utils.js +++ b/src/components/PeopleManagement/utils.js @@ -1,4 +1,5 @@ import dayjs from 'dayjs'; +import { MAX_INITIAL_LEARNER_EMAILS_DISPLAYED_COUNT } from './constants'; /** * Formats provided dates for display @@ -21,3 +22,13 @@ export const getSelectedEmailsByRow = (selectedFlatRows) => { }); return emails; }; + +/** + * Determine whether the number of learner emails exceeds a certain + * threshold, whereby the list of emails should be truncated. + * @param {Array} learnerEmails List of learner emails. + * @returns True is learner emails list should be truncated; otherwise, false. + */ +export const hasLearnerEmailsSummaryListTruncation = (learnerEmails) => ( + learnerEmails.length > MAX_INITIAL_LEARNER_EMAILS_DISPLAYED_COUNT +); From bcfd87ad0b5692e637bd787f772152dbcd47d4eb Mon Sep 17 00:00:00 2001 From: Katrina Nguyen Date: Tue, 7 Jan 2025 21:39:23 +0000 Subject: [PATCH 4/8] feat: updated datatable to show correct selection --- .../PeopleManagement/AddMembersModal/AddMembersModalContent.jsx | 2 +- .../PeopleManagement/EnterpriseCustomerUserDatatable.jsx | 1 - 2 files changed, 1 insertion(+), 2 deletions(-) diff --git a/src/components/PeopleManagement/AddMembersModal/AddMembersModalContent.jsx b/src/components/PeopleManagement/AddMembersModal/AddMembersModalContent.jsx index 42739c6926..4556fa8d3a 100644 --- a/src/components/PeopleManagement/AddMembersModal/AddMembersModalContent.jsx +++ b/src/components/PeopleManagement/AddMembersModal/AddMembersModalContent.jsx @@ -103,7 +103,7 @@ const AddMembersModalContent = ({

Group Name

-

{groupName}

+

{groupName}

diff --git a/src/components/PeopleManagement/EnterpriseCustomerUserDatatable.jsx b/src/components/PeopleManagement/EnterpriseCustomerUserDatatable.jsx index cc6e2a68cb..634c2f603a 100644 --- a/src/components/PeopleManagement/EnterpriseCustomerUserDatatable.jsx +++ b/src/components/PeopleManagement/EnterpriseCustomerUserDatatable.jsx @@ -93,7 +93,6 @@ const EnterpriseCustomerUserDatatable = ({ getRowId: row => row.id.toString(), }} pageCount={enterpriseCustomerUserTableData.pageCount} - SelectionStatusComponent={DataTable.ControlledSelectionStatus} manualSelectColumn={ { id: 'selection', From 17dd068e689b9570576248cf3577733f43d75ebf Mon Sep 17 00:00:00 2001 From: Katrina Nguyen Date: Fri, 10 Jan 2025 23:35:43 +0000 Subject: [PATCH 5/8] feat: adds sorting and search functionality --- .../EnterpriseCustomerUserDatatable.jsx | 32 ++++++---- .../PeopleManagement/MemberDetailsCell.jsx | 8 +-- .../PeopleManagement/MemberJoinedDateCell.jsx | 7 ++- .../hooks/useEnterpriseMembersTableData.js | 8 +++ .../tests/AddMembersModal.test.jsx | 61 ++++++++----------- .../tests/CreateGroupModal.test.jsx | 59 +++++++++--------- src/components/PeopleManagement/utils.js | 4 +- 7 files changed, 92 insertions(+), 87 deletions(-) diff --git a/src/components/PeopleManagement/EnterpriseCustomerUserDatatable.jsx b/src/components/PeopleManagement/EnterpriseCustomerUserDatatable.jsx index 634c2f603a..ff2f93bd76 100644 --- a/src/components/PeopleManagement/EnterpriseCustomerUserDatatable.jsx +++ b/src/components/PeopleManagement/EnterpriseCustomerUserDatatable.jsx @@ -5,12 +5,12 @@ import { TextFilter, CheckboxControl, } from '@openedx/paragon'; -import { useEnterpriseLearnersTableData } from './data/hooks/useEnterpriseLearnersTableData'; import { GROUP_MEMBERS_TABLE_DEFAULT_PAGE, GROUP_MEMBERS_TABLE_PAGE_SIZE } from './constants'; import MemberDetailsCell from './MemberDetailsCell'; import AddMembersBulkAction from './AddMembersBulkAction'; import RemoveMembersBulkAction from './RemoveMembersBulkAction'; import MemberJoinedDateCell from './MemberJoinedDateCell'; +import { useEnterpriseMembersTableData } from './data/hooks'; export const BaseSelectWithContext = ({ row, enterpriseGroupLearners }) => { const { @@ -32,9 +32,8 @@ export const BaseSelectWithContext = ({ row, enterpriseGroupLearners }) => {
); }; +const FilterStatus = (rest) => ; -// TO-DO: add search functionality on member details once the learner endpoint is updated -// to support search const EnterpriseCustomerUserDatatable = ({ enterpriseId, learnerEmails, @@ -44,9 +43,9 @@ const EnterpriseCustomerUserDatatable = ({ }) => { const { isLoading, - enterpriseCustomerUserTableData, - fetchEnterpriseLearnersData, - } = useEnterpriseLearnersTableData(enterpriseId, enterpriseGroupLearners); + enterpriseMembersTableData, + fetchEnterpriseMembersTableData, + } = useEnterpriseMembersTableData({ enterpriseId }); return ( row.id.toString(), + getRowId: row => row.enterpriseCustomerUser.name.toString(), }} - pageCount={enterpriseCustomerUserTableData.pageCount} + pageCount={enterpriseMembersTableData.pageCount} manualSelectColumn={ { id: 'selection', diff --git a/src/components/PeopleManagement/MemberDetailsCell.jsx b/src/components/PeopleManagement/MemberDetailsCell.jsx index 1530618874..ce0be726d4 100644 --- a/src/components/PeopleManagement/MemberDetailsCell.jsx +++ b/src/components/PeopleManagement/MemberDetailsCell.jsx @@ -4,10 +4,10 @@ import { Stack } from '@openedx/paragon'; const MemberDetailsCell = ({ row }) => (
- {row.original?.user?.username} + {row.original?.enterpriseCustomerUser?.name}
- {row.original?.user?.email} + {row.original?.enterpriseCustomerUser?.email}
); @@ -15,9 +15,9 @@ const MemberDetailsCell = ({ row }) => ( MemberDetailsCell.propTypes = { row: PropTypes.shape({ original: PropTypes.shape({ - user: PropTypes.shape({ + enterpriseCustomerUser: PropTypes.shape({ + name: PropTypes.string.isRequired, email: PropTypes.string.isRequired, - username: PropTypes.string.isRequired, }).isRequired, }).isRequired, }).isRequired, diff --git a/src/components/PeopleManagement/MemberJoinedDateCell.jsx b/src/components/PeopleManagement/MemberJoinedDateCell.jsx index a68c3b9ee6..dca184a116 100644 --- a/src/components/PeopleManagement/MemberJoinedDateCell.jsx +++ b/src/components/PeopleManagement/MemberJoinedDateCell.jsx @@ -1,16 +1,17 @@ import PropTypes from 'prop-types'; -import { formatTimestamp } from '../../utils'; const MemberJoinedDateCell = ({ row }) => (
- {formatTimestamp({ timestamp: row.original.created, format: 'MMM DD, YYYY' })} + {row.original.enterpriseCustomerUser.joinedOrg}
); MemberJoinedDateCell.propTypes = { row: PropTypes.shape({ original: PropTypes.shape({ - created: PropTypes.string.isRequired, + enterpriseCustomerUser: PropTypes.shape({ + joinedOrg: PropTypes.string.isRequired, + }), }).isRequired, }).isRequired, }; diff --git a/src/components/PeopleManagement/data/hooks/useEnterpriseMembersTableData.js b/src/components/PeopleManagement/data/hooks/useEnterpriseMembersTableData.js index befa640c63..3f4baffc8f 100644 --- a/src/components/PeopleManagement/data/hooks/useEnterpriseMembersTableData.js +++ b/src/components/PeopleManagement/data/hooks/useEnterpriseMembersTableData.js @@ -3,6 +3,7 @@ import { } from 'react'; import { camelCaseObject } from '@edx/frontend-platform/utils'; import { logError } from '@edx/frontend-platform/logging'; +import _ from 'lodash'; import debounce from 'lodash.debounce'; import LmsApiService from '../../../../data/services/LmsApiService'; @@ -28,6 +29,13 @@ const useEnterpriseMembersTableData = ({ enterpriseId }) => { try { setIsLoading(true); const options = {}; + 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; + } + } args.filters.forEach((filter) => { const { id, value } = filter; if (id === 'name') { diff --git a/src/components/PeopleManagement/tests/AddMembersModal.test.jsx b/src/components/PeopleManagement/tests/AddMembersModal.test.jsx index 9029154ab3..216a63a5ff 100644 --- a/src/components/PeopleManagement/tests/AddMembersModal.test.jsx +++ b/src/components/PeopleManagement/tests/AddMembersModal.test.jsx @@ -14,11 +14,10 @@ import LmsApiService from '../../../data/services/LmsApiService'; import { EMAIL_ADDRESSES_INPUT_VALUE_DEBOUNCE_DELAY } from '../../learner-credit-management/cards/data'; import AddMembersModal from '../AddMembersModal/AddMembersModal'; import { - useEnterpriseLearnersTableData, useGetAllEnterpriseLearnerEmails, } from '../data/hooks/useEnterpriseLearnersTableData'; import { useEnterpriseLearners } from '../../learner-credit-management/data'; -import { useAllEnterpriseGroupLearners } from '../data/hooks'; +import { useAllEnterpriseGroupLearners, useEnterpriseMembersTableData } from '../data/hooks'; jest.mock('@tanstack/react-query', () => ({ ...jest.requireActual('@tanstack/react-query'), @@ -27,12 +26,12 @@ jest.mock('@tanstack/react-query', () => ({ jest.mock('../../../data/services/LmsApiService'); jest.mock('../data/hooks/useEnterpriseLearnersTableData', () => ({ ...jest.requireActual('../data/hooks/useEnterpriseLearnersTableData'), - useEnterpriseLearnersTableData: jest.fn(), useGetAllEnterpriseLearnerEmails: jest.fn(), })); jest.mock('../data/hooks', () => ({ ...jest.requireActual('../data/hooks'), useAllEnterpriseGroupLearners: jest.fn(), + useEnterpriseMembersTableData: jest.fn(), })); jest.mock('../../learner-credit-management/data', () => ({ ...jest.requireActual('../../learner-credit-management/data'), @@ -70,47 +69,35 @@ const mockTabledata = { pageCount: 1, results: [ { - id: 1, - user: { - id: 1, - username: 'testuser-1', - firstName: '', - lastName: '', + enterpriseCustomerUser: { + user_id: 1, + name: 'Test User 1', email: 'testuser-1@2u.com', - dateJoined: '2023-05-09T16:18:22Z', + joinedOrg: 'July 5, 2021', }, }, { - id: 2, - user: { - id: 2, - username: 'testuser-2', - firstName: '', - lastName: '', + enterpriseCustomerUser: { + user_id: 2, + name: 'Test User 2', email: 'testuser-2@2u.com', - dateJoined: '2023-05-09T16:18:22Z', + joinedOrg: 'July 2, 2022', }, }, { - id: 3, - user: { - id: 3, - username: 'testuser-3', - firstName: '', - lastName: '', + enterpriseCustomerUser: { + user_id: 3, + name: 'Test User 3', email: 'testuser-3@2u.com', - dateJoined: '2023-05-09T16:18:22Z', + joinedOrg: 'July 3, 2023', }, }, { - id: 4, - user: { - id: 4, - username: 'testuser-4', - firstName: '', - lastName: '', + enterpriseCustomerUser: { + user_id: 4, + name: 'Test User 4', email: 'testuser-4@2u.com', - dateJoined: '2023-05-09T16:18:22Z', + joinedOrg: 'July 4, 2024', }, }, ], @@ -132,10 +119,10 @@ const AddMembersModalWrapper = ({ describe('', () => { beforeEach(() => { - useEnterpriseLearnersTableData.mockReturnValue({ + useEnterpriseMembersTableData.mockReturnValue({ isLoading: false, - enterpriseCustomerUserTableData: mockTabledata, - fetchEnterpriseLearnersData: jest.fn(), + enterpriseMembersTableData: mockTabledata, + fetchEnterpriseMembersTableData: jest.fn(), }); useGetAllEnterpriseLearnerEmails.mockReturnValue({ isLoading: false, @@ -175,11 +162,11 @@ describe('', () => { // renders datatable expect(screen.getByText('Member details')).toBeInTheDocument(); expect(screen.getByText('Joined organization')).toBeInTheDocument(); - expect(screen.getByText('testuser-1')).toBeInTheDocument(); + expect(screen.getByText('Test User 1')).toBeInTheDocument(); expect(screen.getByText('testuser-1@2u.com')).toBeInTheDocument(); - expect(screen.getByText('testuser-2')).toBeInTheDocument(); + expect(screen.getByText('Test User 2')).toBeInTheDocument(); expect(screen.getByText('testuser-2@2u.com')).toBeInTheDocument(); - expect(screen.getByText('testuser-3')).toBeInTheDocument(); + expect(screen.getByText('Test User 3')).toBeInTheDocument(); expect(screen.getByText('testuser-3@2u.com')).toBeInTheDocument(); }); it('adds members to a group', async () => { diff --git a/src/components/PeopleManagement/tests/CreateGroupModal.test.jsx b/src/components/PeopleManagement/tests/CreateGroupModal.test.jsx index 2fc23d40c2..2a7677cd33 100644 --- a/src/components/PeopleManagement/tests/CreateGroupModal.test.jsx +++ b/src/components/PeopleManagement/tests/CreateGroupModal.test.jsx @@ -14,11 +14,15 @@ import LmsApiService from '../../../data/services/LmsApiService'; import { EMAIL_ADDRESSES_INPUT_VALUE_DEBOUNCE_DELAY } from '../../learner-credit-management/cards/data'; import CreateGroupModal from '../CreateGroupModal'; import { - useEnterpriseLearnersTableData, useGetAllEnterpriseLearnerEmails, } from '../data/hooks/useEnterpriseLearnersTableData'; import { useEnterpriseLearners } from '../../learner-credit-management/data'; +import { useEnterpriseMembersTableData } from '../data/hooks'; +jest.mock('../data/hooks', () => ({ + ...jest.requireActual('../data/hooks'), + useEnterpriseMembersTableData: jest.fn(), +})); jest.mock('@tanstack/react-query', () => ({ ...jest.requireActual('@tanstack/react-query'), useQueryClient: jest.fn(), @@ -62,36 +66,35 @@ const mockTabledata = { pageCount: 1, results: [ { - id: 1, - user: { - id: 1, - username: 'testuser-1', - firstName: '', - lastName: '', + enterpriseCustomerUser: { + user_id: 1, + name: 'Test User 1', email: 'testuser-1@2u.com', - dateJoined: '2023-05-09T16:18:22Z', + joinedOrg: 'July 5, 2021', }, }, { - id: 2, - user: { - id: 2, - username: 'testuser-2', - firstName: '', - lastName: '', + enterpriseCustomerUser: { + user_id: 2, + name: 'Test User 2', email: 'testuser-2@2u.com', - dateJoined: '2023-05-09T16:18:22Z', + joinedOrg: 'July 2, 2022', }, }, { - id: 3, - user: { - id: 3, - username: 'testuser-3', - firstName: '', - lastName: '', + enterpriseCustomerUser: { + user_id: 3, + name: 'Test User 3', email: 'testuser-3@2u.com', - dateJoined: '2023-05-09T16:18:22Z', + joinedOrg: 'July 3, 2023', + }, + }, + { + enterpriseCustomerUser: { + user_id: 4, + name: 'Test User 4', + email: 'testuser-4@2u.com', + joinedOrg: 'July 4, 2024', }, }, ], @@ -113,10 +116,10 @@ const CreateGroupModalWrapper = ({ describe('', () => { beforeEach(() => { - useEnterpriseLearnersTableData.mockReturnValue({ + useEnterpriseMembersTableData.mockReturnValue({ isLoading: false, - enterpriseCustomerUserTableData: mockTabledata, - fetchEnterpriseLearnersData: jest.fn(), + enterpriseMembersTableData: mockTabledata, + fetchEnterpriseMembersTableData: jest.fn(), }); useGetAllEnterpriseLearnerEmails.mockReturnValue({ isLoading: false, @@ -141,11 +144,11 @@ describe('', () => { // renders datatable expect(screen.getByText('Member details')).toBeInTheDocument(); expect(screen.getByText('Joined organization')).toBeInTheDocument(); - expect(screen.getByText('testuser-1')).toBeInTheDocument(); + expect(screen.getByText('Test User 1')).toBeInTheDocument(); expect(screen.getByText('testuser-1@2u.com')).toBeInTheDocument(); - expect(screen.getByText('testuser-2')).toBeInTheDocument(); + expect(screen.getByText('Test User 2')).toBeInTheDocument(); expect(screen.getByText('testuser-2@2u.com')).toBeInTheDocument(); - expect(screen.getByText('testuser-3')).toBeInTheDocument(); + expect(screen.getByText('Test User 3')).toBeInTheDocument(); expect(screen.getByText('testuser-3@2u.com')).toBeInTheDocument(); }); it('creates groups and assigns learners', async () => { diff --git a/src/components/PeopleManagement/utils.js b/src/components/PeopleManagement/utils.js index 5012cd31c5..bdba3983ad 100644 --- a/src/components/PeopleManagement/utils.js +++ b/src/components/PeopleManagement/utils.js @@ -16,8 +16,8 @@ export const getSelectedEmailsByRow = (selectedFlatRows) => { const emails = []; Object.keys(selectedFlatRows).forEach(key => { const { original } = selectedFlatRows[key]; - if (original.user !== null) { - emails.push(original.user.email); + if (original.enterpriseCustomerUser !== null) { + emails.push(original.enterpriseCustomerUser.email); } }); return emails; From 9d8dc70ede3c8fef0b59ec6d95692dda74ecf302 Mon Sep 17 00:00:00 2001 From: Saleem Latif Date: Mon, 23 Dec 2024 17:04:26 +0500 Subject: [PATCH 6/8] chore: Updated the text according to customer feedback. --- src/components/AdvanceAnalyticsV2/tabs/Leaderboard.jsx | 2 +- src/components/AdvanceAnalyticsV2/tabs/Leaderboard.test.jsx | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/src/components/AdvanceAnalyticsV2/tabs/Leaderboard.jsx b/src/components/AdvanceAnalyticsV2/tabs/Leaderboard.jsx index 6dd04a9364..df4e16f244 100644 --- a/src/components/AdvanceAnalyticsV2/tabs/Leaderboard.jsx +++ b/src/components/AdvanceAnalyticsV2/tabs/Leaderboard.jsx @@ -19,7 +19,7 @@ const Leaderboard = ({ startDate, endDate, enterpriseId }) => { })} tableSubtitle={intl.formatMessage({ id: 'advance.analytics.leaderboard.tab.datatable.leaderboard.subtitle', - defaultMessage: 'See the top learners by different measures of engagement. The results are defaulted to sort by learning hours. Download the full CSV below to sort by other metrics.', + defaultMessage: 'Explore the top learners ranked by engagement metrics. The list is sorted by learning hours by default. To dive deeper, download the full CSV to explore and sort by other metrics. *Only learners who have passed the course and completed at least one engagement activity (watching a video, submitting a problem, or posting in forums) are included.', description: 'Subtitle for the leaderboard datatable.', })} startDate={startDate} diff --git a/src/components/AdvanceAnalyticsV2/tabs/Leaderboard.test.jsx b/src/components/AdvanceAnalyticsV2/tabs/Leaderboard.test.jsx index 0d56699b70..2d13dc97ee 100644 --- a/src/components/AdvanceAnalyticsV2/tabs/Leaderboard.test.jsx +++ b/src/components/AdvanceAnalyticsV2/tabs/Leaderboard.test.jsx @@ -75,7 +75,7 @@ describe('Leaderboard Component', () => { { className: '.leaderboard-datatable-container', title: 'Leaderboard', - subtitle: 'See the top learners by different measures of engagement. The results are defaulted to sort by learning hours. Download the full CSV below to sort by other metrics.', + subtitle: 'Explore the top learners ranked by engagement metrics. The list is sorted by learning hours by default. To dive deeper, download the full CSV to explore and sort by other metrics. *Only learners who have passed the course and completed at least one engagement activity (watching a video, submitting a problem, or posting in forums) are included.', }, ]; From 405056f8c64baa1c00eea3ea03294890b5df0c0d Mon Sep 17 00:00:00 2001 From: Kira Miller <31229189+kiram15@users.noreply.github.com> Date: Thu, 9 Jan 2025 09:37:36 -0700 Subject: [PATCH 7/8] fix: changes to group collapsible (#1375) --- .../PeopleManagement/GroupCardGrid.jsx | 45 +++++++++++-------- .../PeopleManagement/_PeopleManagement.scss | 6 --- .../tests/PeopleManagementPage.test.jsx | 8 +++- 3 files changed, 32 insertions(+), 27 deletions(-) diff --git a/src/components/PeopleManagement/GroupCardGrid.jsx b/src/components/PeopleManagement/GroupCardGrid.jsx index 8c1cde76e0..c875938da1 100644 --- a/src/components/PeopleManagement/GroupCardGrid.jsx +++ b/src/components/PeopleManagement/GroupCardGrid.jsx @@ -1,6 +1,7 @@ import React, { useEffect, useState } from 'react'; import PropTypes from 'prop-types'; -import { CardGrid, Collapsible } from '@openedx/paragon'; +import { CardGrid, Collapsible, Icon } from '@openedx/paragon'; +import { ExpandLess, ExpandMore } from '@openedx/paragon/icons'; import GroupDetailCard from './GroupDetailCard'; @@ -30,24 +31,30 @@ const GroupCardGrid = ({ groups }) => { ))} {overflowGroups && ( - - - {overflowGroups.map((group) => ( - - ))} - - + + + + {overflowGroups.map((group) => ( + + ))} + + + + + Show all {groups.length} groups + + + Show less + + + )} ); diff --git a/src/components/PeopleManagement/_PeopleManagement.scss b/src/components/PeopleManagement/_PeopleManagement.scss index 3f943606be..ffd8310883 100644 --- a/src/components/PeopleManagement/_PeopleManagement.scss +++ b/src/components/PeopleManagement/_PeopleManagement.scss @@ -7,9 +7,3 @@ padding: .25rem 1.25rem 1.25rem 1.25rem; } } - -.group-collapsible { - .collapsible-basic .collapsible-trigger { - justify-content: right; - } -} diff --git a/src/components/PeopleManagement/tests/PeopleManagementPage.test.jsx b/src/components/PeopleManagement/tests/PeopleManagementPage.test.jsx index fb69b2f231..65ce498167 100644 --- a/src/components/PeopleManagement/tests/PeopleManagementPage.test.jsx +++ b/src/components/PeopleManagement/tests/PeopleManagementPage.test.jsx @@ -146,10 +146,14 @@ describe('', () => { expect(screen.getByText('Show all 4 groups')).toBeInTheDocument(); expect(screen.queryByText('fruity pebbles')).not.toBeInTheDocument(); - const collapsible = screen.getByText('Show all 4 groups'); - collapsible.click(); + const closedCollapsible = screen.getByText('Show all 4 groups'); + closedCollapsible.click(); await waitFor(() => { expect(screen.getByText('fruity pebbles')).toBeInTheDocument(); }); + + expect(screen.queryByText('Show all 4 groups')).toBeNull(); + const openCollapsible = screen.getByText('Show less'); + expect(openCollapsible).toBeInTheDocument(); }); }); From e83b1262e0377f1bb9e8865d26d8c474a6e1c694 Mon Sep 17 00:00:00 2001 From: Kira Miller <31229189+kiram15@users.noreply.github.com> Date: Fri, 10 Jan 2025 11:29:49 -0700 Subject: [PATCH 8/8] Adding in remove capability for group members + csv download (#1367) * fix: formatting without data * fix: adding in tests * fix: teeny fix * feat: adding in remove member functionality * fix: adding csv download * fix: adding in download capability * fix: remove formatting changes * fix: PR requests --- .../PeopleManagement/CreateGroupModal.jsx | 4 +- .../EnterpriseCustomerUserDatatable.jsx | 2 +- .../AddMemberTableAction.jsx | 8 +- .../AddMembersBulkAction.jsx | 4 +- .../GroupDetailPage/DownloadCsvIconButton.jsx | 83 ++++++++++++ .../GroupDetailPage/GroupDetailPage.jsx | 10 +- .../GroupMembersTable.jsx | 124 ++++++++++++------ .../GroupDetailPage/RemoveMemberModal.jsx | 88 +++++++++++++ src/components/PeopleManagement/constants.js | 2 + .../useEnterpriseGroupLearnersTableData.js | 16 ++- .../data/hooks/useEnterpriseGroupUuid.js | 4 +- .../tests/DownloadCsvIconButton.test.jsx | 106 +++++++++++++++ .../tests/GroupDetailPage.test.jsx | 10 +- .../data/constants.js | 2 +- 14 files changed, 412 insertions(+), 51 deletions(-) rename src/components/PeopleManagement/{ => GroupDetailPage}/AddMemberTableAction.jsx (68%) rename src/components/PeopleManagement/{ => GroupDetailPage}/AddMembersBulkAction.jsx (94%) create mode 100644 src/components/PeopleManagement/GroupDetailPage/DownloadCsvIconButton.jsx rename src/components/PeopleManagement/{ => GroupDetailPage}/GroupMembersTable.jsx (54%) create mode 100644 src/components/PeopleManagement/GroupDetailPage/RemoveMemberModal.jsx create mode 100644 src/components/PeopleManagement/tests/DownloadCsvIconButton.test.jsx 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),