diff --git a/src/components/PeopleManagement/CreateGroupModal.jsx b/src/components/PeopleManagement/CreateGroupModal.jsx index 16b7802866..ecf7e9aae3 100644 --- a/src/components/PeopleManagement/CreateGroupModal.jsx +++ b/src/components/PeopleManagement/CreateGroupModal.jsx @@ -113,6 +113,7 @@ const CreateGroupModal = ({ onSetGroupName={setGroupName} onEmailAddressesChange={handleEmailAddressesChange} isGroupInvite + enterpriseUUID={enterpriseUUID} /> { const [learnerEmails, setLearnerEmails] = useState([]); const [emailAddressesInputValue, setEmailAddressesInputValue] = useState(''); @@ -26,9 +28,11 @@ const CreateGroupModalContent = ({ isValidInput: null, lowerCasedEmails: [], duplicateEmails: [], + emailsNotInOrg: [], }); const [groupNameLength, setGroupNameLength] = useState(0); const [groupName, setGroupName] = useState(''); + const { allEnterpriseLearners } = useEnterpriseLearners({ enterpriseUUID }); const handleGroupNameChange = useCallback((e) => { if (!e.target.value) { @@ -86,6 +90,7 @@ const CreateGroupModalContent = ({ useEffect(() => { const inviteMetadata = isInviteEmailAddressesInputValueValid({ learnerEmails, + allEnterpriseLearners, }); setMemberInviteMetadata(inviteMetadata); if (inviteMetadata.canInvite) { @@ -93,7 +98,7 @@ const CreateGroupModalContent = ({ } else { onEmailAddressesChange([]); } - }, [onEmailAddressesChange, learnerEmails]); + }, [onEmailAddressesChange, learnerEmails, allEnterpriseLearners]); return ( @@ -155,6 +160,7 @@ CreateGroupModalContent.propTypes = { onEmailAddressesChange: PropTypes.func.isRequired, onSetGroupName: PropTypes.func, isGroupInvite: PropTypes.bool, + enterpriseUUID: PropTypes.string.isRequired, }; export default CreateGroupModalContent; diff --git a/src/components/PeopleManagement/LearnerNotInOrgErrorState.jsx b/src/components/PeopleManagement/LearnerNotInOrgErrorState.jsx new file mode 100644 index 0000000000..c4421c31c0 --- /dev/null +++ b/src/components/PeopleManagement/LearnerNotInOrgErrorState.jsx @@ -0,0 +1,36 @@ +import React from 'react'; +import { + Card, Hyperlink, Stack, Icon, +} from '@openedx/paragon'; +import classNames from 'classnames'; +import { Error } from '@openedx/paragon/icons'; + +const LearnerNotInOrgErrorState = () => ( + + + + + +
+
Some people can't be added.
+ Check that all people in the file are registered with your organization. + + Learn more + + +
+
+
+
+
+); + +export default LearnerNotInOrgErrorState; diff --git a/src/components/PeopleManagement/tests/CreateGroupModal.test.jsx b/src/components/PeopleManagement/tests/CreateGroupModal.test.jsx index ba55f11ad9..ca0d39fe01 100644 --- a/src/components/PeopleManagement/tests/CreateGroupModal.test.jsx +++ b/src/components/PeopleManagement/tests/CreateGroupModal.test.jsx @@ -17,6 +17,7 @@ import { useEnterpriseLearnersTableData, useGetAllEnterpriseLearnerEmails, } from '../../learner-credit-management/data/hooks/useEnterpriseLearnersTableData'; +import { useEnterpriseLearners } from '../../learner-credit-management/data'; jest.mock('@tanstack/react-query', () => ({ ...jest.requireActual('@tanstack/react-query'), @@ -28,6 +29,10 @@ jest.mock('../../learner-credit-management/data/hooks/useEnterpriseLearnersTable useEnterpriseLearnersTableData: jest.fn(), useGetAllEnterpriseLearnerEmails: 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); @@ -118,6 +123,9 @@ describe('', () => { fetchLearnerEmails: jest.fn(), addButtonState: 'complete', }); + useEnterpriseLearners.mockReturnValue({ + allEnterpriseLearners: ['testuser-3@2u.com', 'testuser-2@2u.com', 'testuser-1@2u.com', 'tomhaverford@pawnee.org'], + }); }); it('Modal renders as expected', async () => { render(); @@ -204,6 +212,29 @@ describe('', () => { expect(mockInvite).toHaveBeenCalledTimes(1); }); }); + it('displays error for email not belonging in an org', async () => { + 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); + useEnterpriseLearners.mockReturnValue({ + allEnterpriseLearners: ['testuser-3@2u.com'], + }); + render(); + const groupNameInput = screen.getByTestId('group-name'); + userEvent.type(groupNameInput, 'test group name'); + 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 mockCreateGroup = jest.spyOn(LmsApiService, 'createEnterpriseGroup'); const mockInvite = jest.spyOn(LmsApiService, 'inviteEnterpriseLearnersToGroup'); diff --git a/src/components/learner-credit-management/cards/data/utils.js b/src/components/learner-credit-management/cards/data/utils.js index 036d3c8bd8..0015bc80ea 100644 --- a/src/components/learner-credit-management/cards/data/utils.js +++ b/src/components/learner-credit-management/cards/data/utils.js @@ -119,12 +119,13 @@ export const isAssignEmailAddressesInputValueValid = ({ * input, including a validation error when appropriate, and whether the member invitation * should proceed. */ -export const isInviteEmailAddressesInputValueValid = ({ learnerEmails }) => { +export const isInviteEmailAddressesInputValueValid = ({ learnerEmails, allEnterpriseLearners = null }) => { let validationError; const learnerEmailsCount = learnerEmails.length; const lowerCasedEmails = []; const invalidEmails = []; const duplicateEmails = []; + const emailsNotInOrg = []; learnerEmails.forEach((email) => { const lowerCasedEmail = email.toLowerCase(); @@ -135,6 +136,9 @@ export const isInviteEmailAddressesInputValueValid = ({ learnerEmails }) => { } else if (lowerCasedEmails.includes(lowerCasedEmail)) { // Check for duplicates (case-insensitive) duplicateEmails.push(email); + // Check if email belongs in the org + } else if (allEnterpriseLearners && !allEnterpriseLearners.includes(email)) { + emailsNotInOrg.push(email); } else { // Add to list of lower-cased emails already handled lowerCasedEmails.push(lowerCasedEmail); @@ -174,6 +178,15 @@ export const isInviteEmailAddressesInputValueValid = ({ learnerEmails }) => { ensureValidationErrorObjectExists(); validationError.reason = 'duplicate_email'; validationError.message = message; + } else if (emailsNotInOrg.length > 0) { + let message = `${emailsNotInOrg[0]} is not available to be added to a group.`; + if (emailsNotInOrg.length > 1) { + message = `${emailsNotInOrg[0]} and ${makePlural(emailsNotInOrg.length - 1, 'other email address')} + are not available to be added to a group.`; + } + ensureValidationErrorObjectExists(); + validationError.reason = 'email_not_in_org'; + validationError.message = message; } return { canInvite, @@ -182,5 +195,6 @@ export const isInviteEmailAddressesInputValueValid = ({ learnerEmails }) => { invalidEmails, isValidInput, validationError, + emailsNotInOrg, }; }; diff --git a/src/components/learner-credit-management/data/hooks/index.js b/src/components/learner-credit-management/data/hooks/index.js index 6f1842953f..21e5e65f8b 100644 --- a/src/components/learner-credit-management/data/hooks/index.js +++ b/src/components/learner-credit-management/data/hooks/index.js @@ -23,3 +23,4 @@ export { default as useContentMetadata } from './useContentMetadata'; export { default as useEnterpriseRemovedGroupMembers } from './useEnterpriseRemovedGroupMembers'; export { default as useEnterpriseFlexGroups } from './useEnterpriseFlexGroups'; export { default as useGroupDropdownToggle } from './useGroupDropdownToggle'; +export { default as useEnterpriseLearners } from './useEnterpriseLearners'; diff --git a/src/components/learner-credit-management/data/hooks/tests/useEnterpriseLearners.test.jsx b/src/components/learner-credit-management/data/hooks/tests/useEnterpriseLearners.test.jsx new file mode 100644 index 0000000000..ab9b1e6c3f --- /dev/null +++ b/src/components/learner-credit-management/data/hooks/tests/useEnterpriseLearners.test.jsx @@ -0,0 +1,48 @@ +import { QueryClientProvider } from '@tanstack/react-query'; +import { renderHook } from '@testing-library/react-hooks'; + +import { camelCaseObject } from '@edx/frontend-platform/utils'; +import LmsApiService from '../../../../../data/services/LmsApiService'; +import { queryClient } from '../../../../test/testUtils'; +import useEnterpriseLearners from '../useEnterpriseLearners'; + +jest.mock('../../../../../data/services/LmsApiService', () => ({ + fetchEnterpriseLearnerData: jest.fn(), +})); + +jest.mock('@edx/frontend-platform/utils', () => ({ + camelCaseObject: jest.fn(), +})); + +const wrapper = ({ children }) => ( + {children} +); + +describe('useEnterpriseLearners', () => { + beforeEach(() => { + jest.clearAllMocks(); + }); + + it('should fetch and return enterprise learners', async () => { + const mockData = [ + { + user: { + email: 'test@2u.com', + }, + }, + ]; + LmsApiService.fetchEnterpriseLearnerData.mockResolvedValue(mockData); + camelCaseObject.mockResolvedValue(mockData); + + const { result, waitForNextUpdate } = renderHook( + () => useEnterpriseLearners({ enterpriseUUID: 'test-id' }), + { wrapper }, + ); + await waitForNextUpdate(); + expect(LmsApiService.fetchEnterpriseLearnerData).toHaveBeenCalledWith({ + enterprise_customer: 'test-id', + }); + expect(camelCaseObject).toHaveBeenCalledWith(mockData); + expect(result.current.allEnterpriseLearners).toEqual(['test@2u.com']); + }); +}); diff --git a/src/components/learner-credit-management/data/hooks/useEnterpriseLearners.js b/src/components/learner-credit-management/data/hooks/useEnterpriseLearners.js new file mode 100644 index 0000000000..ab4bcc595f --- /dev/null +++ b/src/components/learner-credit-management/data/hooks/useEnterpriseLearners.js @@ -0,0 +1,34 @@ +import { useEffect, useState } from 'react'; +import { camelCaseObject } from '@edx/frontend-platform/utils'; +import { logError } from '@edx/frontend-platform/logging'; + +import LmsApiService from '../../../../data/services/LmsApiService'; + +const useEnterpriseLearners = ({ + enterpriseUUID, +}) => { + const [allEnterpriseLearners, setAllEnterpriseLearners] = useState([]); + + useEffect(() => { + const fetchLearnerEmails = async () => { + try { + const options = { + enterprise_customer: enterpriseUUID, + }; + const data = await LmsApiService.fetchEnterpriseLearnerData(options); + const results = await camelCaseObject(data); + const learnerEmails = results.map(result => result?.user?.email); + setAllEnterpriseLearners(learnerEmails); + } catch (error) { + logError(error); + } + }; + fetchLearnerEmails(); + }, [enterpriseUUID]); + + return { + allEnterpriseLearners, + }; +}; + +export default useEnterpriseLearners; diff --git a/src/components/learner-credit-management/invite-modal/InviteModalContent.jsx b/src/components/learner-credit-management/invite-modal/InviteModalContent.jsx index 3495e9a897..426f468aed 100644 --- a/src/components/learner-credit-management/invite-modal/InviteModalContent.jsx +++ b/src/components/learner-credit-management/invite-modal/InviteModalContent.jsx @@ -31,7 +31,12 @@ const InviteModalContent = ({ 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: [], + emailsNotInOrg: [], + }); const [groupMemberEmails, setGroupMemberEmails] = useState([]); const [checkedGroups, setCheckedGroups] = useState({}); const [dropdownToggleLabel, setDropdownToggleLabel] = useState(GROUP_DROPDOWN_TEXT); diff --git a/src/components/learner-credit-management/invite-modal/InviteModalInputFeedback.jsx b/src/components/learner-credit-management/invite-modal/InviteModalInputFeedback.jsx index fec235f45a..ab2916a75c 100644 --- a/src/components/learner-credit-management/invite-modal/InviteModalInputFeedback.jsx +++ b/src/components/learner-credit-management/invite-modal/InviteModalInputFeedback.jsx @@ -11,6 +11,13 @@ const InviteModalInputFeedback = ({ memberInviteMetadata, isCsvUpload }) => { ); } + if (memberInviteMetadata.emailsNotInOrg.length > 0) { + return ( + + {memberInviteMetadata.validationError.message} + + ); + } return ( {memberInviteMetadata.validationError.message} @@ -46,6 +53,7 @@ InviteModalInputFeedback.propTypes = { lowerCasedEmails: PropTypes.arrayOf( PropTypes.shape({}), ), + emailsNotInOrg: PropTypes.arrayOf(PropTypes.string), }), isCsvUpload: PropTypes.bool, }; diff --git a/src/components/learner-credit-management/invite-modal/InviteModalSummary.jsx b/src/components/learner-credit-management/invite-modal/InviteModalSummary.jsx index 22536bf172..e7f17fc802 100644 --- a/src/components/learner-credit-management/invite-modal/InviteModalSummary.jsx +++ b/src/components/learner-credit-management/invite-modal/InviteModalSummary.jsx @@ -8,6 +8,7 @@ import InviteModalSummaryEmptyState from './InviteModalSummaryEmptyState'; import InviteModalSummaryLearnerList from './InviteModalSummaryLearnerList'; import InviteModalSummaryErrorState from './InviteModalSummaryErrorState'; import InviteModalSummaryDuplicate from './InviteModalSummaryDuplicate'; +import LearnerNotInOrgErrorState from '../../PeopleManagement/LearnerNotInOrgErrorState'; const InviteModalSummary = ({ memberInviteMetadata, @@ -17,7 +18,9 @@ const InviteModalSummary = ({ isValidInput, lowerCasedEmails, duplicateEmails, + emailsNotInOrg, } = memberInviteMetadata; + const hasEmailsNotInOrg = emailsNotInOrg.length > 0; const renderCard = (contents, showErrorHighlight) => ( , + ); + } + if (isEmpty(cardSections)) { cardSections = cardSections.concat( renderCard(), @@ -71,6 +80,7 @@ InviteModalSummary.propTypes = { isValidInput: PropTypes.bool, lowerCasedEmails: PropTypes.arrayOf(PropTypes.string), duplicateEmails: PropTypes.arrayOf(PropTypes.string), + emailsNotInOrg: PropTypes.arrayOf(PropTypes.string), }).isRequired, isGroupInvite: PropTypes.bool, }; diff --git a/src/components/learner-credit-management/invite-modal/InviteSummaryCount.jsx b/src/components/learner-credit-management/invite-modal/InviteSummaryCount.jsx index a878367473..171a48c4d6 100644 --- a/src/components/learner-credit-management/invite-modal/InviteSummaryCount.jsx +++ b/src/components/learner-credit-management/invite-modal/InviteSummaryCount.jsx @@ -18,6 +18,7 @@ InviteSummaryCount.propTypes = { isValidInput: PropTypes.bool, lowerCasedEmails: PropTypes.arrayOf(PropTypes.string), duplicateEmails: PropTypes.arrayOf(PropTypes.string), + emailsNotInOrg: PropTypes.arrayOf(PropTypes.string), }).isRequired, };