From 8e070c8a3135ed8d2ed385a5f4c61faa663d00d9 Mon Sep 17 00:00:00 2001 From: katrinan029 Date: Mon, 7 Oct 2024 19:47:57 +0000 Subject: [PATCH] feat: adds create group modal --- .../PeopleManagement/CreateGroupModal.jsx | 131 +++++++++++++++++ src/components/PeopleManagement/constants.js | 2 + src/components/PeopleManagement/index.jsx | 2 + .../tests/CreateGroupModal.test.jsx | 132 ++++++++++++++++++ .../InviteMembersModalWrapper.jsx | 1 + .../invite-modal/InviteModalContent.jsx | 80 ++++++++++- .../invite-modal/InviteModalSummary.jsx | 5 +- .../InviteModalSummaryEmptyState.jsx | 27 +++- .../invite-modal/InviteSummaryCount.jsx | 24 ++++ .../tests/InviteMemberModal.test.jsx | 18 +++ src/data/services/LmsApiService.js | 10 ++ src/data/services/tests/LmsApiService.test.js | 25 ++++ 12 files changed, 447 insertions(+), 10 deletions(-) create mode 100644 src/components/PeopleManagement/CreateGroupModal.jsx create mode 100644 src/components/PeopleManagement/constants.js create mode 100644 src/components/PeopleManagement/tests/CreateGroupModal.test.jsx create mode 100644 src/components/learner-credit-management/invite-modal/InviteSummaryCount.jsx diff --git a/src/components/PeopleManagement/CreateGroupModal.jsx b/src/components/PeopleManagement/CreateGroupModal.jsx new file mode 100644 index 0000000000..11df1dace7 --- /dev/null +++ b/src/components/PeopleManagement/CreateGroupModal.jsx @@ -0,0 +1,131 @@ +import React, { useCallback, useState, useEffect } from 'react'; +import PropTypes from 'prop-types'; +import { connect } from 'react-redux'; +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 InviteModalContent from '../learner-credit-management/invite-modal/InviteModalContent'; +import SystemErrorAlertModal from '../learner-credit-management/cards/assignment-allocation-status-modals/SystemErrorAlertModal'; + +const CreateGroupModal = ({ + isModalOpen, + closeModal, + enterpriseUUID, +}) => { + const intl = useIntl(); + const [learnerEmails, setLearnerEmails] = useState([]); + const [createButtonState, setCreateButtonState] = useState('default'); + const [groupName, setGroupName] = useState(''); + const [canCreateGroup, setCanCreateGroup] = useState(false); + const [canInviteMembers, setCanInviteMembers] = useState(false); + const [isSystemErrorModalOpen, openSystemErrorModal, closeSystemErrorModal] = useToggle(false); + const handleCloseCreateGroupModal = () => { + closeModal(); + setCreateButtonState('default'); + }; + + const handleCreateGroup = async () => { + setCreateButtonState('pending'); + const options = { + enterpriseUUID, + groupName, + }; + let groupCreationResponse; + + try { + groupCreationResponse = await LmsApiService.createEnterpriseGroup(options); + } catch (err) { + setCreateButtonState('error'); + openSystemErrorModal(); + } + + try { + if (groupCreationResponse.status === 201) { + const requestBody = snakeCaseObject({ + learnerEmails, + }); + await LmsApiService.inviteEnterpriseLearnersToGroup(groupCreationResponse.data.uuid, requestBody); + setCreateButtonState('complete'); + handleCloseCreateGroupModal(); + } + } catch { + setCreateButtonState('error'); + openSystemErrorModal(); + } + }; + + const handleEmailAddressesChange = useCallback(( + value, + { canInvite = false } = {}, + ) => { + setLearnerEmails(value); + setCanInviteMembers(canInvite); + }, []); + + useEffect(() => { + setCanCreateGroup(false); + if (groupName.length > 0 && canInviteMembers) { + setCanCreateGroup(true); + } + }, [groupName, canInviteMembers]); + + return ( + <> + + + + + + )} + > + + + + + ); +}; + +const mapStateToProps = state => ({ + enterpriseUUID: state.portalConfiguration.enterpriseId, +}); + +CreateGroupModal.propTypes = { + enterpriseUUID: PropTypes.string.isRequired, + isModalOpen: PropTypes.bool.isRequired, + closeModal: PropTypes.func.isRequired, +}; + +export default connect(mapStateToProps)(CreateGroupModal); diff --git a/src/components/PeopleManagement/constants.js b/src/components/PeopleManagement/constants.js new file mode 100644 index 0000000000..c8c2b3a241 --- /dev/null +++ b/src/components/PeopleManagement/constants.js @@ -0,0 +1,2 @@ +const MAX_LENGTH_GROUP_NAME = 60; +export default MAX_LENGTH_GROUP_NAME; diff --git a/src/components/PeopleManagement/index.jsx b/src/components/PeopleManagement/index.jsx index 1ad9b86f92..93bc1979af 100644 --- a/src/components/PeopleManagement/index.jsx +++ b/src/components/PeopleManagement/index.jsx @@ -10,6 +10,7 @@ import cardImage from './images/ZeroStateImage.svg'; import Hero from '../Hero'; import { SUBSIDY_TYPES } from '../../data/constants/subsidyTypes'; import { EnterpriseSubsidiesContext } from '../EnterpriseSubsidiesContext'; +import CreateGroupModal from './CreateGroupModal'; const PeopleManagementPage = () => { const intl = useIntl(); @@ -69,6 +70,7 @@ const PeopleManagementPage = () => { description="CTA button text to open new group modal." /> + ({ + ...jest.requireActual('@tanstack/react-query'), + useQueryClient: jest.fn(), +})); +jest.mock('../../../data/services/LmsApiService'); + +const mockStore = configureMockStore([thunk]); +const getMockStore = store => mockStore(store); +const enterpriseSlug = 'test-enterprise'; +const enterpriseUUID = '1234'; +const initialStoreState = { + portalConfiguration: { + enterpriseId: enterpriseUUID, + enterpriseSlug, + enableLearnerPortal: true, + enterpriseFeatures: { + topDownAssignmentRealTimeLcm: true, + enterpriseGroupsV1: true, + }, + }, +}; + +const defaultProps = { + isModalOpen: true, + closeModal: jest.fn(), + enterpriseUUID: 'test-uuid', +}; + +const CreateGroupModalWrapper = ({ + initialState = initialStoreState, +}) => { + const store = getMockStore({ ...initialState }); + return ( + + + + + + + + ); +}; + +describe('', () => { + it('Modal renders as expected', async () => { + render(); + expect(screen.getByText('Create a custom group of members')).toBeInTheDocument(); + expect(screen.getByText('Name 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('Create')).toBeInTheDocument(); + expect(screen.getByText('Cancel')).toBeInTheDocument(); + }); + it('creates groups and assigns learners', async () => { + const mockCreateGroup = jest.spyOn(LmsApiService, 'createEnterpriseGroup'); + const mockInvite = jest.spyOn(LmsApiService, 'inviteEnterpriseLearnersToGroup'); + + const mockGroupData = { uuid: 'test-uuid' }; + LmsApiService.createEnterpriseGroup.mockResolvedValue({ status: 201, data: mockGroupData }); + + 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('emails.csv')).toBeInTheDocument(); + expect(screen.getByText('Summary (1)')).toBeInTheDocument(); + expect(screen.getByText('Total members to invite')).toBeInTheDocument(); + expect(screen.getByText('tomhaverford@pawnee.org')).toBeInTheDocument(); + const formFeedbackText = 'Maximum invite at a time: 1000'; + expect(screen.queryByText(formFeedbackText)).not.toBeInTheDocument(); + }, { timeout: EMAIL_ADDRESSES_INPUT_VALUE_DEBOUNCE_DELAY + 1000 }); + + const createButton = screen.getByRole('button', { name: 'Create' }); + userEvent.click(createButton); + expect(mockCreateGroup).toHaveBeenCalledTimes(1); + await waitFor(() => { + expect(mockInvite).toHaveBeenCalledTimes(1); + }); + }); + it('displays system error modal', async () => { + const mockCreateGroup = jest.spyOn(LmsApiService, 'createEnterpriseGroup'); + const mockInvite = jest.spyOn(LmsApiService, 'inviteEnterpriseLearnersToGroup'); + + mockCreateGroup.mockRejectedValue({ + customAttributes: { + httpErrorStatus: 404, + }, + }); + mockInvite.mockRejectedValue({ + customAttributes: { + httpErrorStatus: 404, + }, + }); + render(); + const groupNameInput = screen.getByTestId('group-name'); + expect(groupNameInput).toBeInTheDocument(); + userEvent.type(groupNameInput, 'test group name'); + const createButton = screen.getByRole('button', { name: 'Create' }); + userEvent.click(createButton); + 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/learner-credit-management/invite-modal/InviteMembersModalWrapper.jsx b/src/components/learner-credit-management/invite-modal/InviteMembersModalWrapper.jsx index ea5d8607a6..bebd81248f 100644 --- a/src/components/learner-credit-management/invite-modal/InviteMembersModalWrapper.jsx +++ b/src/components/learner-credit-management/invite-modal/InviteMembersModalWrapper.jsx @@ -115,6 +115,7 @@ const InviteMembersModalWrapper = ({ { +const InviteModalContent = ({ + onEmailAddressesChange, + subsidyAccessPolicy, + isGroupInvite, + onSetGroupName, +}) => { const [learnerEmails, setLearnerEmails] = useState([]); const [inputType, setInputType] = useState('email'); const [emailAddressesInputValue, setEmailAddressesInputValue] = useState(''); - const [memberInviteMetadata, setMemberInviteMetadata] = useState({}); + const [memberInviteMetadata, setMemberInviteMetadata] = useState({ + isValidInput: null, + lowerCasedEmails: [], + duplicateEmails: [], + }); + const [groupNameLength, setGroupNameLength] = useState(0); + const [groupName, setGroupName] = useState(''); const handleEmailAddressInputChange = (e) => { const inputValue = e.target.value; setEmailAddressesInputValue(inputValue); }; + const handleGroupNameChange = useCallback((e) => { + if (!e.target.value) { + setGroupName(''); + onSetGroupName(''); + return; + } + if (e.target.value.length > MAX_LENGTH_GROUP_NAME) { + return; + } + setGroupName(e.target.value); + setGroupNameLength(e.target.value.length); + onSetGroupName(e.target.value); + }, [onSetGroupName]); + const handleEmailAddressesChanged = useCallback((value) => { if (!value) { setLearnerEmails([]); @@ -58,6 +86,52 @@ const InviteModalContent = ({ onEmailAddressesChange, subsidyAccessPolicy }) => } }, [onEmailAddressesChange, learnerEmails]); + if (isGroupInvite) { + return ( + +

+ +

+ + +

Name your group

+ + + {groupNameLength} / {MAX_LENGTH_GROUP_NAME} + + + +
+ + +

Select group members

+

Upload a CSV or select members from the table below.

+ + + +

Details

+ + {isGroupInvite && } +
+ +
+
+ ); + } + return (

Invite members to this budget

@@ -111,6 +185,8 @@ const InviteModalContent = ({ onEmailAddressesChange, subsidyAccessPolicy }) => InviteModalContent.propTypes = { onEmailAddressesChange: PropTypes.func.isRequired, subsidyAccessPolicy: PropTypes.shape(), + isGroupInvite: PropTypes.bool.isRequired, + onSetGroupName: PropTypes.func, }; export default InviteModalContent; diff --git a/src/components/learner-credit-management/invite-modal/InviteModalSummary.jsx b/src/components/learner-credit-management/invite-modal/InviteModalSummary.jsx index 4817e9b1f5..22536bf172 100644 --- a/src/components/learner-credit-management/invite-modal/InviteModalSummary.jsx +++ b/src/components/learner-credit-management/invite-modal/InviteModalSummary.jsx @@ -11,6 +11,7 @@ import InviteModalSummaryDuplicate from './InviteModalSummaryDuplicate'; const InviteModalSummary = ({ memberInviteMetadata, + isGroupInvite, }) => { const { isValidInput, @@ -48,7 +49,7 @@ const InviteModalSummary = ({ if (isEmpty(cardSections)) { cardSections = cardSections.concat( - renderCard(), + renderCard(), ); } @@ -71,7 +72,7 @@ InviteModalSummary.propTypes = { lowerCasedEmails: PropTypes.arrayOf(PropTypes.string), duplicateEmails: PropTypes.arrayOf(PropTypes.string), }).isRequired, - + isGroupInvite: PropTypes.bool, }; export default InviteModalSummary; diff --git a/src/components/learner-credit-management/invite-modal/InviteModalSummaryEmptyState.jsx b/src/components/learner-credit-management/invite-modal/InviteModalSummaryEmptyState.jsx index f5af5723ac..a004df94bb 100644 --- a/src/components/learner-credit-management/invite-modal/InviteModalSummaryEmptyState.jsx +++ b/src/components/learner-credit-management/invite-modal/InviteModalSummaryEmptyState.jsx @@ -1,10 +1,25 @@ import React from 'react'; +import PropTypes from 'prop-types'; -const InviteModalSummaryEmptyState = () => ( - <> -
You haven't entered any members yet.
- Add member emails to get started. - -); +const InviteModalSummaryEmptyState = ({ isGroupInvite }) => { + if (isGroupInvite) { + return ( + <> +
You haven't uploaded any members yet.
+ Upload a CSV file or select members to get started. + + ); + } + return ( + <> +
You haven't entered any members yet.
+ Add member emails to get started. + + ); +}; + +InviteModalSummaryEmptyState.propTypes = { + isGroupInvite: PropTypes.bool, +}; export default InviteModalSummaryEmptyState; diff --git a/src/components/learner-credit-management/invite-modal/InviteSummaryCount.jsx b/src/components/learner-credit-management/invite-modal/InviteSummaryCount.jsx new file mode 100644 index 0000000000..d9e932a1c1 --- /dev/null +++ b/src/components/learner-credit-management/invite-modal/InviteSummaryCount.jsx @@ -0,0 +1,24 @@ +import React from 'react'; +import PropTypes from 'prop-types'; +import { Card } from '@openedx/paragon'; + +const InviteSummaryCount = ({ memberInviteMetadata }) => ( + + + + Total members to invite + + {memberInviteMetadata.lowerCasedEmails.length} + + +); + +InviteSummaryCount.propTypes = { + memberInviteMetadata: PropTypes.shape({ + isValidInput: PropTypes.bool, + lowerCasedEmails: PropTypes.arrayOf(PropTypes.string), + duplicateEmails: PropTypes.arrayOf(PropTypes.string), + }).isRequired, +}; + +export default InviteSummaryCount; diff --git a/src/components/learner-credit-management/invite-modal/tests/InviteMemberModal.test.jsx b/src/components/learner-credit-management/invite-modal/tests/InviteMemberModal.test.jsx index 3da52a3062..a8eb6c1ec1 100644 --- a/src/components/learner-credit-management/invite-modal/tests/InviteMemberModal.test.jsx +++ b/src/components/learner-credit-management/invite-modal/tests/InviteMemberModal.test.jsx @@ -309,4 +309,22 @@ describe('', () => { expect(inviteButton).not.toBeDisabled(); }, { timeout: EMAIL_ADDRESSES_INPUT_VALUE_DEBOUNCE_DELAY + 1000 }); }); + it('renders the groups invite ', async () => { + render(); + const textareaInputLabel = screen.getByLabelText('Member email addresses'); + const textareaInput = textareaInputLabel.closest('textarea'); + userEvent.type(textareaInput, 'oopsallberries@example.com'); + userEvent.type(textareaInput, '{enter}'); + userEvent.type(textareaInput, 'oopsallberries@example.com'); + userEvent.type(textareaInput, '{enter}'); + userEvent.type(textareaInput, 'sillygoosethisisntanemail'); + await waitFor(() => { + expect(screen.getByText('Summary (1)')).toBeInTheDocument(); + expect(screen.getByText('sillygoosethisisntanemail is not a valid email.')).toBeInTheDocument(); + expect(screen.getByText('Members can\'t be invited as entered.')).toBeInTheDocument(); + expect(screen.getByText('Only 1 invite per email address will be sent.')).toBeInTheDocument(); + const inviteButton = screen.getByRole('button', { name: 'Invite' }); + expect(inviteButton).not.toBeDisabled(); + }, { timeout: EMAIL_ADDRESSES_INPUT_VALUE_DEBOUNCE_DELAY + 1000 }); + }); }); diff --git a/src/data/services/LmsApiService.js b/src/data/services/LmsApiService.js index c0bd742b04..0a4483d524 100644 --- a/src/data/services/LmsApiService.js +++ b/src/data/services/LmsApiService.js @@ -45,6 +45,16 @@ class LmsApiService { static enterpriseGroupListUrl = `${LmsApiService.baseUrl}/enterprise/api/v1/enterprise_group/`; + static createEnterpriseGroup(options) { + const postParams = { + name: options.groupName, + enterprise_customer: options.enterpriseUUID, + members: [], + }; + const createEnterpriseGroupUrl = `${LmsApiService.enterpriseGroupListUrl}`; + return LmsApiService.apiClient().post(createEnterpriseGroupUrl, postParams); + } + static fetchEnterpriseSsoOrchestrationRecord(configurationUuid) { const enterpriseSsoOrchestrationFetchUrl = `${LmsApiService.enterpriseSsoOrchestrationUrl}${configurationUuid}`; return LmsApiService.apiClient().get(enterpriseSsoOrchestrationFetchUrl); diff --git a/src/data/services/tests/LmsApiService.test.js b/src/data/services/tests/LmsApiService.test.js index b9a83c92f6..b74d78846e 100644 --- a/src/data/services/tests/LmsApiService.test.js +++ b/src/data/services/tests/LmsApiService.test.js @@ -89,4 +89,29 @@ describe('LmsApiService', () => { const activeCustomer = await LmsApiService.fetchEnterpriseLearnerData({ username: mockUsername }); expect(activeCustomer).toEqual([{ active: true, enterpriseCustomer: { uuid: 'test-uuid' } }]); }); + test('createEnterpriseGroup returns uuid for the post request', async () => { + axios.post.mockReturnValue({ + status: 201, + data: { + uuid: 'test-uuid', + name: 'test-name', + enterprise_customer: 'test-enterprise-customer', + members: [], + }, + }); + const response = await LmsApiService.createEnterpriseGroup({ + name: 'test-name', + enterprise_customer: 'test-customer-uuid', + members: [], + }); + expect(response).toEqual({ + status: 201, + data: { + uuid: 'test-uuid', + name: 'test-name', + enterprise_customer: 'test-enterprise-customer', + members: [], + }, + }); + }); });