fix: unenroll messaging (#84)

This commit is contained in:
Ben Warzeski
2022-12-02 15:10:11 -05:00
committed by GitHub
parent 496abc5bfb
commit f435e8d2c2
12 changed files with 144 additions and 81 deletions

View File

@@ -23,12 +23,12 @@ exports[`app registry subscribe: APP_READY. links App to root element 1`] = `
>
<Switch>
<PageRoute
path="/courses"
path="/"
>
<App />
</PageRoute>
<Redirect
to="/courses"
to="/"
/>
</Switch>
</AppProvider>

View File

@@ -13,7 +13,6 @@ import messages from './messages';
export const ReasonPane = ({
reason,
handleSubmit,
}) => {
const { formatMessage } = useIntl();
const option = (key) => (
@@ -38,10 +37,10 @@ export const ReasonPane = ({
</Form.Radio>
</Form.RadioSet>
<ActionRow>
<Button variant="tertiary" onClick={reason.skip}>
<Button variant="tertiary" onClick={reason.handleSkip}>
{formatMessage(messages.reasonSkip)}
</Button>
<Button onClick={handleSubmit}>
<Button onClick={reason.handleSubmit}>
{formatMessage(messages.reasonSubmit)}
</Button>
</ActionRow>
@@ -51,15 +50,15 @@ export const ReasonPane = ({
ReasonPane.propTypes = {
reason: PropTypes.shape({
value: PropTypes.string,
skip: PropTypes.func,
handleSkip: PropTypes.func,
selectOption: PropTypes.func,
customOption: PropTypes.shape({
value: PropTypes.string,
onChange: PropTypes.func,
}),
selected: PropTypes.string,
handleSubmit: PropTypes.func.isRequired,
}).isRequired,
handleSubmit: PropTypes.func.isRequired,
};
export default ReasonPane;

View File

@@ -6,15 +6,15 @@ import { ReasonPane } from './ReasonPane';
describe('UnenrollConfirmModal ReasonPane', () => {
const props = {
reason: {
skip: jest.fn().mockName('props.reason.skip'),
handleSkip: jest.fn().mockName('props.reason.handleSkip'),
selectOption: jest.fn().mockName('props.reason.selectOption'),
customOption: {
value: 'props.reason.customOption.value',
onChange: jest.fn().mockName('props.reason.customOption.onChange'),
},
selected: 'props.reason.selected',
handleSubmit: jest.fn().mockName('props.reason.handleSubmit'),
},
handleSubmit: jest.fn().mockName('props.handleSubmit'),
};
test('snapshot', () => {
expect(shallow(<ReasonPane {...props} />)).toMatchSnapshot();

View File

@@ -76,13 +76,13 @@ exports[`UnenrollConfirmModal ReasonPane snapshot 1`] = `
</Form.RadioSet>
<ActionRow>
<Button
onClick={[MockFunction props.reason.skip]}
onClick={[MockFunction props.reason.handleSkip]}
variant="tertiary"
>
Skip
</Button>
<Button
onClick={[MockFunction props.handleSubmit]}
onClick={[MockFunction props.reason.handleSubmit]}
>
Submit
</Button>

View File

@@ -30,12 +30,12 @@ export const messages = StrictDict({
reasonSkip: {
id: 'learner-dash.unenrollConfirm.confirm.reason.skip',
description: 'Skip action for unenroll reason modal',
defaultMessage: 'Skip',
defaultMessage: 'Skip survey',
},
reasonSubmit: {
id: 'learner-dash.unenrollConfirm.confirm.reason.submit',
description: 'Submit action for unenroll reason modal',
defaultMessage: 'Submit',
defaultMessage: 'Submit reason',
},
finishHeading: {
id: 'learner-dash.unenrollConfirm.confirm.finish.heading',

View File

@@ -1,8 +1,7 @@
import React from 'react';
import { StrictDict } from 'utils';
import { hooks as appHooks, thunkActions } from 'data/redux';
import track from 'tracking';
import { thunkActions } from 'data/redux';
import { useUnenrollReasons } from './reasons';
import * as module from '.';
@@ -21,29 +20,19 @@ export const useUnenrollData = ({ closeModal, dispatch, cardId }) => {
const [isConfirmed, setIsConfirmed] = module.state.confirmed(false);
const confirm = () => setIsConfirmed(true);
const reason = useUnenrollReasons({ dispatch, cardId });
const { isEntitlement } = appHooks.useCardEntitlementData(cardId);
const handleTrackReasons = appHooks.useTrackCourseEvent(
track.engagement.unenrollReason,
cardId,
reason.submittedReason,
isEntitlement,
);
let modalState;
if (isConfirmed) {
modalState = reason.isSubmitted ? modalStates.finished : modalStates.reason;
modalState = (reason.isSubmitted || reason.isSkipped)
? modalStates.finished : modalStates.reason;
} else {
modalState = modalStates.confirm;
}
const handleSubmitReason = () => {
handleTrackReasons();
dispatch(thunkActions.app.unenrollFromCourse(cardId, reason.submittedReason));
};
const close = () => {
closeModal();
setIsConfirmed(false);
reason.clear();
reason.handleClear();
};
const closeAndRefresh = () => {
dispatch(thunkActions.app.refreshList());
@@ -57,7 +46,6 @@ export const useUnenrollData = ({ closeModal, dispatch, cardId }) => {
close,
closeAndRefresh,
modalState,
handleSubmitReason,
};
};

View File

@@ -18,7 +18,7 @@ const testValue = 'test-value';
let out;
const mockReason = {
clear: jest.fn(),
handleClear: jest.fn(),
isSubmitted: false,
submittedReason: 'test-submitted-reason',
};
@@ -58,19 +58,19 @@ describe('UnenrollConfirmModal hooks', () => {
expect(out.reason).toEqual(mockReason);
});
describe('close', () => {
it('calls closeModal, sets isConfirmed to false, and calls reason.clear', () => {
it('calls closeModal, sets isConfirmed to false, and calls reason.handleClear', () => {
out.close();
expect(closeModal).toHaveBeenCalled();
expect(state.setState.confirmed).toHaveBeenCalledWith(false);
expect(mockReason.clear).toHaveBeenCalled();
expect(mockReason.handleClear).toHaveBeenCalled();
});
});
describe('closeAndRefresh', () => {
it('calls closeModal, sets isConfirmed to false, and calls reason.clear', () => {
it('calls closeModal, sets isConfirmed to false, and calls reason.handleClear', () => {
out.closeAndRefresh();
expect(closeModal).toHaveBeenCalled();
expect(state.setState.confirmed).toHaveBeenCalledWith(false);
expect(mockReason.clear).toHaveBeenCalled();
expect(mockReason.handleClear).toHaveBeenCalled();
});
it('dispatches refreshList thunkAction', () => {
out.closeAndRefresh();

View File

@@ -1,8 +1,9 @@
import React from 'react';
import { thunkActions } from 'data/redux';
import { thunkActions, hooks as appHooks } from 'data/redux';
import { useValueCallback } from 'hooks';
import { StrictDict } from 'utils';
import track from 'tracking';
import * as module from './reasons';
@@ -10,34 +11,63 @@ export const state = StrictDict({
customOption: (val) => React.useState(val), // eslint-disable-line
isSkipped: (val) => React.useState(val), // eslint-disable-line
selectedReason: (val) => React.useState(val), // eslint-disable-line
isSubmitted: (val) => React.useState(val), //eslint-disable-line
});
export const useTrackUnenrollReasons = ({ cardId, submittedReason }) => {
const { isEntitlement } = appHooks.useCardEntitlementData(cardId);
return appHooks.useTrackCourseEvent(
track.engagement.unenrollReason,
cardId,
submittedReason,
isEntitlement,
);
};
export const useUnenrollReasons = ({
dispatch,
cardId,
}) => {
// The selected option element from the menu
const [selectedReason, setSelectedReason] = module.state.selectedReason(null);
const [isSkipped, setIsSkipped] = module.state.isSkipped(false);
// Custom option element entry value
const [customOption, setCustomOption] = module.state.customOption('');
// Did the user choose to skip selecting a reason?
const [isSkipped, setIsSkipped] = module.state.isSkipped(false);
// Did the user submit an unenrollment reason
const [isSubmitted, setIsSubmitted] = module.state.isSubmitted(false);
const submittedReason = selectedReason === 'custom' ? customOption : selectedReason;
const clear = () => {
const handleTrackReasons = module.useTrackUnenrollReasons({ cardId, submittedReason });
const handleClear = () => {
setSelectedReason(null);
setCustomOption('');
setIsSkipped(false);
setIsSubmitted(false);
};
const skip = () => {
const handleSkip = () => {
setIsSkipped(true);
dispatch(thunkActions.app.unenrollFromCourse(cardId));
};
const handleSubmit = (e) => {
handleTrackReasons(e);
setIsSubmitted(true);
dispatch(thunkActions.app.unenrollFromCourse(cardId, submittedReason));
};
return {
clear,
customOption: { value: customOption, onChange: useValueCallback(setCustomOption) },
selectOption: useValueCallback(setSelectedReason),
handleClear,
handleSkip,
handleSubmit,
isSkipped,
skip,
isSubmitted: isSkipped,
isSubmitted,
selectOption: useValueCallback(setSelectedReason),
submittedReason,
};
};

View File

@@ -1,20 +1,32 @@
import { thunkActions } from 'data/redux';
import { keyStore } from 'utils';
import { thunkActions, hooks as appHooks } from 'data/redux';
import { useValueCallback } from 'hooks';
import { MockUseState } from 'testUtils';
import track from 'tracking';
import * as hooks from './reasons';
jest.mock('data/redux', () => ({
thunkActions: {
app: {
refreshList: jest.fn((args) => ({ refreshList: args })),
unenrollFromCourse: jest.fn((...args) => ({ unenrollFromCourse: args })),
},
},
hooks: {
useCardEntitlementData: jest.fn(),
useTrackCourseEvent: jest.fn(),
},
}));
jest.mock('hooks', () => ({
useValueCallback: jest.fn((cb, prereqs) => ({ useValueCallback: { cb, prereqs } })),
}));
jest.mock('data/redux/thunkActions/app', () => ({
refreshList: jest.fn((args) => ({ refreshList: args })),
unenrollFromCourse: jest.fn((...args) => ({ unenrollFromCourse: args })),
}));
const state = new MockUseState(hooks);
const testValue = 'test-value';
const testValue2 = 'test-value2';
const moduleKeys = keyStore(hooks);
let out;
describe('UnenrollConfirmModal reasons hooks', () => {
@@ -26,50 +38,93 @@ describe('UnenrollConfirmModal reasons hooks', () => {
describe('state fields', () => {
state.testGetter(state.keys.customOption);
state.testGetter(state.keys.isSkipped);
state.testGetter(state.keys.isSubmitted);
state.testGetter(state.keys.selectedReason);
});
describe('useTrackUnenrollReasons', () => {
it('returns trackCourseEvent for unenroll with submitted reason and isEntitlement', () => {
appHooks.useCardEntitlementData.mockReturnValue({ isEntitlement: false });
const args = { cardId, submittedReason: testValue };
expect(hooks.useTrackUnenrollReasons(args)).toEqual(appHooks.useTrackCourseEvent(
track.engagement.unenrollReason,
args.cardId,
args.submittedReason,
false,
));
});
});
describe('useUnenrollReasons', () => {
const trackReasonsEvent = jest.fn((e) => ({ trackReasonsEvent: e }));
let useTrackUnenrollReasons;
beforeEach(() => {
state.mock();
appHooks.useCardEntitlementData.mockReturnValue({ isEntitlement: false });
useTrackUnenrollReasons = jest.spyOn(hooks, moduleKeys.useTrackUnenrollReasons)
.mockImplementation(() => trackReasonsEvent);
out = createUseUnenrollReasons();
});
afterEach(() => {
state.restore();
});
describe('clear method', () => {
describe('customOption', () => {
test('customOption.value returns custom option', () => {
state.mockVal(state.keys.customOption, testValue);
expect(createUseUnenrollReasons().customOption.value).toEqual(testValue);
});
test('customOption.onChange returns valueCallback for setCustomOption', () => {
expect(out.customOption.onChange).toEqual(useValueCallback(state.setState.customOption));
});
});
describe('handleClear method', () => {
it('resets selected and submitted reasons, custom option and isSkipped', () => {
out.clear();
out.handleClear();
expect(state.setState.selectedReason).toHaveBeenCalledWith(null);
expect(state.setState.customOption).toHaveBeenCalledWith('');
expect(state.setState.isSkipped).toHaveBeenCalledWith(false);
expect(state.setState.isSubmitted).toHaveBeenCalledWith(false);
});
});
test('customOption.value returns custom option', () => {
state.mockVal(state.keys.customOption, testValue);
expect(createUseUnenrollReasons().customOption.value).toEqual(testValue);
test('handleSkip sets isSkipped and isSubmitted, and unenrolls w/out a reason', () => {
out.handleSkip();
expect(state.setState.isSkipped).toHaveBeenCalledWith(true);
expect(dispatch).toHaveBeenCalledWith(thunkActions.app.unenrollFromCourse(cardId));
});
test('customOption.onChange returns valueCallback for setCustomOption', () => {
expect(out.customOption.onChange).toEqual(useValueCallback(state.setState.customOption));
});
test('selectedOption returns valueCallback for setSelectedReason', () => {
expect(out.selectOption).toEqual(useValueCallback(state.setState.selectedReason));
describe('handleSubmit', () => {
it('tracks reason event and dispatches unenroll thunk action', () => {
state.mockVal(state.keys.selectedReason, testValue);
out = createUseUnenrollReasons();
expect(useTrackUnenrollReasons).toHaveBeenCalledWith({
cardId,
submittedReason: testValue,
});
expect(trackReasonsEvent).not.toHaveBeenCalled();
const event = { test: 'event' };
out.handleSubmit(event);
expect(trackReasonsEvent).toHaveBeenCalledWith(event);
expect(dispatch).toHaveBeenCalledWith(thunkActions.app.unenrollFromCourse(cardId));
});
});
test('isSkipped returns state value', () => {
state.mockVal(state.keys.isSkipped, testValue);
expect(createUseUnenrollReasons().isSkipped).toEqual(testValue);
});
test('skip returns callback that sets isSkipped to true and unenrolls with no reason', () => {
out.skip();
expect(state.setState.isSkipped).toHaveBeenCalledWith(true);
expect(dispatch).toHaveBeenCalledWith(thunkActions.app.unenrollFromCourse(cardId));
test('isSubmitted returns state value', () => {
state.mockVal(state.keys.isSubmitted, testValue);
expect(createUseUnenrollReasons().isSubmitted).toEqual(testValue);
});
describe('isSubmitted', () => {
it('returns false if submittedReason is null and not isSkipped', () => {
expect(out.isSubmitted).toEqual(false);
});
it('returns true if isSkipped', () => {
state.mockVal(state.keys.isSkipped, true);
expect(createUseUnenrollReasons().isSubmitted).toEqual(true);
test('selectedOption returns valueCallback for setSelectedReason', () => {
expect(out.selectOption).toEqual(useValueCallback(state.setState.selectedReason));
});
describe('submittedReason', () => {
it('returns the selected reason unless custom is selcted, then shows custom option', () => {
state.mockVal(state.keys.selectedReason, testValue);
state.mockVal(state.keys.customOption, testValue2);
out = createUseUnenrollReasons();
expect(out.submittedReason).toEqual(testValue);
state.mockVal(state.keys.selectedReason, 'custom');
state.mockVal(state.keys.customOption, testValue2);
out = createUseUnenrollReasons();
expect(out.submittedReason).toEqual(testValue2);
});
});
});

View File

@@ -27,7 +27,6 @@ export const UnenrollConfirmModal = ({
closeAndRefresh,
close,
modalState,
handleSubmitReason,
} = useUnenrollData({ dispatch, closeModal, cardId });
const showFullscreen = modalState === modalStates.reason;
return (
@@ -49,7 +48,7 @@ export const UnenrollConfirmModal = ({
<FinishedPane handleClose={closeAndRefresh} gaveReason={!reason.isSkipped} />
)}
{(modalState === modalStates.reason) && (
<ReasonPane reason={reason} handleSubmit={handleSubmitReason} />
<ReasonPane reason={reason} />
)}
</div>
</ModalDialog>

View File

@@ -59,10 +59,7 @@ export const leaveEntitlementSession = (cardId) => (dispatch, getState) => {
export const unenrollFromCourse = (cardId) => (dispatch, getState) => {
const { courseId } = selectors.app.courseCard.courseRun(getState(), cardId);
dispatch(requests.unenrollFromCourse({
courseId,
onSuccess: () => dispatch(module.initialize()),
}));
dispatch(requests.unenrollFromCourse({ courseId }));
};
export const masqueradeAs = (user) => (dispatch) => (

View File

@@ -145,15 +145,10 @@ describe('app thunk actions', () => {
});
describe('unenrollFromCourse', () => {
const reason = 'test-reason';
beforeEach(() => {
initializeSpy.mockImplementationOnce(mockInitialize);
});
it('dispatches unenrollFromCourse request action, re-initializing on success', () => {
it('dispatches unenrollFromCourse request action', () => {
module.unenrollFromCourse(cardId, reason)(dispatch, getState);
const request = dispatch.mock.calls[0][0];
expect(request.unenrollFromCourse.courseId).toEqual(courseId);
request.unenrollFromCourse.onSuccess();
expect(dispatch).toHaveBeenCalledWith(mockInitialize());
});
});
describe('masqueradeAs', () => {