diff --git a/package-lock.json b/package-lock.json
index ff22d297..d5d48f3a 100644
--- a/package-lock.json
+++ b/package-lock.json
@@ -31,11 +31,13 @@
"@openedx-plugins/communications-app-schedule-section": "file:plugins/communications-app/ScheduleSection",
"@openedx-plugins/communications-app-subject-form": "file:plugins/communications-app/SubjectForm",
"@openedx-plugins/communications-app-task-alert-modal": "file:plugins/communications-app/TaskAlertModalForm",
+ "@openedx-plugins/communications-app-team-emails": "file:plugins/communications-app/TeamEmails",
"@openedx-plugins/communications-app-test-component": "file:plugins/communications-app/TestComponent",
"@tinymce/tinymce-react": "3.14.0",
"axios": "0.27.2",
"classnames": "2.3.2",
"core-js": "3.26.1",
+ "humps": "^2.0.1",
"jquery": "3.6.1",
"popper.js": "1.16.1",
"prop-types": "15.8.1",
@@ -5526,6 +5528,10 @@
"resolved": "plugins/communications-app/TaskAlertModalForm",
"link": true
},
+ "node_modules/@openedx-plugins/communications-app-team-emails": {
+ "resolved": "plugins/communications-app/TeamEmails",
+ "link": true
+ },
"node_modules/@openedx-plugins/communications-app-test-component": {
"resolved": "plugins/communications-app/TestComponent",
"link": true
@@ -11804,6 +11810,11 @@
"node": ">=10.17.0"
}
},
+ "node_modules/humps": {
+ "version": "2.0.1",
+ "resolved": "https://registry.npmjs.org/humps/-/humps-2.0.1.tgz",
+ "integrity": "sha512-E0eIbrFWUhwfXJmsbdjRQFQPrl5pTEoKlz163j1mTqqUnU9PgR4AgB8AIITzuB3vLBdxZXyZ9TDIrwB2OASz4g=="
+ },
"node_modules/husky": {
"version": "7.0.4",
"dev": true,
@@ -22692,6 +22703,9 @@
"plugins/communications-app/RecipientsForm": {
"name": "@openedx-plugins/communications-app-recipients-checks",
"version": "1.0.0",
+ "dependencies": {
+ "use-deep-compare-effect": "^1.8.1"
+ },
"peerDependencies": {
"@edx/frontend-app-communications": "*",
"@edx/frontend-platform": "*",
@@ -22753,6 +22767,25 @@
}
}
},
+ "plugins/communications-app/TeamEmails": {
+ "name": "@openedx-plugins/communications-app-team-emails",
+ "version": "1.0.0",
+ "dependencies": {
+ "use-deep-compare-effect": "^1.8.1"
+ },
+ "peerDependencies": {
+ "@edx/frontend-app-communications": "*",
+ "@edx/frontend-platform": "*",
+ "@edx/paragon": "*",
+ "prop-types": "*",
+ "react": "*"
+ },
+ "peerDependenciesMeta": {
+ "@edx/frontend-app-communications": {
+ "optional": true
+ }
+ }
+ },
"plugins/communications-app/TestComponent": {
"name": "@openedx-plugins/communications-app-test-component",
"version": "1.0.0",
diff --git a/package.json b/package.json
index ef193b72..bc1ea37c 100644
--- a/package.json
+++ b/package.json
@@ -55,17 +55,18 @@
"@openedx-plugins/communications-app-schedule-section": "file:plugins/communications-app/ScheduleSection",
"@openedx-plugins/communications-app-subject-form": "file:plugins/communications-app/SubjectForm",
"@openedx-plugins/communications-app-task-alert-modal": "file:plugins/communications-app/TaskAlertModalForm",
+ "@openedx-plugins/communications-app-team-emails": "file:plugins/communications-app/TeamEmails",
"@openedx-plugins/communications-app-test-component": "file:plugins/communications-app/TestComponent",
"@tinymce/tinymce-react": "3.14.0",
"axios": "0.27.2",
"classnames": "2.3.2",
"core-js": "3.26.1",
+ "humps": "^2.0.1",
"jquery": "3.6.1",
"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",
@@ -74,7 +75,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",
diff --git a/plugins/communications-app/RecipientsForm/index.jsx b/plugins/communications-app/RecipientsForm/index.jsx
index 36df889b..0aed8864 100644
--- a/plugins/communications-app/RecipientsForm/index.jsx
+++ b/plugins/communications-app/RecipientsForm/index.jsx
@@ -22,6 +22,7 @@ const RecipientsForm = ({ cohorts: additionalCohorts, courseId }) => {
emailRecipients,
isFormSubmitted,
emailLearnersList = [],
+ teamsList = [],
} = formData;
const [selectedGroups, setSelectedGroups] = useState([]);
@@ -61,6 +62,8 @@ const RecipientsForm = ({ cohorts: additionalCohorts, courseId }) => {
dispatch(formActions.updateForm({ emailLearnersList: setEmailLearnersListUpdated }));
};
+ const isInvalidRecipients = teamsList.length === 0 && selectedGroups.length === 0;
+
useEffect(() => {
setSelectedGroups(emailRecipients);
}, [isEditMode, emailRecipients.length, emailRecipients]);
@@ -176,7 +179,7 @@ const RecipientsForm = ({ cohorts: additionalCohorts, courseId }) => {
disabled={hasAllLearnersSelected}
>
@@ -194,7 +197,13 @@ const RecipientsForm = ({ cohorts: additionalCohorts, courseId }) => {
/>
)}
- { isFormSubmitted && selectedGroups.length === 0 && (
+
+
+ { isFormSubmitted && isInvalidRecipients && (
{
const intl = useIntl();
@@ -39,6 +46,7 @@ const ScheduleSection = ({ openTaskAlert }) => {
isEditMode,
formStatus,
isScheduledSubmitted = false,
+ isLoadingTeams = false,
} = formData;
const formStatusErrors = {
@@ -89,6 +97,7 @@ const ScheduleSection = ({ openTaskAlert }) => {
complete: ,
completeSchedule: ,
error: ,
+ isLoadingTeams: ,
}), []);
const statefulButtonLabels = useMemo(() => ({
@@ -99,13 +108,15 @@ const ScheduleSection = ({ openTaskAlert }) => {
complete: intl.formatMessage(messages.ScheduleSectionSubmitButtonComplete),
completeSchedule: intl.formatMessage(messages.ScheduleSectionSubmitButtonCompleteSchedule),
error: intl.formatMessage(messages.ScheduleSectionSubmitButtonError),
+ loadingTeams: intl.formatMessage(messages.ScheduleSectionSubmitButtonDefault),
}), [intl]);
const statefulButtonDisableStates = useMemo(() => [
- 'pending',
- 'complete',
- 'completeSchedule',
- ], []);
+ PENDING,
+ COMPLETE,
+ COMPLETE_SCHEDULE,
+ isLoadingTeams ? LOADING_TEAMS : '',
+ ], [isLoadingTeams]);
return (
@@ -147,16 +158,32 @@ const ScheduleSection = ({ openTaskAlert }) => {
)}
-
+
+
+ {isLoadingTeams && (
+
+ )}
+ >
+ {intl.formatMessage(messages.ScheduleSectionSubmitButtonFeedBackLoadingTeams)}
+
+ )}
+
dispatchForm(formActions.updateForm({ formStatus: status }));
const handleResetFormValues = () => dispatchForm(formActions.resetForm());
const handlePostEmailTask = async () => {
- const emailRecipientsFormat = emailRecipients.filter((recipient) => recipient !== 'individual-learners');
+ const teamsNames = teamsListFullData.map(({ name }) => name);
+ const invalidRecipients = ['individual-learners', ...teamsNames];
+ const emailRecipientsFormat = emailRecipients.filter((recipient) => !invalidRecipients.includes(recipient));
const emailsLearners = emailLearnersList.map(({ email }) => email);
- const extraTargets = { emails: emailsLearners };
+ const extraTargets = { emails: emailsLearners, teams: teamsList };
const emailData = new FormData();
emailData.append('action', 'send');
emailData.append('send_to', JSON.stringify(emailRecipientsFormat));
@@ -93,7 +97,8 @@ const TaskAlertModalForm = ({
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
+ const isValidRecipients = emailRecipients.length > 0 || teamsList.length > 0;
+ const isFormValid = isValidRecipients && subject.length > 0
&& body.length > 0 && isScheduleValid && isIndividualEmailsValid;
if (isFormValid && isEditMode) {
diff --git a/plugins/communications-app/TeamEmails/FeedbackMessage.jsx b/plugins/communications-app/TeamEmails/FeedbackMessage.jsx
new file mode 100644
index 00000000..1f75f8bd
--- /dev/null
+++ b/plugins/communications-app/TeamEmails/FeedbackMessage.jsx
@@ -0,0 +1,30 @@
+import PropTypes from 'prop-types';
+import {
+ Form,
+ Spinner,
+} from '@edx/paragon';
+
+import './FeedbackMessage.scss';
+
+const FeedbackMessage = ({ title }) => (
+
+ )}
+ >
+ {title}
+
+);
+
+FeedbackMessage.propTypes = {
+ title: PropTypes.string.isRequired,
+};
+
+export default FeedbackMessage;
diff --git a/plugins/communications-app/TeamEmails/FeedbackMessage.scss b/plugins/communications-app/TeamEmails/FeedbackMessage.scss
new file mode 100644
index 00000000..2ede6c9b
--- /dev/null
+++ b/plugins/communications-app/TeamEmails/FeedbackMessage.scss
@@ -0,0 +1,8 @@
+$medium-spinner: 15px;
+
+.loading-teams-spinner {
+ &__medium {
+ height: $medium-spinner;
+ width: $medium-spinner;
+ }
+}
diff --git a/plugins/communications-app/TeamEmails/FeedbackMessage.test.jsx b/plugins/communications-app/TeamEmails/FeedbackMessage.test.jsx
new file mode 100644
index 00000000..a3fae6bd
--- /dev/null
+++ b/plugins/communications-app/TeamEmails/FeedbackMessage.test.jsx
@@ -0,0 +1,25 @@
+import React from 'react';
+import { render, screen } from '@testing-library/react';
+import FeedbackMessage from './FeedbackMessage';
+
+describe('FeedbackMessage Component', () => {
+ test('renders with the provided title', () => {
+ const title = 'Loading...';
+ render();
+ const feedbackMessageContainer = screen.getByTestId('feedback-message-container');
+ expect(feedbackMessageContainer).toBeInTheDocument();
+ expect(feedbackMessageContainer).toHaveTextContent(title);
+ });
+
+ test('renders the spinner element', () => {
+ render();
+ const spinnerElement = screen.getByTestId('feedback-message-spinner');
+ expect(spinnerElement).toBeInTheDocument();
+ });
+
+ test('renders the spinner with the correct CSS classes', () => {
+ render();
+ const spinnerElement = screen.getByTestId('feedback-message-spinner');
+ expect(spinnerElement).toHaveClass('mie-3 loading-teams-spinner__medium');
+ });
+});
diff --git a/plugins/communications-app/TeamEmails/ListTeams.jsx b/plugins/communications-app/TeamEmails/ListTeams.jsx
new file mode 100644
index 00000000..6373e211
--- /dev/null
+++ b/plugins/communications-app/TeamEmails/ListTeams.jsx
@@ -0,0 +1,56 @@
+import PropTypes from 'prop-types';
+import { Form } from '@edx/paragon';
+import { FormattedMessage } from '@edx/frontend-platform/i18n';
+
+import './ListTeams.scss';
+
+const recipientsTeamFormDescription = 'A selectable choice from a list of potential email team recipients';
+
+const ListTeams = ({ teams, onChangeCheckBox, teamsSelected }) => (
+
+ {teams.map(({ id, name }) => (
+
+
+
+ ))}
+
+);
+
+ListTeams.defaultProps = {
+ onChangeCheckBox: () => {},
+ teamsSelected: [],
+};
+
+ListTeams.propTypes = {
+ onChangeCheckBox: PropTypes.func,
+ teamsSelected: PropTypes.arrayOf(PropTypes.string),
+ teams: PropTypes.arrayOf(
+ PropTypes.shape({
+ id: PropTypes.string.isRequired,
+ discussionTopicId: PropTypes.string.isRequired,
+ name: PropTypes.string.isRequired,
+ courseId: PropTypes.string.isRequired,
+ topicId: PropTypes.string.isRequired,
+ dateCreated: PropTypes.string.isRequired,
+ description: PropTypes.string.isRequired,
+ country: PropTypes.string.isRequired,
+ language: PropTypes.string.isRequired,
+ lastActivityAt: PropTypes.string.isRequired,
+ membership: PropTypes.arrayOf(PropTypes.shape()),
+ organizationProtected: PropTypes.bool.isRequired,
+ }),
+ ).isRequired,
+};
+
+export default ListTeams;
diff --git a/plugins/communications-app/TeamEmails/ListTeams.scss b/plugins/communications-app/TeamEmails/ListTeams.scss
new file mode 100644
index 00000000..633f69cf
--- /dev/null
+++ b/plugins/communications-app/TeamEmails/ListTeams.scss
@@ -0,0 +1,7 @@
+.team-checkbox {
+ label {
+ overflow-wrap: break-word;
+ display: block !important;
+ max-width: 300px;
+ }
+}
diff --git a/plugins/communications-app/TeamEmails/ListTeams.test.jsx b/plugins/communications-app/TeamEmails/ListTeams.test.jsx
new file mode 100644
index 00000000..853557e1
--- /dev/null
+++ b/plugins/communications-app/TeamEmails/ListTeams.test.jsx
@@ -0,0 +1,85 @@
+import React from 'react';
+import { render, fireEvent } from '@testing-library/react';
+import { IntlProvider } from 'react-intl';
+
+import ListTeams from './ListTeams';
+
+describe('ListTeams component', () => {
+ // eslint-disable-next-line react/prop-types
+ const IntlProviderWrapper = ({ children }) => (
+
+ {children}
+
+ );
+ const teamsData = [
+ {
+ id: '1',
+ discussionTopicId: 'topic1',
+ name: 'Team 1',
+ courseId: 'course1',
+ topicId: 'topic1',
+ dateCreated: '2024-01-02T23:21:16.321434Z',
+ description: 'Description 1',
+ country: '',
+ language: '',
+ lastActivityAt: '2024-01-02T23:20:13Z',
+ membership: [],
+ organizationProtected: false,
+ },
+ ];
+
+ test('renders checkboxes for each team', () => {
+ const { getAllByTestId } = render(
+
+
+ ,
+ );
+ const teamCheckboxes = getAllByTestId(/team:/i);
+ expect(teamCheckboxes).toHaveLength(teamsData.length);
+ });
+
+ test('displays team names in checkboxes', () => {
+ const { getByText } = render(
+
+
+ ,
+ );
+ teamsData.forEach(({ name }) => {
+ const teamNameElement = getByText(name);
+ expect(teamNameElement).toBeInTheDocument();
+ });
+ });
+
+ test('renders no checkboxes when teams array is empty', () => {
+ const { queryByTestId } = render();
+ const teamCheckboxes = queryByTestId(/team:/i);
+ expect(teamCheckboxes).toBeNull();
+ });
+
+ test('calls onChangeCheckBox function when a checkbox is clicked', () => {
+ const onChangeMock = jest.fn();
+ const { getByTestId } = render(
+
+
+ ,
+ );
+
+ const checkbox = getByTestId('team:1');
+ fireEvent.click(checkbox);
+
+ expect(onChangeMock).toHaveBeenCalledTimes(1);
+ });
+
+ test('renders checkboxes with checked status for selected teams', () => {
+ const selectedTeams = ['1'];
+ const { getByTestId } = render(
+
+
+ ,
+ );
+
+ const checkbox = getByTestId('team:1');
+ expect(checkbox).toBeInTheDocument();
+ expect(checkbox).toBeChecked();
+ });
+});
diff --git a/plugins/communications-app/TeamEmails/api.js b/plugins/communications-app/TeamEmails/api.js
new file mode 100644
index 00000000..625abf3d
--- /dev/null
+++ b/plugins/communications-app/TeamEmails/api.js
@@ -0,0 +1,14 @@
+import { getConfig } from '@edx/frontend-platform';
+import { getAuthenticatedHttpClient } from '@edx/frontend-platform/auth';
+
+export async function getTopicsList(courseId, page = 1, pageSize = 100) {
+ const endpointUrl = `${
+ getConfig().LMS_BASE_URL
+ }/platform-plugin-teams/${courseId}/api/topics/?page=${page}&page_size=${pageSize}`;
+ try {
+ const response = await getAuthenticatedHttpClient().get(endpointUrl);
+ return response;
+ } catch (error) {
+ throw new Error(error);
+ }
+}
diff --git a/plugins/communications-app/TeamEmails/api.test.js b/plugins/communications-app/TeamEmails/api.test.js
new file mode 100644
index 00000000..477784bc
--- /dev/null
+++ b/plugins/communications-app/TeamEmails/api.test.js
@@ -0,0 +1,52 @@
+import { getAuthenticatedHttpClient } from '@edx/frontend-platform/auth';
+import { getConfig } from '@edx/frontend-platform';
+
+import { getTopicsList } from './api';
+
+jest.mock('@edx/frontend-platform/auth', () => ({
+ getAuthenticatedHttpClient: jest.fn(),
+}));
+jest.mock('@edx/frontend-platform', () => ({
+ getConfig: jest.fn(),
+}));
+
+describe('getTopicsList function', () => {
+ const mockCourseId = 'course123';
+ 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 teams list with default parameters', async () => {
+ const response = await getTopicsList(mockCourseId);
+
+ expect(response).toEqual(mockResponseData);
+ expect(getAuthenticatedHttpClient().get).toHaveBeenCalledWith(
+ `http://localhost/platform-plugin-teams/${mockCourseId}/api/topics/?page=1&page_size=100`,
+ );
+ });
+
+ test('successfully fetches teams list with custom page and pageSize', async () => {
+ const customPage = 2;
+ const customPageSize = 50;
+
+ const response = await getTopicsList(mockCourseId, customPage, customPageSize);
+
+ expect(response).toEqual(mockResponseData);
+ expect(getAuthenticatedHttpClient().get).toHaveBeenCalledWith(
+ `http://localhost/platform-plugin-teams/${mockCourseId}/api/topics/?page=${customPage}&page_size=${customPageSize}`,
+ );
+ });
+
+ test('handles an error', async () => {
+ const errorMessage = 'Network error';
+ getAuthenticatedHttpClient().get.mockRejectedValue(new Error(errorMessage));
+
+ await expect(getTopicsList(mockCourseId)).rejects.toThrow(errorMessage);
+ });
+});
diff --git a/plugins/communications-app/TeamEmails/index.jsx b/plugins/communications-app/TeamEmails/index.jsx
new file mode 100644
index 00000000..89b4c305
--- /dev/null
+++ b/plugins/communications-app/TeamEmails/index.jsx
@@ -0,0 +1,126 @@
+import React, { useState, useEffect, useRef } from 'react';
+import PropTypes from 'prop-types';
+import { useIntl } 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 ListTeams from './ListTeams';
+import FeedbackMessage from './FeedbackMessage';
+import messages from './messages';
+import { getTopicsList } from './api';
+import { getTeamsFromTopics, convertSnakeCaseToCamelCase } from './utils';
+
+const TeamEmails = ({ courseId }) => {
+ const intl = useIntl();
+ const formData = useSelector((state) => state.form);
+ const dispatch = useDispatch();
+ const {
+ teamsList = [],
+ emailRecipients,
+ teamsListFullData = [],
+ formStatus,
+ } = formData;
+ const [teams, setTeams] = useState([]);
+ const [checkedTeams, setCheckedTeams] = useState([]);
+ const [loadingTeams, setLoadingTeams] = useState(false);
+ const previousFormStatusRef = useRef(null);
+
+ const fetchTeams = async (page = 1) => {
+ try {
+ setLoadingTeams(true);
+ const responseTopics = await getTopicsList(courseId, page);
+ const { results, next } = responseTopics.data;
+
+ const camelCaseResult = convertSnakeCaseToCamelCase(results);
+ const formatResult = getTeamsFromTopics(camelCaseResult);
+
+ setTeams((prevTeams) => [...prevTeams, ...formatResult]);
+
+ if (next) {
+ fetchTeams(page + 1);
+ } else {
+ dispatch(formActions.updateForm({ isLoadingTeams: false }));
+ }
+ } catch (error) {
+ console.error('There was an error while getting teams:', error.message);
+ } finally {
+ setLoadingTeams(false);
+ }
+ };
+
+ useEffect(() => {
+ fetchTeams();
+ // eslint-disable-next-line react-hooks/exhaustive-deps
+ }, [courseId]);
+
+ useEffect(() => {
+ if (loadingTeams) {
+ dispatch(formActions.updateForm({ isLoadingTeams: true }));
+ }
+ // eslint-disable-next-line react-hooks/exhaustive-deps
+ }, [loadingTeams]);
+
+ useEffect(() => {
+ if (teams.length) {
+ dispatch(formActions.updateForm({ teamsListFullData: teams }));
+ }
+ // eslint-disable-next-line react-hooks/exhaustive-deps
+ }, [teams.length]);
+
+ useEffect(() => {
+ const wasFormSubmittedSuccessfully = previousFormStatusRef.current === 'complete' && formStatus === 'default';
+ if (wasFormSubmittedSuccessfully) {
+ setCheckedTeams([]);
+ }
+
+ previousFormStatusRef.current = formStatus;
+ }, [formStatus]);
+
+ const handleChangeTeamCheckBox = ({ target: { value, checked } }) => {
+ let newTeamsList;
+ let newEmailRecipients;
+ let newCheckBoxesSelected;
+ const teamData = teamsListFullData.find(({ id }) => id === value);
+ const teamName = teamData.name;
+
+ if (checked) {
+ const uniqueEmailRecipients = new Set([...emailRecipients, teamName]);
+ newTeamsList = [...teamsList, value];
+ newEmailRecipients = Array.from(uniqueEmailRecipients);
+ newCheckBoxesSelected = [...checkedTeams, value];
+ } else {
+ newTeamsList = teamsList.filter((teamId) => teamId !== value);
+ newEmailRecipients = emailRecipients.filter((recipient) => recipient !== teamName);
+ newCheckBoxesSelected = checkedTeams.filter((teamId) => teamId !== value);
+ }
+ dispatch(formActions.updateForm({ teamsList: newTeamsList, emailRecipients: newEmailRecipients }));
+ setCheckedTeams(newCheckBoxesSelected);
+ };
+
+ if (!teams.length) {
+ return null;
+ }
+
+ return (
+
+
{intl.formatMessage(messages.teamEmailsTitle)}
+
+ {loadingTeams && (
+
+ )}
+
+ );
+};
+
+TeamEmails.propTypes = {
+ courseId: PropTypes.string.isRequired,
+};
+
+export default TeamEmails;
diff --git a/plugins/communications-app/TeamEmails/index.test.jsx b/plugins/communications-app/TeamEmails/index.test.jsx
new file mode 100644
index 00000000..c1256f50
--- /dev/null
+++ b/plugins/communications-app/TeamEmails/index.test.jsx
@@ -0,0 +1,241 @@
+import React from 'react';
+import {
+ render, screen, fireEvent, waitFor,
+} from '@testing-library/react';
+import { IntlProvider } from 'react-intl';
+import { useDispatch, useSelector } from '@communications-app/src/components/bulk-email-tool/bulk-email-form/BuildEmailFormExtensible/context';
+
+import * as api from './api';
+import messages from './messages';
+import TeamEmails from '.';
+
+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(),
+}));
+
+jest.mock(
+ '@communications-app/src/components/bulk-email-tool/bulk-email-form/BuildEmailFormExtensible/context',
+ () => ({
+ ...jest.requireActual(
+ '@communications-app/src/components/bulk-email-tool/bulk-email-form/BuildEmailFormExtensible/context',
+ ),
+ useDispatch: jest.fn(),
+ useSelector: jest.fn(),
+ }),
+);
+
+jest.mock('./api');
+describe('TeamEmails Component', () => {
+ const mockData = {
+ results: [
+ {
+ description: 'Placeholder description for the first topic',
+ name: 'First Placeholder Topic',
+ id: 'topic-1-placeholder-id',
+ type: 'open',
+ max_team_size: 10,
+ teams: [
+ {
+ id: 'team-1-placeholder-id',
+ discussion_topic_id: 'topic-1-placeholder-id',
+ name: 'Team 1 Placeholder',
+ course_id: 'course-placeholder-id',
+ topic_id: 'topic-1-placeholder-id',
+ date_created: '2024-01-02T23:21:16.321434Z',
+ description: 'Placeholder description for Team 1',
+ country: 'US',
+ language: 'en',
+ last_activity_at: '2024-01-02T23:20:13Z',
+ membership: [],
+ organization_protected: false,
+ },
+ {
+ id: 'team-2-placeholder-id',
+ discussion_topic_id: 'topic-1-placeholder-id',
+ name: 'Team 2 Placeholder',
+ course_id: 'course-placeholder-id',
+ topic_id: 'topic-1-placeholder-id',
+ date_created: '2024-01-03T15:21:16.664826Z',
+ description: 'Placeholder description for Team 2',
+ country: 'UK',
+ language: 'en',
+ last_activity_at: '2024-01-03T15:19:42Z',
+ membership: [],
+ organization_protected: false,
+ },
+ ],
+ team_count: 2,
+ },
+ {
+ description: 'Placeholder description for the second topic',
+ name: 'Second Placeholder Topic',
+ id: 'topic-2-placeholder-id',
+ type: 'open',
+ max_team_size: 10,
+ teams: [
+ {
+ id: 'team-3-placeholder-id',
+ discussion_topic_id: 'topic-2-placeholder-id',
+ name: 'Team 3 Placeholder',
+ course_id: 'course-placeholder-id',
+ topic_id: 'topic-2-placeholder-id',
+ date_created: '2024-01-03T15:23:56.065029Z',
+ description: 'Placeholder description for Team 3',
+ country: 'CA',
+ language: 'fr',
+ last_activity_at: '2024-01-03T15:22:26Z',
+ membership: [],
+ organization_protected: false,
+ },
+ ],
+ team_count: 1,
+ },
+ ],
+ };
+
+ const mockTeamsList = [
+ {
+ id: 'team-1-placeholder-id',
+ discussionTopicId: 'topic-1-placeholder-id',
+ name: 'Team 1 Placeholder',
+ courseId: 'course-placeholder-id',
+ topicId: 'topic-1-placeholder-id',
+ dateCreated: '2024-01-02T23:21:16.321434Z',
+ description: 'Placeholder description for Team 1',
+ country: 'US',
+ language: 'en',
+ lastActivityAt: '2024-01-02T23:20:13Z',
+ membership: [],
+ organizationProtected: false,
+ },
+ {
+ id: 'team-2-placeholder-id',
+ discussionTopicId: 'topic-1-placeholder-id',
+ name: 'Team 2 Placeholder',
+ courseId: 'course-placeholder-id',
+ topicId: 'topic-1-placeholder-id',
+ dateCreated: '2024-01-03T15:21:16.664826Z',
+ description: 'Placeholder description for Team 2',
+ country: 'UK',
+ language: 'en',
+ lastActivityAt: '2024-01-03T15:19:42Z',
+ membership: [],
+ organizationProtected: false,
+ },
+ {
+ id: 'team-3-placeholder-id',
+ discussionTopicId: 'topic-2-placeholder-id',
+ name: 'Team 3 Placeholder',
+ courseId: 'course-placeholder-id',
+ topicId: 'topic-2-placeholder-id',
+ dateCreated: '2024-01-03T15:23:56.065029Z',
+ description: 'Placeholder description for Team 3',
+ country: 'CA',
+ language: 'fr',
+ lastActivityAt: '2024-01-03T15:22:26Z',
+ membership: [],
+ organizationProtected: false,
+ },
+ ];
+
+ beforeEach(() => {
+ jest.clearAllMocks();
+
+ api.getTopicsList.mockResolvedValue({ data: mockData });
+ useSelector.mockImplementation((selectorFn) => selectorFn({
+ form: {
+ teamsList: [],
+ emailRecipients: [],
+ teamsListFullData: mockTeamsList,
+ formStatus: 'default',
+ },
+ }));
+ const mockDispatch = jest.fn();
+ useDispatch.mockReturnValue(mockDispatch);
+ });
+
+ // eslint-disable-next-line react/prop-types
+ const IntlProviderWrapper = ({ children }) => (
+
+ {children}
+
+ );
+
+ test('renders the component without errors', async () => {
+ api.getTopicsList.mockResolvedValue({ data: mockData });
+
+ render(
+
+
+ ,
+ );
+
+ await waitFor(() => {
+ expect(screen.getByText(messages.teamEmailsTitle.defaultMessage)).toBeInTheDocument();
+ const checkboxes = mockTeamsList.map(({ id }) => screen.getByTestId(`team:${id}`));
+ checkboxes.forEach((checkbox) => {
+ expect(checkbox).toBeInTheDocument();
+ expect(checkbox).toBeVisible();
+ expect(checkbox).toHaveAttribute('type', 'checkbox');
+ });
+ });
+ });
+
+ test('renders null when teams are empty', async () => {
+ api.getTopicsList.mockResolvedValue({ data: { results: [] } });
+ render(
+
+
+ ,
+ );
+
+ await waitFor(() => {
+ const titleTeams = screen.queryByText(messages.teamEmailsTitle.defaultMessage);
+ expect(titleTeams).toBeNull();
+ });
+ });
+
+ test('handles checkbox change', async () => {
+ const mockDispatch = jest.fn();
+ useDispatch.mockReturnValue(mockDispatch);
+ api.getTopicsList.mockResolvedValue({ data: mockData });
+
+ render(
+
+
+ ,
+ );
+
+ await waitFor(() => {
+ const checkbox = screen.getByTestId(`team:${mockTeamsList[0].id}`);
+ fireEvent.click(checkbox, { target: { checked: true } });
+ expect(mockDispatch).toHaveBeenCalled();
+ });
+ });
+
+ test('handles error when api.getTopicsList fails', async () => {
+ const mockedError = new Error('API Failed');
+ api.getTopicsList.mockRejectedValue(mockedError);
+
+ const consoleSpy = jest.spyOn(console, 'error').mockImplementation(() => {});
+
+ render(
+
+
+ ,
+ );
+
+ await waitFor(() => {
+ expect(consoleSpy).toHaveBeenCalledWith('There was an error while getting teams:', mockedError.message);
+ });
+
+ consoleSpy.mockRestore();
+ });
+});
diff --git a/plugins/communications-app/TeamEmails/messages.js b/plugins/communications-app/TeamEmails/messages.js
new file mode 100644
index 00000000..b957dc1c
--- /dev/null
+++ b/plugins/communications-app/TeamEmails/messages.js
@@ -0,0 +1,17 @@
+import { defineMessages } from '@edx/frontend-platform/i18n';
+
+const messages = defineMessages({
+ /* index.jsx Messages */
+ teamEmailsTitle: {
+ id: 'team.emails.title',
+ defaultMessage: 'Teams',
+ description: 'Title for checkboxes of team members',
+ },
+ teamEmailsFeedBackLoadingTeams: {
+ id: 'team.emails.feedback.loading.teams',
+ defaultMessage: 'Loading teams',
+ description: 'A loading shown to the user while teams are being fetching',
+ },
+});
+
+export default messages;
diff --git a/plugins/communications-app/TeamEmails/package.json b/plugins/communications-app/TeamEmails/package.json
new file mode 100644
index 00000000..8f8d8018
--- /dev/null
+++ b/plugins/communications-app/TeamEmails/package.json
@@ -0,0 +1,24 @@
+{
+ "name": "@openedx-plugins/communications-app-team-emails",
+ "version": "1.0.0",
+ "description": "openedx recipients for sending emails to a teams",
+ "scripts": {
+ "test": "echo \"Error: no test specified\" && exit 1"
+ },
+ "dependencies": {
+ "use-deep-compare-effect": "^1.8.1",
+ "humps": "^2.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/TeamEmails/utils.js b/plugins/communications-app/TeamEmails/utils.js
new file mode 100644
index 00000000..3fa6f280
--- /dev/null
+++ b/plugins/communications-app/TeamEmails/utils.js
@@ -0,0 +1,16 @@
+import { camelizeKeys } from 'humps';
+
+/**
+ * Extracts an array of teams from an array of topics.
+ *
+ * @param {Array} topics - An array of topic objects.
+ * @returns {Array} - An array containing all the teams from the topics.
+ */
+export const getTeamsFromTopics = (topics) => topics.reduce((teams, topic) => teams.concat(topic.teams), []);
+
+/**
+ * Converts snake_case keys in an object or an array of objects to camelCase.
+ * @param {object | object[]} data - Input object or an array of objects.
+ * @returns {object | object[]} - Object or array of objects with keys converted to camelCase.
+ */
+export const convertSnakeCaseToCamelCase = (object) => camelizeKeys(object);
diff --git a/plugins/communications-app/TeamEmails/utils.test.js b/plugins/communications-app/TeamEmails/utils.test.js
new file mode 100644
index 00000000..3d0cf1db
--- /dev/null
+++ b/plugins/communications-app/TeamEmails/utils.test.js
@@ -0,0 +1,94 @@
+import { getTeamsFromTopics, convertSnakeCaseToCamelCase } from './utils';
+
+describe('TeamEmail utils', () => {
+ describe('getTeamsFromTopics function', () => {
+ test('should return an array of teams when given an array of topics', () => {
+ const topics = [
+ { name: 'Topic 1', teams: ['Team A', 'Team B'] },
+ { name: 'Topic 2', teams: ['Team C', 'Team D'] },
+ ];
+
+ const result = getTeamsFromTopics(topics);
+
+ expect(result).toEqual(['Team A', 'Team B', 'Team C', 'Team D']);
+ });
+
+ test('should return an empty array when given an empty array of topics', () => {
+ const topics = [];
+
+ const result = getTeamsFromTopics(topics);
+
+ expect(result).toEqual([]);
+ });
+
+ test('should return an empty array when no teams are present in the topics', () => {
+ const topics = [
+ { name: 'Topic 1', teams: [] },
+ { name: 'Topic 2', teams: [] },
+ ];
+
+ const result = getTeamsFromTopics(topics);
+
+ expect(result).toEqual([]);
+ });
+ });
+ describe('convertSnakeCaseToCamelCase function', () => {
+ test('should convert snake_case keys in a single object to camelCase', () => {
+ const snakeCaseObj = {
+ first_name: 'John',
+ last_name: 'Doe',
+ age_group: '30-40',
+ };
+
+ const camelCaseObj = convertSnakeCaseToCamelCase(snakeCaseObj);
+
+ expect(camelCaseObj).toEqual({
+ firstName: 'John',
+ lastName: 'Doe',
+ ageGroup: '30-40',
+ });
+ });
+
+ test('should convert snake_case keys in an array of objects to camelCase', () => {
+ const snakeCaseArray = [
+ {
+ first_name: 'Alice',
+ last_name: 'Smith',
+ age_group: '20-30',
+ },
+ {
+ first_name: 'Bob',
+ last_name: 'Johnson',
+ age_group: '40-50',
+ },
+ ];
+
+ const camelCaseArray = convertSnakeCaseToCamelCase(snakeCaseArray);
+
+ expect(camelCaseArray).toEqual([
+ {
+ firstName: 'Alice',
+ lastName: 'Smith',
+ ageGroup: '20-30',
+ },
+ {
+ firstName: 'Bob',
+ lastName: 'Johnson',
+ ageGroup: '40-50',
+ },
+ ]);
+ });
+
+ test('should not convert snake_case keys null case', () => {
+ const camelCaseArray = convertSnakeCaseToCamelCase(null);
+
+ expect(camelCaseArray).toEqual(null);
+ });
+
+ test('should not convert snake_case keys empty case', () => {
+ const camelCaseArray = convertSnakeCaseToCamelCase('');
+
+ expect(camelCaseArray).toEqual('');
+ });
+ });
+});