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:
24
src/components/bulk-email-tool/data/BulkEmailProvider.jsx
Normal file
24
src/components/bulk-email-tool/data/BulkEmailProvider.jsx
Normal 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,
|
||||
};
|
||||
31
src/components/bulk-email-tool/data/actions.js
Normal file
31
src/components/bulk-email-tool/data/actions.js
Normal 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,
|
||||
},
|
||||
});
|
||||
9
src/components/bulk-email-tool/data/api.js
Normal file
9
src/components/bulk-email-tool/data/api.js
Normal 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);
|
||||
}
|
||||
49
src/components/bulk-email-tool/data/reducer.js
Normal file
49
src/components/bulk-email-tool/data/reducer.js
Normal 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();
|
||||
}
|
||||
}
|
||||
@@ -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');
|
||||
@@ -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;
|
||||
});
|
||||
67
src/components/bulk-email-tool/data/test/reducer.test.js
Normal file
67
src/components/bulk-email-tool/data/test/reducer.test.js
Normal 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);
|
||||
});
|
||||
});
|
||||
29
src/components/bulk-email-tool/data/thunks.js
Normal file
29
src/components/bulk-email-tool/data/thunks.js
Normal 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));
|
||||
};
|
||||
}
|
||||
33
src/utils/useAsyncReducer.js
Normal file
33
src/utils/useAsyncReducer.js
Normal 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;
|
||||
Reference in New Issue
Block a user