From 35f8f190a8c5841f0c7d1fbaab5c59b7d32c8727 Mon Sep 17 00:00:00 2001 From: Katrina Nguyen Date: Wed, 4 Dec 2024 16:19:04 +0000 Subject: [PATCH 1/2] 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 000000000..81465c474 --- /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 000000000..639db12a5 --- /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 9f02b81e9..b0a099280 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/2] 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 384b2917e..2fc23d40c 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 d93a06344..9d6c3bd05 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 ( - + + + );