From 01f8c9f03a1ca8ec8fcc76fb0741ebf9cffb2074 Mon Sep 17 00:00:00 2001 From: johnvente Date: Wed, 3 Jan 2024 19:37:14 -0500 Subject: [PATCH 1/6] feat: send emails by teams pluggable --- package-lock.json | 33 +++ package.json | 6 +- .../RecipientsForm/index.jsx | 11 +- .../TaskAlertModalForm/index.jsx | 11 +- .../TeamEmails/ListTeams.jsx | 56 ++++ .../TeamEmails/ListTeams.scss | 7 + .../TeamEmails/ListTeams.test.jsx | 85 ++++++ plugins/communications-app/TeamEmails/api.js | 14 + .../communications-app/TeamEmails/api.test.js | 52 ++++ .../communications-app/TeamEmails/index.jsx | 103 ++++++++ .../TeamEmails/index.test.jsx | 241 ++++++++++++++++++ .../communications-app/TeamEmails/messages.js | 12 + .../TeamEmails/package.json | 24 ++ .../communications-app/TeamEmails/utils.js | 16 ++ .../TeamEmails/utils.test.js | 94 +++++++ 15 files changed, 759 insertions(+), 6 deletions(-) create mode 100644 plugins/communications-app/TeamEmails/ListTeams.jsx create mode 100644 plugins/communications-app/TeamEmails/ListTeams.scss create mode 100644 plugins/communications-app/TeamEmails/ListTeams.test.jsx create mode 100644 plugins/communications-app/TeamEmails/api.js create mode 100644 plugins/communications-app/TeamEmails/api.test.js create mode 100644 plugins/communications-app/TeamEmails/index.jsx create mode 100644 plugins/communications-app/TeamEmails/index.test.jsx create mode 100644 plugins/communications-app/TeamEmails/messages.js create mode 100644 plugins/communications-app/TeamEmails/package.json create mode 100644 plugins/communications-app/TeamEmails/utils.js create mode 100644 plugins/communications-app/TeamEmails/utils.test.js 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..980b81c8 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]); @@ -194,7 +197,13 @@ const RecipientsForm = ({ cohorts: additionalCohorts, courseId }) => { /> )} - { isFormSubmitted && selectedGroups.length === 0 && ( + + + { isFormSubmitted && isInvalidRecipients && ( 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/ListTeams.jsx b/plugins/communications-app/TeamEmails/ListTeams.jsx new file mode 100644 index 00000000..9624edba --- /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..8e7559ba --- /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 no-unused-vars, 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..b4dafbd0 --- /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 getTeamsList(courseId, page = 1, pageSize = 100) { + const endpointUrl = `${ + getConfig().LMS_BASE_URL + }/platform-plugin-teams/${courseId}/api/topics/?page=${page}&pageSize=${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..b326508c --- /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 { getTeamsList } from './api'; + +jest.mock('@edx/frontend-platform/auth', () => ({ + getAuthenticatedHttpClient: jest.fn(), +})); +jest.mock('@edx/frontend-platform', () => ({ + getConfig: jest.fn(), +})); + +describe('getTeamsList 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 getTeamsList(mockCourseId); + + expect(response).toEqual(mockResponseData); + expect(getAuthenticatedHttpClient().get).toHaveBeenCalledWith( + `http://localhost/platform-plugin-teams/${mockCourseId}/api/topics/?page=1&pageSize=100`, + ); + }); + + test('successfully fetches teams list with custom page and pageSize', async () => { + const customPage = 2; + const customPageSize = 50; + + const response = await getTeamsList(mockCourseId, customPage, customPageSize); + + expect(response).toEqual(mockResponseData); + expect(getAuthenticatedHttpClient().get).toHaveBeenCalledWith( + `http://localhost/platform-plugin-teams/${mockCourseId}/api/topics/?page=${customPage}&pageSize=${customPageSize}`, + ); + }); + + test('handles an error', async () => { + const errorMessage = 'Network error'; + getAuthenticatedHttpClient().get.mockRejectedValue(new Error(errorMessage)); + + await expect(getTeamsList(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..acc22158 --- /dev/null +++ b/plugins/communications-app/TeamEmails/index.jsx @@ -0,0 +1,103 @@ +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 messages from './messages'; +import { getTeamsList } 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 previousFormStatusRef = useRef(null); + + useEffect(() => { + const getTeamsFromApi = async () => { + try { + const responseTeams = await getTeamsList(courseId); + const { results } = responseTeams.data; + const camelCaseResult = convertSnakeCaseToCamelCase(results); + const formatResult = getTeamsFromTopics(camelCaseResult); + setTeams(formatResult); + } catch (error) { + console.error('there was an error while getting teams', error.messages); + } + }; + + getTeamsFromApi(); + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [courseId]); + + 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)}

+ +
+ ); +}; + +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..dcf1b2e9 --- /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.getTeamsList.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.getTeamsList.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.getTeamsList.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.getTeamsList.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.getTeamsList fails', async () => { + const mockedError = new Error('API Failed'); + api.getTeamsList.mockRejectedValue(mockedError); + + const consoleSpy = jest.spyOn(console, 'error').mockImplementation(() => {}); + + render( + + + , + ); + + await waitFor(() => { + expect(consoleSpy).toHaveBeenCalledWith('there was an error while getting teams', mockedError.messages); + }); + + consoleSpy.mockRestore(); + }); +}); diff --git a/plugins/communications-app/TeamEmails/messages.js b/plugins/communications-app/TeamEmails/messages.js new file mode 100644 index 00000000..661a8722 --- /dev/null +++ b/plugins/communications-app/TeamEmails/messages.js @@ -0,0 +1,12 @@ +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', + }, +}); + +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(''); + }); + }); +}); From 928070241ed4580e7b51558292a7b0b6e79e2ef6 Mon Sep 17 00:00:00 2001 From: johnvente Date: Thu, 4 Jan 2024 12:04:05 -0500 Subject: [PATCH 2/6] chore: remove unnecessary eslint rule --- plugins/communications-app/TeamEmails/ListTeams.test.jsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/plugins/communications-app/TeamEmails/ListTeams.test.jsx b/plugins/communications-app/TeamEmails/ListTeams.test.jsx index 8e7559ba..853557e1 100644 --- a/plugins/communications-app/TeamEmails/ListTeams.test.jsx +++ b/plugins/communications-app/TeamEmails/ListTeams.test.jsx @@ -5,7 +5,7 @@ import { IntlProvider } from 'react-intl'; import ListTeams from './ListTeams'; describe('ListTeams component', () => { - // eslint-disable-next-line no-unused-vars, react/prop-types + // eslint-disable-next-line react/prop-types const IntlProviderWrapper = ({ children }) => ( {children} From f96e899d9fe06a7c613ef0ac2afc4177d4e7c5b6 Mon Sep 17 00:00:00 2001 From: johnvente Date: Fri, 5 Jan 2024 11:30:08 -0500 Subject: [PATCH 3/6] feat: adding pagination for teams --- .../ScheduleSection/index.jsx | 3 ++ .../ScheduleSection/messages.js | 4 ++ plugins/communications-app/TeamEmails/api.js | 4 +- .../communications-app/TeamEmails/api.test.js | 14 +++--- .../communications-app/TeamEmails/index.jsx | 45 +++++++++++++------ .../TeamEmails/index.test.jsx | 14 +++--- 6 files changed, 55 insertions(+), 29 deletions(-) diff --git a/plugins/communications-app/ScheduleSection/index.jsx b/plugins/communications-app/ScheduleSection/index.jsx index 05f87883..588b25ae 100644 --- a/plugins/communications-app/ScheduleSection/index.jsx +++ b/plugins/communications-app/ScheduleSection/index.jsx @@ -89,6 +89,7 @@ const ScheduleSection = ({ openTaskAlert }) => { complete: , completeSchedule: , error: , + loadingTeams: , }), []); const statefulButtonLabels = useMemo(() => ({ @@ -99,12 +100,14 @@ const ScheduleSection = ({ openTaskAlert }) => { complete: intl.formatMessage(messages.ScheduleSectionSubmitButtonComplete), completeSchedule: intl.formatMessage(messages.ScheduleSectionSubmitButtonCompleteSchedule), error: intl.formatMessage(messages.ScheduleSectionSubmitButtonError), + loadingTeams: intl.formatMessage(messages.ScheduleSectionSubmitButtonLoadingTeams), }), [intl]); const statefulButtonDisableStates = useMemo(() => [ 'pending', 'complete', 'completeSchedule', + 'loadingTeams', ], []); return ( diff --git a/plugins/communications-app/ScheduleSection/messages.js b/plugins/communications-app/ScheduleSection/messages.js index e795715d..c3f8ff07 100644 --- a/plugins/communications-app/ScheduleSection/messages.js +++ b/plugins/communications-app/ScheduleSection/messages.js @@ -48,6 +48,10 @@ const messages = defineMessages({ id: 'schedule.section.submit.button.schedule.complete', defaultMessage: 'Scheduling Done', }, + ScheduleSectionSubmitButtonLoadingTeams: { + id: 'schedule.section.submit.button.loading.teams', + defaultMessage: 'Loading teams', + }, }); export default messages; diff --git a/plugins/communications-app/TeamEmails/api.js b/plugins/communications-app/TeamEmails/api.js index b4dafbd0..625abf3d 100644 --- a/plugins/communications-app/TeamEmails/api.js +++ b/plugins/communications-app/TeamEmails/api.js @@ -1,10 +1,10 @@ import { getConfig } from '@edx/frontend-platform'; import { getAuthenticatedHttpClient } from '@edx/frontend-platform/auth'; -export async function getTeamsList(courseId, page = 1, pageSize = 100) { +export async function getTopicsList(courseId, page = 1, pageSize = 100) { const endpointUrl = `${ getConfig().LMS_BASE_URL - }/platform-plugin-teams/${courseId}/api/topics/?page=${page}&pageSize=${pageSize}`; + }/platform-plugin-teams/${courseId}/api/topics/?page=${page}&page_size=${pageSize}`; try { const response = await getAuthenticatedHttpClient().get(endpointUrl); return response; diff --git a/plugins/communications-app/TeamEmails/api.test.js b/plugins/communications-app/TeamEmails/api.test.js index b326508c..477784bc 100644 --- a/plugins/communications-app/TeamEmails/api.test.js +++ b/plugins/communications-app/TeamEmails/api.test.js @@ -1,7 +1,7 @@ import { getAuthenticatedHttpClient } from '@edx/frontend-platform/auth'; import { getConfig } from '@edx/frontend-platform'; -import { getTeamsList } from './api'; +import { getTopicsList } from './api'; jest.mock('@edx/frontend-platform/auth', () => ({ getAuthenticatedHttpClient: jest.fn(), @@ -10,7 +10,7 @@ jest.mock('@edx/frontend-platform', () => ({ getConfig: jest.fn(), })); -describe('getTeamsList function', () => { +describe('getTopicsList function', () => { const mockCourseId = 'course123'; const mockResponseData = { data: 'someData' }; const mockConfig = { LMS_BASE_URL: 'http://localhost' }; @@ -23,11 +23,11 @@ describe('getTeamsList function', () => { }); test('successfully fetches teams list with default parameters', async () => { - const response = await getTeamsList(mockCourseId); + const response = await getTopicsList(mockCourseId); expect(response).toEqual(mockResponseData); expect(getAuthenticatedHttpClient().get).toHaveBeenCalledWith( - `http://localhost/platform-plugin-teams/${mockCourseId}/api/topics/?page=1&pageSize=100`, + `http://localhost/platform-plugin-teams/${mockCourseId}/api/topics/?page=1&page_size=100`, ); }); @@ -35,11 +35,11 @@ describe('getTeamsList function', () => { const customPage = 2; const customPageSize = 50; - const response = await getTeamsList(mockCourseId, customPage, customPageSize); + 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}&pageSize=${customPageSize}`, + `http://localhost/platform-plugin-teams/${mockCourseId}/api/topics/?page=${customPage}&page_size=${customPageSize}`, ); }); @@ -47,6 +47,6 @@ describe('getTeamsList function', () => { const errorMessage = 'Network error'; getAuthenticatedHttpClient().get.mockRejectedValue(new Error(errorMessage)); - await expect(getTeamsList(mockCourseId)).rejects.toThrow(errorMessage); + await expect(getTopicsList(mockCourseId)).rejects.toThrow(errorMessage); }); }); diff --git a/plugins/communications-app/TeamEmails/index.jsx b/plugins/communications-app/TeamEmails/index.jsx index acc22158..53abbfbc 100644 --- a/plugins/communications-app/TeamEmails/index.jsx +++ b/plugins/communications-app/TeamEmails/index.jsx @@ -9,7 +9,7 @@ import { actionCreators as formActions } from '@communications-app/src/component import ListTeams from './ListTeams'; import messages from './messages'; -import { getTeamsList } from './api'; +import { getTopicsList } from './api'; import { getTeamsFromTopics, convertSnakeCaseToCamelCase } from './utils'; const TeamEmails = ({ courseId }) => { @@ -24,25 +24,44 @@ const TeamEmails = ({ courseId }) => { } = formData; const [teams, setTeams] = useState([]); const [checkedTeams, setCheckedTeams] = useState([]); + const [loadingTeams, setLoadingTeams] = useState(false); const previousFormStatusRef = useRef(null); - useEffect(() => { - const getTeamsFromApi = async () => { - try { - const responseTeams = await getTeamsList(courseId); - const { results } = responseTeams.data; - const camelCaseResult = convertSnakeCaseToCamelCase(results); - const formatResult = getTeamsFromTopics(camelCaseResult); - setTeams(formatResult); - } catch (error) { - console.error('there was an error while getting teams', error.messages); + 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({ formStatus: 'default' })); } - }; + } catch (error) { + console.error('There was an error while getting teams:', error.message); + } finally { + setLoadingTeams(false); + } + }; - getTeamsFromApi(); + useEffect(() => { + fetchTeams(); // eslint-disable-next-line react-hooks/exhaustive-deps }, [courseId]); + useEffect(() => { + if (loadingTeams) { + dispatch(formActions.updateForm({ formStatus: 'loadingTeams' })); + } + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [formStatus, loadingTeams]); + useEffect(() => { if (teams.length) { dispatch(formActions.updateForm({ teamsListFullData: teams })); diff --git a/plugins/communications-app/TeamEmails/index.test.jsx b/plugins/communications-app/TeamEmails/index.test.jsx index dcf1b2e9..c1256f50 100644 --- a/plugins/communications-app/TeamEmails/index.test.jsx +++ b/plugins/communications-app/TeamEmails/index.test.jsx @@ -148,7 +148,7 @@ describe('TeamEmails Component', () => { beforeEach(() => { jest.clearAllMocks(); - api.getTeamsList.mockResolvedValue({ data: mockData }); + api.getTopicsList.mockResolvedValue({ data: mockData }); useSelector.mockImplementation((selectorFn) => selectorFn({ form: { teamsList: [], @@ -169,7 +169,7 @@ describe('TeamEmails Component', () => { ); test('renders the component without errors', async () => { - api.getTeamsList.mockResolvedValue({ data: mockData }); + api.getTopicsList.mockResolvedValue({ data: mockData }); render( @@ -189,7 +189,7 @@ describe('TeamEmails Component', () => { }); test('renders null when teams are empty', async () => { - api.getTeamsList.mockResolvedValue({ data: { results: [] } }); + api.getTopicsList.mockResolvedValue({ data: { results: [] } }); render( @@ -205,7 +205,7 @@ describe('TeamEmails Component', () => { test('handles checkbox change', async () => { const mockDispatch = jest.fn(); useDispatch.mockReturnValue(mockDispatch); - api.getTeamsList.mockResolvedValue({ data: mockData }); + api.getTopicsList.mockResolvedValue({ data: mockData }); render( @@ -220,9 +220,9 @@ describe('TeamEmails Component', () => { }); }); - test('handles error when api.getTeamsList fails', async () => { + test('handles error when api.getTopicsList fails', async () => { const mockedError = new Error('API Failed'); - api.getTeamsList.mockRejectedValue(mockedError); + api.getTopicsList.mockRejectedValue(mockedError); const consoleSpy = jest.spyOn(console, 'error').mockImplementation(() => {}); @@ -233,7 +233,7 @@ describe('TeamEmails Component', () => { ); await waitFor(() => { - expect(consoleSpy).toHaveBeenCalledWith('there was an error while getting teams', mockedError.messages); + expect(consoleSpy).toHaveBeenCalledWith('There was an error while getting teams:', mockedError.message); }); consoleSpy.mockRestore(); From 7905e8a836cc34bf981d2cf5b8525701481f20cc Mon Sep 17 00:00:00 2001 From: johnvente Date: Fri, 5 Jan 2024 13:08:34 -0500 Subject: [PATCH 4/6] fix: loading teams messages --- .../ScheduleSection/index.jsx | 49 +++++++++++++------ .../ScheduleSection/messages.js | 5 +- .../ScheduleSection/styles.scss | 8 +++ .../TeamEmails/FeedbackMessage.jsx | 30 ++++++++++++ .../TeamEmails/FeedbackMessage.scss | 8 +++ .../TeamEmails/FeedbackMessage.test.jsx | 25 ++++++++++ .../communications-app/TeamEmails/index.jsx | 10 ++-- .../communications-app/TeamEmails/messages.js | 5 ++ 8 files changed, 120 insertions(+), 20 deletions(-) create mode 100644 plugins/communications-app/ScheduleSection/styles.scss create mode 100644 plugins/communications-app/TeamEmails/FeedbackMessage.jsx create mode 100644 plugins/communications-app/TeamEmails/FeedbackMessage.scss create mode 100644 plugins/communications-app/TeamEmails/FeedbackMessage.test.jsx diff --git a/plugins/communications-app/ScheduleSection/index.jsx b/plugins/communications-app/ScheduleSection/index.jsx index 588b25ae..f398a6d8 100644 --- a/plugins/communications-app/ScheduleSection/index.jsx +++ b/plugins/communications-app/ScheduleSection/index.jsx @@ -7,6 +7,7 @@ import { Form, Icon, Toast, + Spinner, } from '@edx/paragon'; import { SpinnerSimple, @@ -23,8 +24,9 @@ import { useSelector, useDispatch } from '@communications-app/src/components/bul import { actionCreators as formActions } from '@communications-app/src/components/bulk-email-tool/bulk-email-form/BuildEmailFormExtensible/context/reducer'; import messages from './messages'; +import './styles.scss'; -const formStatusToast = ['error', 'complete', 'completeSchedule']; +const formStatusToast = ['error', 'complete', 'completeSchedule', 'loadingTeams']; const ScheduleSection = ({ openTaskAlert }) => { const intl = useIntl(); @@ -39,6 +41,7 @@ const ScheduleSection = ({ openTaskAlert }) => { isEditMode, formStatus, isScheduledSubmitted = false, + isLoadingTeams = false, } = formData; const formStatusErrors = { @@ -89,7 +92,7 @@ const ScheduleSection = ({ openTaskAlert }) => { complete: , completeSchedule: , error: , - loadingTeams: , + isLoadingTeams: , }), []); const statefulButtonLabels = useMemo(() => ({ @@ -100,15 +103,15 @@ const ScheduleSection = ({ openTaskAlert }) => { complete: intl.formatMessage(messages.ScheduleSectionSubmitButtonComplete), completeSchedule: intl.formatMessage(messages.ScheduleSectionSubmitButtonCompleteSchedule), error: intl.formatMessage(messages.ScheduleSectionSubmitButtonError), - loadingTeams: intl.formatMessage(messages.ScheduleSectionSubmitButtonLoadingTeams), + loadingTeams: intl.formatMessage(messages.ScheduleSectionSubmitButtonDefault), }), [intl]); const statefulButtonDisableStates = useMemo(() => [ 'pending', 'complete', 'completeSchedule', - 'loadingTeams', - ], []); + isLoadingTeams ? 'loadingTeams' : '', + ], [isLoadingTeams]); return ( @@ -150,16 +153,32 @@ const ScheduleSection = ({ openTaskAlert }) => { )} - +
+ + {isLoadingTeams && ( + + )} + > + {intl.formatMessage(messages.ScheduleSectionSubmitButtonFeedBackLoadingTeams)} + + )} +
( + + )} + > + {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/index.jsx b/plugins/communications-app/TeamEmails/index.jsx index 53abbfbc..89b4c305 100644 --- a/plugins/communications-app/TeamEmails/index.jsx +++ b/plugins/communications-app/TeamEmails/index.jsx @@ -8,6 +8,7 @@ import { 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'; @@ -41,7 +42,7 @@ const TeamEmails = ({ courseId }) => { if (next) { fetchTeams(page + 1); } else { - dispatch(formActions.updateForm({ formStatus: 'default' })); + dispatch(formActions.updateForm({ isLoadingTeams: false })); } } catch (error) { console.error('There was an error while getting teams:', error.message); @@ -57,10 +58,10 @@ const TeamEmails = ({ courseId }) => { useEffect(() => { if (loadingTeams) { - dispatch(formActions.updateForm({ formStatus: 'loadingTeams' })); + dispatch(formActions.updateForm({ isLoadingTeams: true })); } // eslint-disable-next-line react-hooks/exhaustive-deps - }, [formStatus, loadingTeams]); + }, [loadingTeams]); useEffect(() => { if (teams.length) { @@ -111,6 +112,9 @@ const TeamEmails = ({ courseId }) => { teamsSelected={checkedTeams} onChangeCheckBox={handleChangeTeamCheckBox} /> + {loadingTeams && ( + + )} ); }; diff --git a/plugins/communications-app/TeamEmails/messages.js b/plugins/communications-app/TeamEmails/messages.js index 661a8722..b957dc1c 100644 --- a/plugins/communications-app/TeamEmails/messages.js +++ b/plugins/communications-app/TeamEmails/messages.js @@ -7,6 +7,11 @@ const messages = defineMessages({ 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; From 9752165f2ba39e709e18a839b7777b773f4535f6 Mon Sep 17 00:00:00 2001 From: johnvente Date: Fri, 5 Jan 2024 14:03:02 -0500 Subject: [PATCH 5/6] refactor: addressing to some pr comments --- .../ScheduleSection/index.jsx | 17 +++++++++++------ .../communications-app/TeamEmails/ListTeams.jsx | 2 +- 2 files changed, 12 insertions(+), 7 deletions(-) diff --git a/plugins/communications-app/ScheduleSection/index.jsx b/plugins/communications-app/ScheduleSection/index.jsx index f398a6d8..5d4bf55c 100644 --- a/plugins/communications-app/ScheduleSection/index.jsx +++ b/plugins/communications-app/ScheduleSection/index.jsx @@ -26,7 +26,12 @@ import { actionCreators as formActions } from '@communications-app/src/component import messages from './messages'; import './styles.scss'; -const formStatusToast = ['error', 'complete', 'completeSchedule', 'loadingTeams']; +const ERROR = 'error'; +const COMPLETE = 'complete'; +const COMPLETE_SCHEDULE = 'completeSchedule'; +const LOADING_TEAMS = 'loadingTeams'; +const PENDING = 'pending'; +const formStatusToast = [ERROR, COMPLETE, COMPLETE_SCHEDULE, LOADING_TEAMS]; const ScheduleSection = ({ openTaskAlert }) => { const intl = useIntl(); @@ -107,10 +112,10 @@ const ScheduleSection = ({ openTaskAlert }) => { }), [intl]); const statefulButtonDisableStates = useMemo(() => [ - 'pending', - 'complete', - 'completeSchedule', - isLoadingTeams ? 'loadingTeams' : '', + PENDING, + COMPLETE, + COMPLETE_SCHEDULE, + isLoadingTeams ? LOADING_TEAMS : '', ], [isLoadingTeams]); return ( @@ -159,7 +164,7 @@ const ScheduleSection = ({ openTaskAlert }) => { data-testid="send-email-btn" variant="primary" onClick={handleClickStatefulButton} - state={isLoadingTeams ? 'loadingTeams' : formStatus} + state={isLoadingTeams ? LOADING_TEAMS : formStatus} icons={statefulButtonIcons} labels={statefulButtonLabels} disabledStates={statefulButtonDisableStates} diff --git a/plugins/communications-app/TeamEmails/ListTeams.jsx b/plugins/communications-app/TeamEmails/ListTeams.jsx index 9624edba..6373e211 100644 --- a/plugins/communications-app/TeamEmails/ListTeams.jsx +++ b/plugins/communications-app/TeamEmails/ListTeams.jsx @@ -12,7 +12,7 @@ const ListTeams = ({ teams, onChangeCheckBox, teamsSelected }) => ( Date: Mon, 27 May 2024 14:34:21 -0400 Subject: [PATCH 6/6] fix: use different ID for individual learner message --- plugins/communications-app/RecipientsForm/index.jsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/plugins/communications-app/RecipientsForm/index.jsx b/plugins/communications-app/RecipientsForm/index.jsx index 980b81c8..0aed8864 100644 --- a/plugins/communications-app/RecipientsForm/index.jsx +++ b/plugins/communications-app/RecipientsForm/index.jsx @@ -179,7 +179,7 @@ const RecipientsForm = ({ cohorts: additionalCohorts, courseId }) => { disabled={hasAllLearnersSelected} >