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
This commit is contained in:
Thomas Tracy
2022-05-16 11:43:04 -04:00
committed by GitHub
parent e09bb55544
commit ffa0361c22
9 changed files with 280 additions and 0 deletions

View File

@@ -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 <BulkEmailContext.Provider value={[state, dispatch]}>{children}</BulkEmailContext.Provider>;
}
BulkEmailProvider.propTypes = {
children: PropTypes.oneOfType([PropTypes.arrayOf(PropTypes.node), PropTypes.node]).isRequired,
};

View File

@@ -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,
},
});

View File

@@ -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);
}

View File

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

View File

@@ -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: '<p>body</p>',
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');

View File

@@ -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;
});

View File

@@ -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);
});
});

View File

@@ -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));
};
}

View File

@@ -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;