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)}
+
+
+
+
+ {intl.formatMessage(
+ messages.bulkEmailTaskEmailLearnersAddEmailButton,
+ )}
+
+
+ {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();
+ });
});