From ffa0361c22f5bc02d8a0f9f949b32789bc172b01 Mon Sep 17 00:00:00 2001 From: Thomas Tracy Date: Mon, 16 May 2022 11:43:04 -0400 Subject: [PATCH] feat: [MICROBA-1506] bulkEmailTool data context (#39) Currently, our bulk email tool in concept looks something like this: - BulkEmailTool - BulkEmailForm - BulkEmailTaskManager Right now, the two components under the parent BulkEmailTool dont really need to communicate with each other. For scheduled email, these two components are going to be relying on the same data, and there need to be provided that data by the parent. In order to make things more manageable, this PR sets up some boilerplate and patterning for this data. What this PR will include: - Documentation around the pattern - Necessary boilerplate to leverage the context store for the BulkEmailTool - Tests around said store What this PR will not include: - Changes to the UI or form functionality --- .../data/BulkEmailProvider.jsx | 24 +++++++ .../bulk-email-tool/data/actions.js | 31 +++++++++ src/components/bulk-email-tool/data/api.js | 9 +++ .../bulk-email-tool/data/reducer.js | 49 ++++++++++++++ .../test/__factories__/courseEmail.factory.js | 19 ++++++ .../__factories__/scheduledEmails.factory.js | 19 ++++++ .../bulk-email-tool/data/test/reducer.test.js | 67 +++++++++++++++++++ src/components/bulk-email-tool/data/thunks.js | 29 ++++++++ src/utils/useAsyncReducer.js | 33 +++++++++ 9 files changed, 280 insertions(+) create mode 100644 src/components/bulk-email-tool/data/BulkEmailProvider.jsx create mode 100644 src/components/bulk-email-tool/data/actions.js create mode 100644 src/components/bulk-email-tool/data/api.js create mode 100644 src/components/bulk-email-tool/data/reducer.js create mode 100644 src/components/bulk-email-tool/data/test/__factories__/courseEmail.factory.js create mode 100644 src/components/bulk-email-tool/data/test/__factories__/scheduledEmails.factory.js create mode 100644 src/components/bulk-email-tool/data/test/reducer.test.js create mode 100644 src/components/bulk-email-tool/data/thunks.js create mode 100644 src/utils/useAsyncReducer.js diff --git a/src/components/bulk-email-tool/data/BulkEmailProvider.jsx b/src/components/bulk-email-tool/data/BulkEmailProvider.jsx new file mode 100644 index 0000000..465f0ab --- /dev/null +++ b/src/components/bulk-email-tool/data/BulkEmailProvider.jsx @@ -0,0 +1,24 @@ +import React from 'react'; +import PropTypes from 'prop-types'; +import useAsyncReducer from '../../../utils/useAsyncReducer'; +import BulkEmailReducer from './reducer'; + +export const BulkEmailContext = React.createContext(); + +export function BulkEmailProvider({ children }) { + const initialState = { + editor: { + emailBody: {}, + emailSubject: '', + emailRecipient: [], + emailSchedule: '', + }, + scheduledEmails: [], + }; + const [state, dispatch] = useAsyncReducer(BulkEmailReducer, initialState); + return {children}; +} + +BulkEmailProvider.propTypes = { + children: PropTypes.oneOfType([PropTypes.arrayOf(PropTypes.node), PropTypes.node]).isRequired, +}; diff --git a/src/components/bulk-email-tool/data/actions.js b/src/components/bulk-email-tool/data/actions.js new file mode 100644 index 0000000..d8638c7 --- /dev/null +++ b/src/components/bulk-email-tool/data/actions.js @@ -0,0 +1,31 @@ +export const fetchScheduledEmails = () => ({ + type: 'FETCH_SCHEDULED_EMAILS', +}); + +export const fetchScheduledEmailsStart = () => ({ + type: 'FETCH_START', +}); + +export const fetchScheduledEmailsComplete = (scheduledEmails) => ({ + type: 'FETCH_COMPLETE', + payload: { scheduledEmails }, +}); + +export const fetchScheduledEmailsError = () => ({ + type: 'FETCH_ERROR', +}); + +export const copyToEditor = (body, subject) => ({ + type: 'COPY_TO_EDITOR', + payload: { + emailBody: body, + emailSubject: subject, + }, +}); + +export const handleEditorChange = (fieldName, fieldValue) => ({ + type: 'EDITOR_ON_CHANGE', + payload: { + [fieldName]: fieldValue, + }, +}); diff --git a/src/components/bulk-email-tool/data/api.js b/src/components/bulk-email-tool/data/api.js new file mode 100644 index 0000000..c38c354 --- /dev/null +++ b/src/components/bulk-email-tool/data/api.js @@ -0,0 +1,9 @@ +import { camelCaseObject, getConfig } from '@edx/frontend-platform'; +import { getAuthenticatedHttpClient } from '@edx/frontend-platform/auth'; + +// eslint-disable-next-line import/prefer-default-export +export async function getScheduledBulkEmailData(courseId) { + const endpointUrl = `${getConfig().LMS_BASE_URL}/api/instructor_task/v1/schedules/${courseId}/bulk_email/`; + const { data } = await getAuthenticatedHttpClient().get(endpointUrl); + return camelCaseObject(data); +} diff --git a/src/components/bulk-email-tool/data/reducer.js b/src/components/bulk-email-tool/data/reducer.js new file mode 100644 index 0000000..adeb5fc --- /dev/null +++ b/src/components/bulk-email-tool/data/reducer.js @@ -0,0 +1,49 @@ +import { logError, logInfo } from '@edx/frontend-platform/logging'; + +export default function BulkEmailReducer(state, action) { + switch (action.type) { + case 'FETCH_SCHEDULED_EMAILS': + logInfo(action.type, state); + return state; + case 'FETCH_START': + logInfo(action.type, state); + return { + ...state, + isLoading: true, + }; + case 'FETCH_COMPLETE': + logInfo(action.type, state); + return { + ...state, + isLoading: false, + errorRetrievingData: false, + ...action.payload, + }; + case 'FETCH_FAILURE': + logError(action.type, state); + return { + ...state, + isLoading: false, + errorRetrievingData: true, + }; + case 'COPY_TO_EDITOR': + logInfo(action.type, state); + return { + ...state, + editor: { + ...action.payload, + }, + }; + case 'EDITOR_ON_CHANGE': + logInfo(action.type, state); + return { + ...state, + editor: { + ...state.editor, + ...action.payload, + }, + }; + default: + throw new Error(); + } +} diff --git a/src/components/bulk-email-tool/data/test/__factories__/courseEmail.factory.js b/src/components/bulk-email-tool/data/test/__factories__/courseEmail.factory.js new file mode 100644 index 0000000..a3a684b --- /dev/null +++ b/src/components/bulk-email-tool/data/test/__factories__/courseEmail.factory.js @@ -0,0 +1,19 @@ +import { Factory } from 'rosie'; // eslint-disable-line import/no-extraneous-dependencies + +Factory.define('emailDataFactory') + .sequence('id') + .attrs({ + subject: 'subject', + html_message: '

body

', + text_message: 'body', + course_id: 'course-v1:edX+DemoX+Demo_Course', + to_option: '', + sender: 'edx', + targets: ['learners'], + }); + +export default Factory.define('courseEmailFactory') + .sequence('id') + .attr('course_email', Factory.build('emailDataFactory')) + .sequence('task') + .attr('task_due', '2022-04-27T17:00:00Z'); diff --git a/src/components/bulk-email-tool/data/test/__factories__/scheduledEmails.factory.js b/src/components/bulk-email-tool/data/test/__factories__/scheduledEmails.factory.js new file mode 100644 index 0000000..260a7eb --- /dev/null +++ b/src/components/bulk-email-tool/data/test/__factories__/scheduledEmails.factory.js @@ -0,0 +1,19 @@ +import { Factory } from 'rosie'; // eslint-disable-line import/no-extraneous-dependencies +import './courseEmail.factory'; + +export default Factory.define('scheduledEmailFactory') + .attr( + 'next', + 'http://localhost:18000/api/instructor_task/v1/schedules/course-v1:edX+DemoX+Demo_Course/bulk_email/?page=$2', + ) + .attr('previous', 'null') + .option('count', '1') + .attr('current_page', 1) + .attr('start', 0) + .attr('results', ['count'], (count) => { + const emails = []; + for (let i = 1; i <= count; i++) { + emails.push(Factory.build('courseEmailFactory')); + } + return emails; + }); diff --git a/src/components/bulk-email-tool/data/test/reducer.test.js b/src/components/bulk-email-tool/data/test/reducer.test.js new file mode 100644 index 0000000..6fd3c39 --- /dev/null +++ b/src/components/bulk-email-tool/data/test/reducer.test.js @@ -0,0 +1,67 @@ +import { initializeMockApp } from '../../../../setupTest'; +import BulkEmailReducer from '../reducer'; + +describe('BulkEmailReducer', () => { + const testState = { + editor: { + emailBody: {}, + emailSubject: '', + emailRecipient: [], + emailSchedule: '', + }, + scheduledEmails: [], + isLoading: false, + errorRetrievingData: false, + }; + beforeAll(async () => { + await initializeMockApp(); + }); + + it('does not change state on FETCH_SCHEDULED_EMAILS', () => { + expect(BulkEmailReducer(testState, { type: 'FETCH_SCHEDULED_EMAILS' })).toEqual(testState); + }); + it('sets loading state on FETCH_START', () => { + const finalState = { + ...testState, + isLoading: true, + }; + const returnedState = BulkEmailReducer(testState, { type: 'FETCH_START' }); + expect(returnedState).toEqual(finalState); + }); + it('adds payload on FETCH_COMPLETE', () => { + const finalState = { + ...testState, + additionalField: true, + isLoading: false, + }; + const returnedState = BulkEmailReducer(testState, { type: 'FETCH_COMPLETE', payload: { additionalField: true } }); + expect(returnedState).toEqual(finalState); + expect(returnedState.isLoading).toEqual(false); + expect(returnedState.errorRetrievingData).toEqual(false); + }); + it('sets Error to true when FETCH_FAILURE action dispatched', () => { + const finalState = { + ...testState, + isLoading: false, + errorRetrievingData: true, + }; + const returnedState = BulkEmailReducer(testState, { type: 'FETCH_FAILURE' }); + expect(returnedState).toEqual(finalState); + }); + it('it copies full editor state when COPY_TO_EDITOR action dispatched', () => { + const newEditorState = { + emailBody: 'test', + emailSubject: 'test', + emailRecipient: ['test'], + emailSchedule: 'test', + }; + const finalState = { + ...testState, + editor: { + ...newEditorState, + }, + }; + const returnedState = BulkEmailReducer(testState, { type: 'COPY_TO_EDITOR', payload: newEditorState }); + expect(returnedState).toEqual(finalState); + }); +}); diff --git a/src/components/bulk-email-tool/data/thunks.js b/src/components/bulk-email-tool/data/thunks.js new file mode 100644 index 0000000..77a787f --- /dev/null +++ b/src/components/bulk-email-tool/data/thunks.js @@ -0,0 +1,29 @@ +import { + copyToEditor, + fetchScheduledEmails, + fetchScheduledEmailsComplete, + fetchScheduledEmailsError, + fetchScheduledEmailsStart, +} from './actions'; +import { getScheduledBulkEmailData } from './api'; + +export function getScheduledBulkEmailThunk(courseId) { + return async (dispatch) => { + dispatch(fetchScheduledEmails()); + dispatch(fetchScheduledEmailsStart()); + try { + const data = await getScheduledBulkEmailData(courseId); + if (!!data && data.results) { + dispatch(fetchScheduledEmailsComplete(data.results)); + } + } catch (error) { + dispatch(fetchScheduledEmailsError()); + } + }; +} + +export function copyTextToEditorThunk(body, subject) { + return async (dispatch) => { + dispatch(copyToEditor(body, subject)); + }; +} diff --git a/src/utils/useAsyncReducer.js b/src/utils/useAsyncReducer.js new file mode 100644 index 0000000..d658f19 --- /dev/null +++ b/src/utils/useAsyncReducer.js @@ -0,0 +1,33 @@ +import { useMemo, useReducer } from 'react'; + +/** + * This helper function wraps the useReducer dispatch function to allow for invoking function calls + * when a state change is dispatched. + * @param {*} dispatch useReducer's dispatch function. + * @returns a wrapped dispatch that execututes function actions. + */ +function wrapAsync(dispatch) { + return (action) => { + if (typeof action === 'function') { + return action(dispatch); + } + return dispatch(action); + }; +} +/** + * By default, the useReducer hook does not allow for async dispatches. This small + * hook takes the dispatch function from useReducer and wraps it to allow for the execution + * of functions that are invoked with the dispatch object. This makes it easier for us to perform + * async operations, or to execute multiple dispatches in a row using a single thunk. + * @param {Function} reducer a reducer function for the context state. + * @param {Object} initialState an initial state for the context store. + * @returns [state, asyncDispatch ] + */ +const useAsyncReducer = (reducer, initialState = null) => { + const [state, dispatch] = useReducer(reducer, initialState); + const asyncDispatch = useMemo(() => wrapAsync(dispatch), [dispatch]); + + return [state, asyncDispatch]; +}; + +export default useAsyncReducer;