Skip to content

Commit

Permalink
feat: verify learner email belongs to org (#1356)
Browse files Browse the repository at this point in the history
  • Loading branch information
katrinan029 authored Nov 26, 2024
1 parent 3bb7650 commit c9bd93f
Show file tree
Hide file tree
Showing 12 changed files with 198 additions and 3 deletions.
1 change: 1 addition & 0 deletions src/components/PeopleManagement/CreateGroupModal.jsx
Original file line number Diff line number Diff line change
Expand Up @@ -113,6 +113,7 @@ const CreateGroupModal = ({
onSetGroupName={setGroupName}
onEmailAddressesChange={handleEmailAddressesChange}
isGroupInvite
enterpriseUUID={enterpriseUUID}
/>
</FullscreenModal>
<SystemErrorAlertModal
Expand Down
8 changes: 7 additions & 1 deletion src/components/PeopleManagement/CreateGroupModalContent.jsx
Original file line number Diff line number Diff line change
Expand Up @@ -14,21 +14,25 @@ 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 CreateGroupModalContent = ({
onEmailAddressesChange,
onSetGroupName,
isGroupInvite,
enterpriseUUID,
}) => {
const [learnerEmails, setLearnerEmails] = useState([]);
const [emailAddressesInputValue, setEmailAddressesInputValue] = useState('');
const [memberInviteMetadata, setMemberInviteMetadata] = useState({
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) {
Expand Down Expand Up @@ -86,14 +90,15 @@ const CreateGroupModalContent = ({
useEffect(() => {
const inviteMetadata = isInviteEmailAddressesInputValueValid({
learnerEmails,
allEnterpriseLearners,
});
setMemberInviteMetadata(inviteMetadata);
if (inviteMetadata.canInvite) {
onEmailAddressesChange(learnerEmails, { canInvite: true });
} else {
onEmailAddressesChange([]);
}
}, [onEmailAddressesChange, learnerEmails]);
}, [onEmailAddressesChange, learnerEmails, allEnterpriseLearners]);

return (
<Container size="lg" className="py-3">
Expand Down Expand Up @@ -155,6 +160,7 @@ CreateGroupModalContent.propTypes = {
onEmailAddressesChange: PropTypes.func.isRequired,
onSetGroupName: PropTypes.func,
isGroupInvite: PropTypes.bool,
enterpriseUUID: PropTypes.string.isRequired,
};

export default CreateGroupModalContent;
36 changes: 36 additions & 0 deletions src/components/PeopleManagement/LearnerNotInOrgErrorState.jsx
Original file line number Diff line number Diff line change
@@ -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 = () => (
<Stack gap={2.5} className="mb-4">
<Card
className={classNames(
'invite-modal-summary-card rounded-0 shadow-none',
{ invalid: true },
)}
>
<Card.Section>
<Stack direction="horizontal" gap={3}>
<Icon className="text-danger" src={Error} />
<div>
<div className="h4 mb-0">Some people can&apos;t be added.</div>
<span className="small">Check that all people in the file are registered with your organization.
<Hyperlink
destination="https://www.edx.org"
target="_blank"
>
Learn more
</Hyperlink>
</span>
</div>
</Stack>
</Card.Section>
</Card>
</Stack>
);

export default LearnerNotInOrgErrorState;
31 changes: 31 additions & 0 deletions src/components/PeopleManagement/tests/CreateGroupModal.test.jsx
Original file line number Diff line number Diff line change
Expand Up @@ -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'),
Expand All @@ -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);
Expand Down Expand Up @@ -118,6 +123,9 @@ describe('<CreateGroupModal />', () => {
fetchLearnerEmails: jest.fn(),
addButtonState: 'complete',
});
useEnterpriseLearners.mockReturnValue({
allEnterpriseLearners: ['[email protected]', '[email protected]', '[email protected]', '[email protected]'],
});
});
it('Modal renders as expected', async () => {
render(<CreateGroupModalWrapper />);
Expand Down Expand Up @@ -204,6 +212,29 @@ describe('<CreateGroupModal />', () => {
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: ['[email protected]'],
});
render(<CreateGroupModalWrapper />);
const groupNameInput = screen.getByTestId('group-name');
userEvent.type(groupNameInput, 'test group name');
const fakeFile = new File(['[email protected]'], '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(/[email protected] 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');
Expand Down
16 changes: 15 additions & 1 deletion src/components/learner-credit-management/cards/data/utils.js
Original file line number Diff line number Diff line change
Expand Up @@ -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();
Expand All @@ -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);
Expand Down Expand Up @@ -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,
Expand All @@ -182,5 +195,6 @@ export const isInviteEmailAddressesInputValueValid = ({ learnerEmails }) => {
invalidEmails,
isValidInput,
validationError,
emailsNotInOrg,
};
};
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Original file line number Diff line number Diff line change
@@ -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 }) => (
<QueryClientProvider client={queryClient()}>{children}</QueryClientProvider>
);

describe('useEnterpriseLearners', () => {
beforeEach(() => {
jest.clearAllMocks();
});

it('should fetch and return enterprise learners', async () => {
const mockData = [
{
user: {
email: '[email protected]',
},
},
];
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(['[email protected]']);
});
});
Original file line number Diff line number Diff line change
@@ -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;
Original file line number Diff line number Diff line change
Expand Up @@ -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);
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,13 @@ const InviteModalInputFeedback = ({ memberInviteMetadata, isCsvUpload }) => {
</Form.Control.Feedback>
);
}
if (memberInviteMetadata.emailsNotInOrg.length > 0) {
return (
<Form.Control.Feedback type="invalid">
{memberInviteMetadata.validationError.message}
</Form.Control.Feedback>
);
}
return (
<Form.Control.Feedback className="text-info-500">
{memberInviteMetadata.validationError.message}
Expand Down Expand Up @@ -46,6 +53,7 @@ InviteModalInputFeedback.propTypes = {
lowerCasedEmails: PropTypes.arrayOf(
PropTypes.shape({}),
),
emailsNotInOrg: PropTypes.arrayOf(PropTypes.string),
}),
isCsvUpload: PropTypes.bool,
};
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand All @@ -17,7 +18,9 @@ const InviteModalSummary = ({
isValidInput,
lowerCasedEmails,
duplicateEmails,
emailsNotInOrg,
} = memberInviteMetadata;
const hasEmailsNotInOrg = emailsNotInOrg.length > 0;
const renderCard = (contents, showErrorHighlight) => (
<Stack gap={2.5} className="mb-4">
<Card
Expand Down Expand Up @@ -47,6 +50,12 @@ const InviteModalSummary = ({
);
}

if (hasEmailsNotInOrg) {
cardSections = cardSections.concat(
<LearnerNotInOrgErrorState />,
);
}

if (isEmpty(cardSections)) {
cardSections = cardSections.concat(
renderCard(<InviteModalSummaryEmptyState isGroupInvite={isGroupInvite} />),
Expand All @@ -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,
};
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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,
};

Expand Down

0 comments on commit c9bd93f

Please sign in to comment.