diff --git a/public/index.html b/public/index.html index e2dd3ec5..9f6891ae 100644 --- a/public/index.html +++ b/public/index.html @@ -4,7 +4,6 @@ Communications | <%= process.env.SITE_NAME %> -
diff --git a/src/components/bulk-email-tool/bulk-email-form/BulkEmailForm.jsx b/src/components/bulk-email-tool/bulk-email-form/BulkEmailForm.jsx index f0bdc316..24295b05 100644 --- a/src/components/bulk-email-tool/bulk-email-form/BulkEmailForm.jsx +++ b/src/components/bulk-email-tool/bulk-email-form/BulkEmailForm.jsx @@ -60,6 +60,7 @@ function BulkEmailForm(props) { const [isTaskAlertOpen, openTaskAlert, closeTaskAlert] = useToggle(false); const [isScheduled, toggleScheduled] = useState(false); const isMobile = useMobileResponsive(); + const [learnersEmailList, setLearnersEmailList] = useState([]); /** * Since we are working with both an old and new API endpoint, the body for the POST @@ -69,12 +70,14 @@ function BulkEmailForm(props) { * @returns formatted Data */ const formatDataForFormAction = (action) => { + const individualLearnersList = learnersEmailList.map(({ email }) => email); if (action === FORM_ACTIONS.POST) { const emailData = new FormData(); emailData.append('action', 'send'); emailData.append('send_to', JSON.stringify(editor.emailRecipients)); emailData.append('subject', editor.emailSubject); emailData.append('message', editor.emailBody); + emailData.append('individual_learners_emails', JSON.stringify(individualLearnersList)); if (isScheduled) { emailData.append('schedule', new Date(`${editor.scheduleDate} ${editor.scheduleTime}`).toISOString()); } @@ -87,6 +90,7 @@ function BulkEmailForm(props) { subject: editor.emailSubject, message: editor.emailBody, id: editor.emailId, + individual_learners_emails: individualLearnersList, }, schedule: isScheduled ? new Date(`${editor.scheduleDate} ${editor.scheduleTime}`).toISOString() : null, }; @@ -125,6 +129,8 @@ function BulkEmailForm(props) { dispatch(addRecipient(event.target.value)); // if "All Learners" is checked then we want to remove any cohorts, verified learners, and audit learners if (event.target.value === 'learners') { + // Clean the emails list when select "All Learners" + setLearnersEmailList([]); editor.emailRecipients.forEach(recipient => { if (/^cohort/.test(recipient) || /^track/.test(recipient)) { dispatch(removeRecipient(recipient)); @@ -136,6 +142,20 @@ function BulkEmailForm(props) { } }; + // When the user clicks the button "Add email" gets the email and an id + const handleEmailLearnersSelected = (emailSelected) => { + const isEmailAdded = learnersEmailList.some(({ email }) => email === emailSelected.email); + if (!isEmailAdded) { + setLearnersEmailList([...learnersEmailList, emailSelected]); + } + }; + + // To delete an email from learners list, that list is on the bottom of the input autocomplete + const handleDeleteEmailLearnerSelected = (idDelete) => { + const setLearnersEmailListUpdated = learnersEmailList.filter(({ id }) => id !== idDelete); + setLearnersEmailList(setLearnersEmailListUpdated); + }; + const validateDateTime = (date, time) => { if (isScheduled) { const now = new Date(); @@ -209,6 +229,26 @@ function BulkEmailForm(props) { // eslint-disable-next-line react-hooks/exhaustive-deps }, [isScheduled, editor.editMode, editor.isLoading, editor.errorRetrievingData, editor.formComplete]); + /* + This will be checking if there are emails added to the learnersEmailList state + if so, we will delete emailRecipients "learners" because that is for all learners + if not, we will delete the individual-learners from emailRecipients because of we won't use the emails + */ + useEffect(() => { + const hasLearners = learnersEmailList.length > 0; + const hasIndividualLearners = editor.emailRecipients.includes('individual-learners'); + const hasLearnersGroup = editor.emailRecipients.includes('learners'); + + if (hasLearners && !hasIndividualLearners) { + dispatch(addRecipient('individual-learners')); + if (hasLearnersGroup) { + dispatch(removeRecipient('learners')); + } + } else if (!hasLearners && hasIndividualLearners) { + dispatch(removeRecipient('individual-learners')); + } + }, [dispatch, editor.emailRecipients, learnersEmailList]); + const AlertMessage = () => ( <>

{intl.formatMessage(messages.bulkEmailTaskAlertRecipients, { subject: editor.emailSubject })}

@@ -272,6 +312,9 @@ function BulkEmailForm(props) { handleCheckboxes={onRecipientChange} additionalCohorts={cohorts} isValid={emailFormValidation.recipients} + learnersEmailList={learnersEmailList} + handleEmailLearnersSelected={handleEmailLearnersSelected} + handleDeleteEmailLearnerSelected={handleDeleteEmailLearnerSelected} /> {intl.formatMessage(messages.bulkEmailSubjectLabel)} diff --git a/src/components/bulk-email-tool/bulk-email-form/bulk-email-recipient/BulkEmailRecipient.jsx b/src/components/bulk-email-tool/bulk-email-form/bulk-email-recipient/BulkEmailRecipient.jsx index 2bafefc9..3b634b31 100644 --- a/src/components/bulk-email-tool/bulk-email-form/bulk-email-recipient/BulkEmailRecipient.jsx +++ b/src/components/bulk-email-tool/bulk-email-form/bulk-email-recipient/BulkEmailRecipient.jsx @@ -3,6 +3,7 @@ import PropTypes from 'prop-types'; import { Form } from '@edx/paragon'; import { FormattedMessage } from '@edx/frontend-platform/i18n'; +import EmailList from './components/EmailList'; import './bulkEmailRecepient.scss'; const DEFAULT_GROUPS = { @@ -11,10 +12,14 @@ const DEFAULT_GROUPS = { ALL_LEARNERS: 'learners', VERIFIED: 'track:verified', AUDIT: 'track:audit', + INDIVIDUAL_LEARNERS: 'individual-learners', }; export default function BulkEmailRecipient(props) { - const { handleCheckboxes, selectedGroups, additionalCohorts } = props; + const { + handleCheckboxes, selectedGroups, additionalCohorts, handleEmailLearnersSelected, + learnersEmailList, handleDeleteEmailLearnerSelected, + } = props; return ( @@ -103,6 +108,12 @@ export default function BulkEmailRecipient(props) { description="A selectable choice from a list of potential email recipients" /> + + {!props.isValid && ( @@ -120,6 +131,9 @@ export default function BulkEmailRecipient(props) { BulkEmailRecipient.defaultProps = { isValid: true, additionalCohorts: [], + handleEmailLearnersSelected: () => {}, + handleDeleteEmailLearnerSelected: () => {}, + learnersEmailList: [], }; BulkEmailRecipient.propTypes = { @@ -127,4 +141,12 @@ BulkEmailRecipient.propTypes = { handleCheckboxes: PropTypes.func.isRequired, isValid: PropTypes.bool, additionalCohorts: PropTypes.arrayOf(PropTypes.string), + handleEmailLearnersSelected: PropTypes.func, + handleDeleteEmailLearnerSelected: PropTypes.func, + learnersEmailList: PropTypes.arrayOf( + PropTypes.shape({ + id: PropTypes.oneOfType([PropTypes.string, PropTypes.number]).isRequired, + email: PropTypes.string.isRequired, + }), + ), }; diff --git a/src/components/bulk-email-tool/bulk-email-form/bulk-email-recipient/components/EmailList/index.jsx b/src/components/bulk-email-tool/bulk-email-form/bulk-email-recipient/components/EmailList/index.jsx new file mode 100644 index 00000000..56a657c2 --- /dev/null +++ b/src/components/bulk-email-tool/bulk-email-form/bulk-email-recipient/components/EmailList/index.jsx @@ -0,0 +1,129 @@ +import React, { useState } from 'react'; +import { + Form, Chip, Container, Button, +} from '@edx/paragon'; +import { Person, Close, SupervisedUserCircle } from '@edx/paragon/icons'; +import PropTypes from 'prop-types'; +import { injectIntl, intlShape } from '@edx/frontend-platform/i18n'; +import messages from '../../../messages'; + +import { isValidEmail } from './utils'; +import './styles.scss'; + +function EmailList(props) { + const { + handleEmailLearnersSelected, learnersEmailList, handleDeleteEmailLearnerSelected, + intl, + } = props; + + const [emailListAdded, setEmailListAdded] = useState(false); + const [emailInputValue, setEmailInputValue] = useState(''); + const [invalidEmailError, setInvalidEmailError] = useState(false); + + const handleDeleteEmailSelected = (id) => { + if (handleDeleteEmailLearnerSelected) { + handleDeleteEmailLearnerSelected(id); + } + }; + + const handleChangeEmailInput = ({ target: { value } }) => { + setEmailInputValue(value); + if (isValidEmail(value)) { + setInvalidEmailError(false); + } + }; + + const handleAddEmail = () => { + if (!emailInputValue.length) { return; } + if (!emailListAdded) { + setEmailListAdded(true); + } + if (isValidEmail(emailInputValue)) { + const emailFormatted = emailInputValue.toLocaleLowerCase(); + const currentDateTime = new Date().getTime(); + const data = { id: currentDateTime, email: emailFormatted }; + handleEmailLearnersSelected(data); + setInvalidEmailError(false); + setEmailInputValue(''); + } else { + setInvalidEmailError(true); + } + }; + + return ( + + + + {intl.formatMessage(messages.bulkEmailTaskEmailLearnersInputLabel)} + + + + + + {invalidEmailError && ( + + {intl.formatMessage( + messages.bulkEmailTaskEmailLearnersErrorMessage, + )} + + )} + + + + {intl.formatMessage(messages.bulkEmailTaskEmailLearnersListLabel)} + + {learnersEmailList.map(({ id, email }) => ( + handleDeleteEmailSelected(id)} + key={id} + data-testid="email-list-chip" + > + {email} + + ))} + + + ); +} + +EmailList.defaultProps = { + handleEmailLearnersSelected: () => {}, + handleDeleteEmailLearnerSelected: () => {}, + learnersEmailList: [], +}; + +EmailList.propTypes = { + handleEmailLearnersSelected: PropTypes.func, + handleDeleteEmailLearnerSelected: PropTypes.func, + learnersEmailList: PropTypes.arrayOf( + PropTypes.shape({ + id: PropTypes.oneOfType([PropTypes.string, PropTypes.number]).isRequired, + email: PropTypes.string.isRequired, + }), + ), + intl: intlShape.isRequired, +}; + +export default injectIntl(EmailList); diff --git a/src/components/bulk-email-tool/bulk-email-form/bulk-email-recipient/components/EmailList/styles.scss b/src/components/bulk-email-tool/bulk-email-form/bulk-email-recipient/components/EmailList/styles.scss new file mode 100644 index 00000000..efa0b22d --- /dev/null +++ b/src/components/bulk-email-tool/bulk-email-form/bulk-email-recipient/components/EmailList/styles.scss @@ -0,0 +1,18 @@ +.email-list { + width: 100%; + display: flex; + flex-wrap: wrap; + border: 1px solid #ccc; + padding: 10px; + margin-top: 16px; +} + +.email-chip { + background-color: #f0f0f0; + border: 1px solid #ccc; + border-radius: 20px; + padding: 5px 10px; + margin: 5px; + display: flex; + align-items: center; +} diff --git a/src/components/bulk-email-tool/bulk-email-form/bulk-email-recipient/components/EmailList/utils.js b/src/components/bulk-email-tool/bulk-email-form/bulk-email-recipient/components/EmailList/utils.js new file mode 100644 index 00000000..41936379 --- /dev/null +++ b/src/components/bulk-email-tool/bulk-email-form/bulk-email-recipient/components/EmailList/utils.js @@ -0,0 +1,5 @@ +/* eslint-disable import/prefer-default-export */ +// ref: https://github.com/openedx/frontend-app-authn/blob/master/src/data/constants.js#L31 +const emailRegex = /^[-!#$%&'*+/=?^_`{}|~0-9A-Za-z]+(\.[-!#$%&'*+/=?^_`{}|~0-9A-Za-z]+)*'|^"([\x20-\x21\x23-\x5b\x5d-\x7e]|\\[\x20-\x7e])*"|@((?:[A-Za-z0-9](?:[A-Za-z0-9-]{0,61}[A-Za-z0-9])?\.)+)(?:[A-Za-z0-9-]{2,63})|\[(25[0-5]|2[0-4]\d|[0-1]?\d?\d)(\.(25[0-5]|2[0-4]\d|[0-1]?\d?\d)){3}]$/; + +export const isValidEmail = (email) => emailRegex.test(email); diff --git a/src/components/bulk-email-tool/bulk-email-form/bulk-email-recipient/test/EmailList.test.jsx b/src/components/bulk-email-tool/bulk-email-form/bulk-email-recipient/test/EmailList.test.jsx new file mode 100644 index 00000000..5b0a7cd0 --- /dev/null +++ b/src/components/bulk-email-tool/bulk-email-form/bulk-email-recipient/test/EmailList.test.jsx @@ -0,0 +1,85 @@ +import React from 'react'; +import { screen, fireEvent } from '@testing-library/react'; +import { render, initializeMockApp } from '../../../../../setupTest'; +import EmailList from '../components/EmailList'; + +describe('EmailList Component', () => { + beforeAll(() => { + // Call initializeMockApp to configure authService + initializeMockApp(); + }); + + const mockLearnersEmailList = [ + { id: '1', email: 'user1@example.com' }, + { id: '2', email: 'user2@example.com' }, + ]; + + it('renders the component without errors', () => { + render(); + }); + + it('renders the component with main components', () => { + render(); + + // Check if the component renders without errors + const emailInputLabel = screen.getByTestId('learners-email-input-label'); + const emailInput = screen.getByTestId('learners-email-input'); + const emailAddButton = screen.getByTestId('learners-email-add-button'); + const emailListLabel = screen.getByTestId('learners-email-list-label'); + + expect(emailInputLabel).toBeInTheDocument(); + expect(emailInput).toBeInTheDocument(); + expect(emailAddButton).toBeInTheDocument(); + expect(emailListLabel).toBeInTheDocument(); + }); + + it('should render two email chips', () => { + render(); + + const emailChips = screen.getAllByTestId('email-list-chip'); + + expect(emailChips).toHaveLength(2); + }); + + it('displays an error message for invalid email', () => { + render(); + + // Enter an invalid email + const emailInput = screen.getByTestId('learners-email-input'); + fireEvent.change(emailInput, { target: { value: 'invalid-email' } }); + + // Click the add button + const emailAddButton = screen.getByTestId('learners-email-add-button'); + fireEvent.click(emailAddButton); + + // Check if the error message is displayed + const errorMessage = screen.getByText('Invalid email address'); + expect(errorMessage).toBeInTheDocument(); + }); + + it('Should remove an email chip when handleDeleteEmail is called', () => { + const mockHandleDeleteEmail = jest.fn(); + + render( + , + ); + + const emailChips = screen.getAllByTestId('email-list-chip'); + // Iterate through the email chips to find the button inside each one + emailChips.forEach((chip) => { + const deleteButton = chip.querySelector('[role="button"]'); + fireEvent.click(deleteButton); + }); + + // Ensure that handleDeleteEmail is called for each email chip + expect(mockHandleDeleteEmail).toHaveBeenCalledTimes(mockLearnersEmailList.length); + + // Check that handleDeleteEmail is called with the correct email IDs + expect(mockHandleDeleteEmail).toHaveBeenCalledWith(mockLearnersEmailList[0].id); + expect(mockHandleDeleteEmail).toHaveBeenCalledWith(mockLearnersEmailList[1].id); + }); +}); diff --git a/src/components/bulk-email-tool/bulk-email-form/messages.js b/src/components/bulk-email-tool/bulk-email-form/messages.js index c304c072..049cb02f 100644 --- a/src/components/bulk-email-tool/bulk-email-form/messages.js +++ b/src/components/bulk-email-tool/bulk-email-form/messages.js @@ -114,6 +114,31 @@ const messages = defineMessages({ defaultMessage: 'This will not create a new scheduled email task and instead overwrite the one currently selected. Do you want to overwrite this scheduled email?', description: 'This alert pops up before submitting when editing an email that has already been scheduled', }, + bulkEmailTaskEmailLearnersInputLabel: { + id: 'bulk.email.task.learners.input.label', + defaultMessage: 'Add individual learner', + description: 'Input autocomplete label for learners email', + }, + bulkEmailTaskEmailLearnersInputPlaceholder: { + id: 'bulk.email.task.learners.input.placeholder', + defaultMessage: 'Type an email address', + description: 'Placeholder for input to add learners email', + }, + bulkEmailTaskEmailLearnersListLabel: { + id: 'bulk.email.task.learners.list.label', + defaultMessage: 'Recipients', + description: 'Title for learners email list', + }, + bulkEmailTaskEmailLearnersErrorMessage: { + id: 'bulk.email.task.learners.list.error.message', + defaultMessage: 'Invalid email address', + description: 'Error message when email address is invalid', + }, + bulkEmailTaskEmailLearnersAddEmailButton: { + id: 'bulk.email.task.learners.list.add.button', + defaultMessage: 'Add email', + description: 'Button to add a new email to learners list', + }, }); export default messages; diff --git a/src/components/bulk-email-tool/bulk-email-form/test/BulkEmailForm.test.jsx b/src/components/bulk-email-tool/bulk-email-form/test/BulkEmailForm.test.jsx index b7a29039..245860f2 100644 --- a/src/components/bulk-email-tool/bulk-email-form/test/BulkEmailForm.test.jsx +++ b/src/components/bulk-email-tool/bulk-email-form/test/BulkEmailForm.test.jsx @@ -170,4 +170,31 @@ describe('bulk-email-form', () => { fireEvent.click(continueButton); expect(dispatchMock).toHaveBeenCalled(); }); + test('learnersEmailList state is updated correctly', () => { + const { getByTestId } = render( + renderBulkEmailFormContext({ + editor: { + editMode: true, + emailBody: 'test', + emailSubject: 'test', + emailRecipients: ['test'], + scheduleDate: formatDate(tomorrow), + scheduleTime: '10:00', + schedulingId: 1, + emailId: 1, + isLoading: false, + errorRetrievingData: false, + }, + }), + ); + + const learnersEmailAddButton = getByTestId('learners-email-add-button'); + const emailLearnerInput = getByTestId('learners-email-input'); + fireEvent.change(emailLearnerInput, { target: { value: 'test@email.com' } }); + expect(emailLearnerInput.value).toBe('test@email.com'); + fireEvent.click(learnersEmailAddButton); + // clean the input after adding an email learner + expect(emailLearnerInput.value).toBe(''); + expect(dispatchMock).toHaveBeenCalled(); + }); });