diff --git a/package-lock.json b/package-lock.json
index de21bbf0..ff22d297 100644
--- a/package-lock.json
+++ b/package-lock.json
@@ -24,6 +24,7 @@
"@loadable/component": "^5.15.3",
"@openedx-plugins/communications-app-body-email-form": "file:plugins/communications-app/BodyForm",
"@openedx-plugins/communications-app-check-box-form": "file:plugins/communications-app/CheckBoxForm",
+ "@openedx-plugins/communications-app-individual-emails": "file:plugins/communications-app/IndividualEmails",
"@openedx-plugins/communications-app-input-form": "file:plugins/communications-app/InputForm",
"@openedx-plugins/communications-app-instructions-pro-freading": "file:plugins/communications-app/InstructionsProfreading",
"@openedx-plugins/communications-app-recipients-checks": "file:plugins/communications-app/RecipientsForm",
@@ -39,6 +40,7 @@
"popper.js": "1.16.1",
"prop-types": "15.8.1",
"react": "17.0.2",
+ "react-bootstrap-typeahead": "^6.3.2",
"react-dom": "17.0.2",
"react-helmet": "^6.1.0",
"react-redux": "7.2.9",
@@ -47,7 +49,8 @@
"redux": "4.2.0",
"regenerator-runtime": "0.13.11",
"tinymce": "5.10.7",
- "use-deep-compare-effect": "^1.8.1"
+ "use-deep-compare-effect": "^1.8.1",
+ "uuid": "^9.0.1"
},
"devDependencies": {
"@edx/browserslist-config": "^1.2.0",
@@ -5495,6 +5498,10 @@
"resolved": "plugins/communications-app/CheckBoxForm",
"link": true
},
+ "node_modules/@openedx-plugins/communications-app-individual-emails": {
+ "resolved": "plugins/communications-app/IndividualEmails",
+ "link": true
+ },
"node_modules/@openedx-plugins/communications-app-input-form": {
"resolved": "plugins/communications-app/InputForm",
"link": true
@@ -8419,6 +8426,11 @@
"resolved": "https://registry.npmjs.org/safe-buffer/-/safe-buffer-5.1.2.tgz",
"integrity": "sha512-Gd2UZBJDkXlY7GbJxfsE8/nvKkUEU1G38c1siN6QP6a9PT9MmHB8GnpscSmMJSoF8LOIrt8ud/wPtojys4G6+g=="
},
+ "node_modules/compute-scroll-into-view": {
+ "version": "3.1.0",
+ "resolved": "https://registry.npmjs.org/compute-scroll-into-view/-/compute-scroll-into-view-3.1.0.tgz",
+ "integrity": "sha512-rj8l8pD4bJ1nx+dAkMhV1xB5RuZEyVysfxJqB1pRchh1KVvwOv9b7CGB8ZfjTImVv2oF+sYMUkMZq6Na5Ftmbg=="
+ },
"node_modules/concat-map": {
"version": "0.0.1",
"license": "MIT"
@@ -18461,6 +18473,32 @@
"react-dom": ">=16.8.0"
}
},
+ "node_modules/react-bootstrap-typeahead": {
+ "version": "6.3.2",
+ "resolved": "https://registry.npmjs.org/react-bootstrap-typeahead/-/react-bootstrap-typeahead-6.3.2.tgz",
+ "integrity": "sha512-N5Mb0WlSSMcD7Z0pcCypILgIuECybev0hl4lsnCa5lbXTnN4QdkuHLGuTLSlXBwm1ZMFpOc2SnsdSRgeFiF+Ow==",
+ "dependencies": {
+ "@babel/runtime": "^7.14.6",
+ "@popperjs/core": "^2.10.2",
+ "@restart/hooks": "^0.4.0",
+ "classnames": "^2.2.0",
+ "fast-deep-equal": "^3.1.1",
+ "invariant": "^2.2.1",
+ "lodash.debounce": "^4.0.8",
+ "prop-types": "^15.5.8",
+ "react-overlays": "^5.2.0",
+ "react-popper": "^2.2.5",
+ "scroll-into-view-if-needed": "^3.1.0",
+ "warning": "^4.0.1"
+ },
+ "engines": {
+ "node": ">=18.0.0"
+ },
+ "peerDependencies": {
+ "react": ">=16.8.0",
+ "react-dom": ">=16.8.0"
+ }
+ },
"node_modules/react-clientside-effect": {
"version": "1.2.6",
"license": "MIT",
@@ -19843,6 +19881,14 @@
"url": "https://opencollective.com/webpack"
}
},
+ "node_modules/scroll-into-view-if-needed": {
+ "version": "3.1.0",
+ "resolved": "https://registry.npmjs.org/scroll-into-view-if-needed/-/scroll-into-view-if-needed-3.1.0.tgz",
+ "integrity": "sha512-49oNpRjWRvnU8NyGVmUaYG4jtTkNonFZI86MmGRDqBphEK2EXT9gdEUoQPZhuBM8yWHxCWbobltqYO5M4XrUvQ==",
+ "dependencies": {
+ "compute-scroll-into-view": "^3.0.2"
+ }
+ },
"node_modules/select-hose": {
"version": "2.0.0",
"resolved": "https://registry.npmjs.org/select-hose/-/select-hose-2.0.0.tgz",
@@ -21794,8 +21840,13 @@
}
},
"node_modules/uuid": {
- "version": "9.0.0",
- "license": "MIT",
+ "version": "9.0.1",
+ "resolved": "https://registry.npmjs.org/uuid/-/uuid-9.0.1.tgz",
+ "integrity": "sha512-b+1eJOlsR9K8HJpow9Ok3fiWOWSIcIzXodvv0rQjVoOVNpWMpxf1wZNpt4y9h10odCNrqnYp1OBzRktckBe3sA==",
+ "funding": [
+ "https://github.com/sponsors/broofa",
+ "https://github.com/sponsors/ctavan"
+ ],
"bin": {
"uuid": "dist/bin/uuid"
}
@@ -22586,6 +22637,26 @@
}
}
},
+ "plugins/communications-app/IndividualEmails": {
+ "name": "@openedx-plugins/communications-app-individual-emails",
+ "version": "1.0.0",
+ "dependencies": {
+ "react-bootstrap-typeahead": "^6.3.2",
+ "uuid": "^9.0.1"
+ },
+ "peerDependencies": {
+ "@edx/frontend-app-communications": "*",
+ "@edx/frontend-platform": "*",
+ "@edx/paragon": "*",
+ "prop-types": "*",
+ "react": "*"
+ },
+ "peerDependenciesMeta": {
+ "@edx/frontend-app-communications": {
+ "optional": true
+ }
+ }
+ },
"plugins/communications-app/InputForm": {
"name": "@openedx-plugins/communications-app-input-form",
"version": "1.0.0",
diff --git a/package.json b/package.json
index bd8b3c8e..ef193b72 100644
--- a/package.json
+++ b/package.json
@@ -48,6 +48,7 @@
"@loadable/component": "^5.15.3",
"@openedx-plugins/communications-app-body-email-form": "file:plugins/communications-app/BodyForm",
"@openedx-plugins/communications-app-check-box-form": "file:plugins/communications-app/CheckBoxForm",
+ "@openedx-plugins/communications-app-individual-emails": "file:plugins/communications-app/IndividualEmails",
"@openedx-plugins/communications-app-input-form": "file:plugins/communications-app/InputForm",
"@openedx-plugins/communications-app-instructions-pro-freading": "file:plugins/communications-app/InstructionsProfreading",
"@openedx-plugins/communications-app-recipients-checks": "file:plugins/communications-app/RecipientsForm",
@@ -63,6 +64,8 @@
"popper.js": "1.16.1",
"prop-types": "15.8.1",
"react": "17.0.2",
+ "react-bootstrap-typeahead": "^6.3.2",
+ "uuid": "^9.0.1",
"react-dom": "17.0.2",
"react-helmet": "^6.1.0",
"react-redux": "7.2.9",
diff --git a/plugins/communications-app/IndividualEmails/api.js b/plugins/communications-app/IndividualEmails/api.js
new file mode 100644
index 00000000..fe1902d2
--- /dev/null
+++ b/plugins/communications-app/IndividualEmails/api.js
@@ -0,0 +1,16 @@
+import { getConfig } from '@edx/frontend-platform';
+import { getAuthenticatedHttpClient } from '@edx/frontend-platform/auth';
+import { logError } from '@edx/frontend-platform/logging';
+
+export async function getLearnersEmailInstructorTask(courseId, search, page = 1, pageSize = 10) {
+ const endpointUrl = `${
+ getConfig().LMS_BASE_URL
+ }/platform-plugin-communications/${courseId}/api/search_learners?query=${search}&page=${page}&page_size=${pageSize}`;
+ try {
+ const response = await getAuthenticatedHttpClient().get(endpointUrl);
+ return response;
+ } catch (error) {
+ logError(error);
+ throw new Error(error);
+ }
+}
diff --git a/plugins/communications-app/IndividualEmails/api.test.js b/plugins/communications-app/IndividualEmails/api.test.js
new file mode 100644
index 00000000..6e0537b2
--- /dev/null
+++ b/plugins/communications-app/IndividualEmails/api.test.js
@@ -0,0 +1,44 @@
+import { getAuthenticatedHttpClient } from '@edx/frontend-platform/auth';
+import { getConfig } from '@edx/frontend-platform';
+import { logError } from '@edx/frontend-platform/logging';
+
+import { getLearnersEmailInstructorTask } from './api';
+
+jest.mock('@edx/frontend-platform/auth', () => ({
+ getAuthenticatedHttpClient: jest.fn(),
+}));
+jest.mock('@edx/frontend-platform', () => ({
+ getConfig: jest.fn(),
+}));
+jest.mock('@edx/frontend-platform/logging', () => ({
+ logError: jest.fn(),
+}));
+
+describe('getLearnersEmailInstructorTask', () => {
+ const mockCourseId = 'course123';
+ const mockSearch = 'testuser';
+ const mockResponseData = { data: 'someData' };
+ const mockConfig = { LMS_BASE_URL: 'http://localhost' };
+
+ beforeEach(() => {
+ getConfig.mockReturnValue(mockConfig);
+ getAuthenticatedHttpClient.mockReturnValue({
+ get: jest.fn().mockResolvedValue(mockResponseData),
+ });
+ });
+
+ test('successfully fetches data', async () => {
+ const data = await getLearnersEmailInstructorTask(mockCourseId, mockSearch);
+ expect(data).toEqual(mockResponseData);
+ expect(getAuthenticatedHttpClient().get).toHaveBeenCalledWith(
+ `http://localhost/platform-plugin-communications/${mockCourseId}/api/search_learners?query=${mockSearch}&page=1&page_size=10`,
+ );
+ });
+
+ test('handles an error', async () => {
+ getAuthenticatedHttpClient().get.mockRejectedValue(new Error('Network error'));
+
+ await expect(getLearnersEmailInstructorTask(mockCourseId, mockSearch)).rejects.toThrow('Network error');
+ expect(logError).toHaveBeenCalledWith(new Error('Network error'));
+ });
+});
diff --git a/plugins/communications-app/IndividualEmails/index.jsx b/plugins/communications-app/IndividualEmails/index.jsx
new file mode 100644
index 00000000..cfa19098
--- /dev/null
+++ b/plugins/communications-app/IndividualEmails/index.jsx
@@ -0,0 +1,149 @@
+import React, { useState } from 'react';
+import PropTypes from 'prop-types';
+import { AsyncTypeahead } from 'react-bootstrap-typeahead';
+import { v4 as uuidv4 } from 'uuid';
+import {
+ Form,
+ Chip,
+ Container,
+} from '@edx/paragon';
+import { Person, Close } from '@edx/paragon/icons';
+import { useIntl, FormattedMessage } from '@edx/frontend-platform/i18n';
+import { logError } from '@edx/frontend-platform/logging';
+import { useSelector } from '@communications-app/src/components/bulk-email-tool/bulk-email-form/BuildEmailFormExtensible/context';
+
+import { getLearnersEmailInstructorTask } from './api';
+import messages from './messages';
+
+import './styles.scss';
+
+const IndividualEmails = ({
+ courseId,
+ handleEmailSelected,
+ emailList,
+ handleDeleteEmail,
+}) => {
+ const intl = useIntl();
+ const [isLoading, setIsLoading] = useState(false);
+ const [options, setOptions] = useState([]);
+ const [inputValue] = useState([]);
+ const formData = useSelector((state) => state.form);
+ const { isFormSubmitted } = formData;
+
+ const handleSearchEmailLearners = async (userEmail) => {
+ setIsLoading(true);
+ try {
+ const response = await getLearnersEmailInstructorTask(courseId, userEmail);
+ const { results } = response.data;
+ const formatResult = results.map((item) => ({ id: uuidv4(), ...item }));
+ setOptions(formatResult);
+ } catch (error) {
+ logError(error);
+ } finally {
+ setIsLoading(false);
+ }
+ };
+
+ const filterBy = (option) => option.name || option.email || option.username;
+ const handleDeleteEmailSelected = (id) => {
+ if (handleDeleteEmail) {
+ handleDeleteEmail(id);
+ }
+ };
+
+ const handleSelectedLearnerEmail = (selected) => {
+ const [itemSelected] = selected || [{ email: '' }];
+ const isEmailAdded = emailList.some((item) => item.email === itemSelected.email);
+
+ if (selected && !isEmailAdded) {
+ handleEmailSelected(selected);
+ }
+ };
+
+ const isIndividualEmailsInvalid = isFormSubmitted && emailList.length === 0;
+
+ return (
+
+
+ {intl.formatMessage(messages.individualEmailsLabelLearnersInputLabel)}
+
+ (
+
+ {name ? `${name} -` : ''} {username ? `${username} -` : ''} {email}
+
+ )}
+ />
+
+ { isIndividualEmailsInvalid && (
+
+
+
+ )}
+
+ {emailList.length > 0 && (
+
+
+ {intl.formatMessage(messages.individualEmailsLabelLearnersListLabel)}
+
+ {emailList.map(({ id, email }) => (
+ handleDeleteEmailSelected(id)}
+ key={id}
+ data-testid="email-list-chip"
+ >
+ {email}
+
+ ))}
+
+ ) }
+
+
+ );
+};
+
+IndividualEmails.defaultProps = {
+ courseId: '',
+ handleEmailSelected: () => {},
+ handleDeleteEmail: () => {},
+ emailList: [],
+};
+
+IndividualEmails.propTypes = {
+ courseId: PropTypes.string,
+ handleEmailSelected: PropTypes.func,
+ handleDeleteEmail: PropTypes.func,
+ emailList: PropTypes.arrayOf(
+ PropTypes.shape({
+ id: PropTypes.string,
+ email: PropTypes.string,
+ username: PropTypes.string,
+ }),
+ ),
+};
+
+export default IndividualEmails;
diff --git a/plugins/communications-app/IndividualEmails/index.test.jsx b/plugins/communications-app/IndividualEmails/index.test.jsx
new file mode 100644
index 00000000..6d79a027
--- /dev/null
+++ b/plugins/communications-app/IndividualEmails/index.test.jsx
@@ -0,0 +1,134 @@
+import React from 'react';
+import {
+ render, screen, fireEvent, waitFor,
+} from '@testing-library/react';
+import { IntlProvider } from 'react-intl';
+
+import IndividualEmails from '.';
+import * as api from './api';
+import messages from './messages';
+
+jest.mock('./api');
+describe('IndividualEmails Component', () => {
+ beforeEach(() => {
+ jest.clearAllMocks();
+ api.getLearnersEmailInstructorTask.mockResolvedValue({
+ data: {
+ results: [
+ { email: 'test@email.com', username: 'test', name: 'test' },
+ { email: 'test2@email.com', username: 'test2', name: 'test2' },
+ ],
+ },
+ });
+ });
+
+ const mockEmailList = [
+ { id: '1', email: 'user1@example.com' },
+ { id: '2', email: 'user2@example.com' },
+ ];
+
+ // eslint-disable-next-line react/prop-types
+ const IntlProviderWrapper = ({ children }) => (
+
+ {children}
+
+ );
+
+ test('renders the component without errors', () => {
+ render(
+
+
+ ,
+ );
+ });
+
+ test('displays the correct internationalized messages', () => {
+ render(
+
+
+ ,
+ );
+
+ const emailInput = screen.getByRole('combobox');
+
+ const {
+ individualEmailsLabelLearnersInputLabel,
+ individualEmailsLabelLearnersInputPlaceholder,
+ individualEmailsLabelLearnersListLabel,
+ } = messages;
+
+ expect(screen.getByTestId('learners-email-input-label')).toHaveTextContent(individualEmailsLabelLearnersInputLabel.defaultMessage);
+ expect(emailInput).toHaveAttribute('placeholder', individualEmailsLabelLearnersInputPlaceholder.defaultMessage);
+ expect(screen.getByTestId('learners-email-list-label')).toHaveTextContent(individualEmailsLabelLearnersListLabel.defaultMessage);
+ });
+
+ test('renders the component with main components ', () => {
+ render(
+
+
+ ,
+ );
+
+ const emailInputLabel = screen.getByTestId('learners-email-input-label');
+ const emailInput = screen.getByRole('combobox');
+ const emailListLabel = screen.getByTestId('learners-email-list-label');
+
+ expect(emailInputLabel).toBeInTheDocument();
+ expect(emailInput).toBeInTheDocument();
+ expect(emailListLabel).toBeInTheDocument();
+ });
+
+ test('should render two email chips', () => {
+ render(
+
+
+ ,
+ );
+
+ const emailChips = screen.getAllByTestId('email-list-chip');
+ expect(emailChips).toHaveLength(2);
+ });
+
+ test('triggers search on typing in search box', async () => {
+ const mockHandleEmailSelected = jest.fn();
+ const mockCourseId = 'course123';
+ render(
+
+
+ ,
+ );
+
+ fireEvent.change(screen.getByRole('combobox'), { target: { value: 'test' } });
+ await waitFor(() => {
+ expect(api.getLearnersEmailInstructorTask).toHaveBeenCalled();
+ expect(api.getLearnersEmailInstructorTask).toHaveBeenCalledWith(mockCourseId, 'test');
+ const learnersToSelect = screen.getAllByTestId('autocomplete-email-option');
+ expect(learnersToSelect).toHaveLength(2);
+
+ const [firstOption] = learnersToSelect;
+ fireEvent.click(firstOption);
+ expect(mockHandleEmailSelected).toHaveBeenCalled();
+ });
+ });
+
+ test('invokes handleDeleteEmail when clicking on delete icons', () => {
+ const mockHandleDeleteEmail = jest.fn();
+ render(
+
+
+ ,
+ );
+
+ const emailChips = screen.getAllByTestId('email-list-chip');
+
+ emailChips.forEach((chip) => {
+ const deleteButton = chip.querySelector('[role="button"]');
+ fireEvent.click(deleteButton);
+ });
+
+ expect(mockHandleDeleteEmail).toHaveBeenCalledTimes(mockEmailList.length);
+
+ expect(mockHandleDeleteEmail).toHaveBeenCalledWith(mockEmailList[0].id);
+ expect(mockHandleDeleteEmail).toHaveBeenCalledWith(mockEmailList[1].id);
+ });
+});
diff --git a/plugins/communications-app/IndividualEmails/messages.js b/plugins/communications-app/IndividualEmails/messages.js
new file mode 100644
index 00000000..932e0a04
--- /dev/null
+++ b/plugins/communications-app/IndividualEmails/messages.js
@@ -0,0 +1,22 @@
+import { defineMessages } from '@edx/frontend-platform/i18n';
+
+const messages = defineMessages({
+ /* index.jsx Messages */
+ individualEmailsLabelLearnersInputLabel: {
+ id: 'individual.emails.learners.input.label',
+ defaultMessage: 'Add individual learner',
+ description: 'Input autocomplete label for learners email',
+ },
+ individualEmailsLabelLearnersInputPlaceholder: {
+ id: 'individual.emails.learners.input.placeholder',
+ defaultMessage: 'Search for individual email',
+ description: 'Placeholder for autocomplete input for learners email',
+ },
+ individualEmailsLabelLearnersListLabel: {
+ id: 'individual.emails.learners.list.label',
+ defaultMessage: 'Recipients',
+ description: 'Title for learners email list',
+ },
+});
+
+export default messages;
diff --git a/plugins/communications-app/IndividualEmails/package.json b/plugins/communications-app/IndividualEmails/package.json
new file mode 100644
index 00000000..985c6c76
--- /dev/null
+++ b/plugins/communications-app/IndividualEmails/package.json
@@ -0,0 +1,24 @@
+{
+ "name": "@openedx-plugins/communications-app-individual-emails",
+ "version": "1.0.0",
+ "description": "openedx autocomplete input and list of expecifict email",
+ "scripts": {
+ "test": "echo \"Error: no test specified\" && exit 1"
+ },
+ "dependencies": {
+ "react-bootstrap-typeahead": "^6.3.2",
+ "uuid": "^9.0.1"
+ },
+ "peerDependencies": {
+ "@edx/frontend-app-communications": "*",
+ "@edx/frontend-platform": "*",
+ "@edx/paragon": "*",
+ "prop-types": "*",
+ "react": "*"
+ },
+ "peerDependenciesMeta": {
+ "@edx/frontend-app-communications": {
+ "optional": true
+ }
+ }
+}
diff --git a/plugins/communications-app/IndividualEmails/styles.scss b/plugins/communications-app/IndividualEmails/styles.scss
new file mode 100644
index 00000000..634e97bd
--- /dev/null
+++ b/plugins/communications-app/IndividualEmails/styles.scss
@@ -0,0 +1,21 @@
+$light-gray: #ccc;
+$dark-gray: #f0f0f0;
+
+.email-list {
+ width: 100%;
+ display: flex;
+ flex-wrap: wrap;
+ border: 1px solid $light-gray;
+ padding: 10px;
+ margin-top: 16px;
+}
+
+.email-chip {
+ background-color: $dark-gray;
+ border: 1px solid $dark-gray;
+ border-radius: 20px;
+ padding: 5px 10px;
+ margin: 5px;
+ display: flex;
+ align-items: center;
+}
diff --git a/plugins/communications-app/RecipientsForm/index.jsx b/plugins/communications-app/RecipientsForm/index.jsx
index 4644cc94..36df889b 100644
--- a/plugins/communications-app/RecipientsForm/index.jsx
+++ b/plugins/communications-app/RecipientsForm/index.jsx
@@ -1,25 +1,35 @@
-import React, { useEffect, useState } from 'react';
+import React, { useEffect, useState, useContext } from 'react';
import PropTypes from 'prop-types';
+import useDeepCompareEffect from 'use-deep-compare-effect';
import { Form } from '@edx/paragon';
import { FormattedMessage } from '@edx/frontend-platform/i18n';
import { useSelector, useDispatch } from '@communications-app/src/components/bulk-email-tool/bulk-email-form/BuildEmailFormExtensible/context';
import { actionCreators as formActions } from '@communications-app/src/components/bulk-email-tool/bulk-email-form/BuildEmailFormExtensible/context/reducer';
+import PluggableComponent from '@communications-app/src/components/PluggableComponent';
+import { BulkEmailContext } from '@communications-app/src/components/bulk-email-tool/bulk-email-context';
import './styles.scss';
-const disableIsHasLearners = ['track', 'cohort'];
+const disableIsHasLearners = ['track', 'cohort', 'individual-learners'];
const recipientsFormDescription = 'A selectable choice from a list of potential email recipients';
-const RecipientsForm = ({ cohorts: additionalCohorts }) => {
+const RecipientsForm = ({ cohorts: additionalCohorts, courseId }) => {
const formData = useSelector((state) => state.form);
+ const [{ editor }] = useContext(BulkEmailContext);
const dispatch = useDispatch();
- const { isEditMode, emailRecipients, isFormSubmitted } = formData;
+ const {
+ isEditMode,
+ emailRecipients,
+ isFormSubmitted,
+ emailLearnersList = [],
+ } = formData;
const [selectedGroups, setSelectedGroups] = useState([]);
const hasAllLearnersSelected = selectedGroups.some((group) => group === 'learners');
const handleChangeCheckBoxes = ({ target: { value, checked } }) => {
let newValue;
+ let newEmailLearnersList = emailLearnersList;
if (checked) {
const uniqueSet = new Set([...emailRecipients, value]);
@@ -30,16 +40,42 @@ const RecipientsForm = ({ cohorts: additionalCohorts }) => {
if (checked && value === 'learners') {
newValue = newValue.filter(item => !disableIsHasLearners.some(disabled => item.includes(disabled)));
+ newEmailLearnersList = [];
}
- dispatch(formActions.updateForm({ emailRecipients: newValue }));
+ dispatch(formActions.updateForm({ emailRecipients: newValue, emailLearnersList: newEmailLearnersList }));
setSelectedGroups(newValue);
};
+ // When the user selects an email from input autocomplete list
+ const handleEmailLearnersSelected = (emailSelected) => {
+ const [firstItem] = emailSelected;
+ if (firstItem) {
+ dispatch(formActions.updateForm({ emailLearnersList: [...emailLearnersList, firstItem] }));
+ }
+ };
+
+ // To delete an email from learners list, that list is on the bottom of the input autocomplete
+ const handleLearnersDeleteEmail = (idToDelete) => {
+ const setEmailLearnersListUpdated = emailLearnersList.filter(({ id }) => id !== idToDelete);
+ dispatch(formActions.updateForm({ emailLearnersList: setEmailLearnersListUpdated }));
+ };
+
useEffect(() => {
setSelectedGroups(emailRecipients);
}, [isEditMode, emailRecipients.length, emailRecipients]);
+ useDeepCompareEffect(() => {
+ if (!editor.editMode) {
+ const newSubjectValue = editor.emailSubject;
+ const newBodyValue = editor.emailBody;
+ dispatch(formActions.updateForm({
+ subject: newSubjectValue,
+ body: newBodyValue,
+ }));
+ }
+ }, [editor, dispatch]);
+
return (
@@ -132,7 +168,32 @@ const RecipientsForm = ({ cohorts: additionalCohorts }) => {
description={recipientsFormDescription}
/>
+
+
+
+
+
+ {selectedGroups.includes('individual-learners') && (
+
+ )}
+
{ isFormSubmitted && selectedGroups.length === 0 && (
{
RecipientsForm.defaultProps = {
cohorts: [],
+ courseId: '',
};
RecipientsForm.propTypes = {
cohorts: PropTypes.arrayOf(PropTypes.string),
+ courseId: PropTypes.string,
};
export default RecipientsForm;
diff --git a/plugins/communications-app/RecipientsForm/package.json b/plugins/communications-app/RecipientsForm/package.json
index 2b033cca..72c7c455 100644
--- a/plugins/communications-app/RecipientsForm/package.json
+++ b/plugins/communications-app/RecipientsForm/package.json
@@ -5,6 +5,9 @@
"scripts": {
"test": "echo \"Error: no test specified\" && exit 1"
},
+ "dependencies": {
+ "use-deep-compare-effect": "^1.8.1"
+ },
"peerDependencies": {
"@edx/frontend-app-communications": "*",
"@edx/frontend-platform": "*",
diff --git a/plugins/communications-app/TaskAlertModalForm/api.js b/plugins/communications-app/TaskAlertModalForm/api.js
index 62fb078d..aab453cd 100644
--- a/plugins/communications-app/TaskAlertModalForm/api.js
+++ b/plugins/communications-app/TaskAlertModalForm/api.js
@@ -23,3 +23,14 @@ export async function patchScheduledBulkEmailInstructorTask(emailData, courseId,
throw new Error(error);
}
}
+
+export async function postBulkEmailInstructorTaskSendEmails(emailData, courseId) {
+ try {
+ const url = `${getConfig().LMS_BASE_URL}/platform-plugin-communications/${courseId}/api/send_email`;
+ const response = await getAuthenticatedHttpClient().post(url, emailData);
+ return response;
+ } catch (error) {
+ logError(error);
+ throw new Error(error);
+ }
+}
diff --git a/plugins/communications-app/TaskAlertModalForm/index.jsx b/plugins/communications-app/TaskAlertModalForm/index.jsx
index db25edd4..1b020ff5 100644
--- a/plugins/communications-app/TaskAlertModalForm/index.jsx
+++ b/plugins/communications-app/TaskAlertModalForm/index.jsx
@@ -6,7 +6,7 @@ import { BulkEmailContext } from '@communications-app/src/components/bulk-email-
import { useSelector, useDispatch } from '@communications-app/src/components/bulk-email-tool/bulk-email-form/BuildEmailFormExtensible/context';
import { actionCreators as formActions } from '@communications-app/src/components/bulk-email-tool/bulk-email-form/BuildEmailFormExtensible/context/reducer';
-import { postBulkEmailInstructorTask, patchScheduledBulkEmailInstructorTask } from './api';
+import { postBulkEmailInstructorTaskSendEmails, patchScheduledBulkEmailInstructorTask } from './api';
import { AlertMessage, EditMessage } from './AlertTypes';
const TaskAlertModalForm = ({
@@ -30,17 +30,22 @@ const TaskAlertModalForm = ({
body,
isScheduleButtonClicked = false,
isFormSubmitted = false,
+ emailLearnersList = [],
} = formData;
const changeFormStatus = (status) => dispatchForm(formActions.updateForm({ formStatus: status }));
const handleResetFormValues = () => dispatchForm(formActions.resetForm());
const handlePostEmailTask = async () => {
+ const emailRecipientsFormat = emailRecipients.filter((recipient) => recipient !== 'individual-learners');
+ const emailsLearners = emailLearnersList.map(({ email }) => email);
+ const extraTargets = { emails: emailsLearners };
const emailData = new FormData();
emailData.append('action', 'send');
- emailData.append('send_to', JSON.stringify(emailRecipients));
+ emailData.append('send_to', JSON.stringify(emailRecipientsFormat));
emailData.append('subject', subject);
emailData.append('message', body);
+ emailData.append('extra_targets', JSON.stringify(extraTargets));
if (isScheduled) {
emailData.append('schedule', new Date(`${scheduleDate} ${scheduleTime}`).toISOString());
@@ -49,7 +54,7 @@ const TaskAlertModalForm = ({
changeFormStatus('pending');
try {
- await postBulkEmailInstructorTask(emailData, courseId);
+ await postBulkEmailInstructorTaskSendEmails(emailData, courseId);
const newFormStatus = isScheduled ? 'completeSchedule' : 'complete';
changeFormStatus(newFormStatus);
setTimeout(() => handleResetFormValues(), 3000);
@@ -86,8 +91,10 @@ const TaskAlertModalForm = ({
const createEmailTask = async () => {
const isScheduleValid = isScheduled ? scheduleDate.length > 0 && scheduleTime.length > 0 : true;
+ const isIndividualEmailsValid = (emailRecipients.includes('individual-learners') && emailLearnersList.length > 0)
+ || !emailRecipients.includes('individual-learners');
const isFormValid = emailRecipients.length > 0 && subject.length > 0
- && body.length > 0 && isScheduleValid;
+ && body.length > 0 && isScheduleValid && isIndividualEmailsValid;
if (isFormValid && isEditMode) {
await handlePatchEmailTask();