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(''); + }); + }); +});