mubbsharanwar/unenrollment process improvement (#704)

Co-authored-by: Deborah Kaplan <deborahgu@users.noreply.github.com>
This commit is contained in:
Mubbshar Anwar
2025-09-03 23:39:49 +05:00
committed by GitHub
parent 2696486e5b
commit ff925b06f1
13 changed files with 62 additions and 82 deletions

View File

@@ -1,5 +1,6 @@
import React from 'react';
import PropTypes from 'prop-types';
import { reduxHooks } from 'hooks';
import { useIntl } from '@edx/frontend-platform/i18n';
import {
@@ -10,13 +11,17 @@ import {
import messages from './messages';
export const ConfirmPane = ({
cardId,
handleClose,
handleConfirm,
}) => {
const { formatMessage } = useIntl();
const { courseName } = reduxHooks.useCardCourseData(cardId);
const courseTitle = <span className="font-italic">{courseName}</span>;
return (
<>
<h4>{formatMessage(messages.confirmHeader)}</h4>
<p className="py-2">{formatMessage(messages.confirmText, { courseTitle })}</p>
<ActionRow>
<Button variant="tertiary" onClick={handleClose}>
{formatMessage(messages.confirmCancel)}
@@ -31,6 +36,7 @@ export const ConfirmPane = ({
ConfirmPane.propTypes = {
handleClose: PropTypes.func.isRequired,
handleConfirm: PropTypes.func.isRequired,
cardId: PropTypes.string.isRequired,
};
export default ConfirmPane;

View File

@@ -6,6 +6,7 @@ import { ConfirmPane } from './ConfirmPane';
import messages from './messages';
const props = {
cardId: 'cardId',
handleClose: jest.fn().mockName('props.handleClose'),
handleConfirm: jest.fn().mockName('props.handleConfirm'),
};

View File

@@ -1,5 +1,6 @@
import React from 'react';
import PropTypes from 'prop-types';
import { reduxHooks } from 'hooks';
import { useIntl } from '@edx/frontend-platform/i18n';
import {
@@ -10,16 +11,18 @@ import {
import messages from './messages';
export const FinishedPane = ({
gaveReason,
cardId,
handleClose,
}) => {
const { formatMessage } = useIntl();
const { courseName } = reduxHooks.useCardCourseData(cardId);
const courseTitle = <span className="font-italic">{courseName}</span>;
return (
<>
<h4>{formatMessage(messages.finishHeading)}</h4>
<p>
{gaveReason && formatMessage(messages.finishThanksText)}
{formatMessage(messages.finishText)}
{formatMessage(messages.finishText, { courseTitle })}
</p>
<ActionRow>
<Button onClick={handleClose}>{formatMessage(messages.finishReturn)}</Button>
@@ -29,7 +32,7 @@ export const FinishedPane = ({
};
FinishedPane.propTypes = {
handleClose: PropTypes.func.isRequired,
gaveReason: PropTypes.bool.isRequired,
cardId: PropTypes.string.isRequired,
};
export default FinishedPane;

View File

@@ -6,7 +6,7 @@ import { FinishedPane } from './FinishedPane';
import messages from './messages';
const props = {
gaveReason: true,
cardId: 'cardId',
handleClose: jest.fn().mockName('props.handleClose'),
};
@@ -25,22 +25,8 @@ describe('UnenrollConfirmModal FinishedPane', () => {
expect(returnButton).toBeInTheDocument();
});
it('Gave reason, display thanks message', () => {
const thanksMsg = screen.getByText((text) => text.includes('Thank you'));
expect(thanksMsg).toBeInTheDocument();
expect(thanksMsg.innerHTML).toContain(formatMessage(messages.finishThanksText));
});
});
describe('Did not give reason', () => {
it('Does not display thanks message', () => {
const customProps = {
gaveReason: false,
handleClose: jest.fn().mockName('props.handleClose'),
};
render(<IntlProvider locale="en"><FinishedPane {...customProps} /></IntlProvider>);
const thanksMsg = screen.queryByText((text) => text.includes('Thank you'));
expect(thanksMsg).toBeNull();
const finishMsg = screen.getByText(formatMessage(messages.finishText));
expect(finishMsg).toBeInTheDocument();
const finishSuccessMessage = screen.getByText((text) => text.includes('Unenrollment Successful'));
expect(finishSuccessMessage).toBeInTheDocument();
});
});
});

View File

@@ -13,6 +13,7 @@ import messages from './messages';
export const ReasonPane = ({
reason,
handleClose,
}) => {
const { formatMessage } = useIntl();
const option = (key) => (
@@ -27,6 +28,7 @@ export const ReasonPane = ({
name="unenrollReason"
onChange={reason.selectOption}
value={reason.selected}
defaultValue={constants.reasonKeys.preferNotToSay}
>
{constants.order.map(option)}
<Form.Radio value={constants.reasonKeys.custom}>
@@ -35,12 +37,13 @@ export const ReasonPane = ({
placeholder={formatMessage(constants.messages.customPlaceholder)}
/>
</Form.Radio>
{option(constants.reasonKeys.preferNotToSay)}
</Form.RadioSet>
<ActionRow>
<Button variant="tertiary" onClick={reason.handleSkip}>
{formatMessage(messages.reasonSkip)}
<Button variant="tertiary" onClick={handleClose}>
{formatMessage(messages.confirmCancel)}
</Button>
<Button disabled={!reason.hasReason} onClick={reason.handleSubmit}>
<Button onClick={reason.handleSubmit}>
{formatMessage(messages.reasonSubmit)}
</Button>
</ActionRow>
@@ -50,7 +53,6 @@ export const ReasonPane = ({
ReasonPane.propTypes = {
reason: PropTypes.shape({
value: PropTypes.string,
handleSkip: PropTypes.func,
hasReason: PropTypes.bool,
selectOption: PropTypes.func,
customOption: PropTypes.shape({
@@ -60,6 +62,7 @@ ReasonPane.propTypes = {
selected: PropTypes.string,
handleSubmit: PropTypes.func.isRequired,
}).isRequired,
handleClose: PropTypes.func.isRequired,
};
export default ReasonPane;

View File

@@ -28,11 +28,11 @@ describe('UnenrollConfirmModal ReasonPane', () => {
render(<IntlProvider locale="en"><ReasonPane {...props} /></IntlProvider>);
const radioButtons = screen.getAllByRole('radio');
expect(radioButtons).toBeDefined();
expect(radioButtons.length).toBe(10);
expect(radioButtons.length).toBe(11);
});
it('render skip button', () => {
it('render cancel button', () => {
render(<IntlProvider locale="en"><ReasonPane {...props} hasReason={false} /></IntlProvider>);
const skipButton = screen.getByRole('button', { name: formatMessage(messages.reasonSkip) });
const skipButton = screen.getByRole('button', { name: formatMessage(messages.confirmCancel) });
expect(skipButton).toBeInTheDocument();
});
it('render submit button', () => {

View File

@@ -5,12 +5,17 @@ const messages = defineMessages({
confirmHeader: {
id: 'learner-dash.unenrollConfirm.confirm.header',
description: 'Header for confirm unenroll modal',
defaultMessage: 'Unenroll from course?',
defaultMessage: 'Confirm Unenrollment',
},
confirmText: {
id: 'learner-dash.unenrollConfirm.confirm.text',
description: 'Text for confirm unenroll modal',
defaultMessage: 'Are you sure you want to unenroll from the course {courseTitle} ?',
},
confirmCancel: {
id: 'learner-dash.unenrollConfirm.confirm.cancel',
description: 'Cancel action for confirm unenroll modal',
defaultMessage: 'Never mind',
defaultMessage: 'Cancel',
},
confirmUnenroll: {
id: 'learner-dash.unenrollConfirm.confirm.unenroll',
@@ -20,7 +25,7 @@ const messages = defineMessages({
reasonHeading: {
id: 'learner-dash.unenrollConfirm.confirm.reason.heading',
description: 'Heading for unenroll reason modal',
defaultMessage: 'What\'s your main reason for unenrolling?',
defaultMessage: 'Why are you unenrolling?',
},
reasonSkip: {
id: 'learner-dash.unenrollConfirm.confirm.reason.skip',
@@ -30,27 +35,22 @@ const messages = defineMessages({
reasonSubmit: {
id: 'learner-dash.unenrollConfirm.confirm.reason.submit',
description: 'Submit action for unenroll reason modal',
defaultMessage: 'Submit reason',
defaultMessage: 'Unenroll',
},
finishHeading: {
id: 'learner-dash.unenrollConfirm.confirm.finish.heading',
description: 'Heading for unenroll finish modal',
defaultMessage: 'You are unenrolled',
},
finishThanksText: {
id: 'learner-dash.unenrollConfirm.confirm.finish.thanks-text',
description: 'Thank you message on unenroll modal for providing a reason',
defaultMessage: 'Thank you for sharing your reason for unenrolling. ',
defaultMessage: 'Unenrollment Successful',
},
finishText: {
id: 'learner-dash.unenrollConfirm.confirm.finish.text',
description: 'Text for unenroll finish modal',
defaultMessage: 'This course will be removed from your dashboard.',
defaultMessage: 'You have been unenrolled from the course {courseTitle}',
},
finishReturn: {
id: 'learner-dash.unenrollConfirm.confirm.finish.return',
description: 'Return action for unenroll finish modal',
defaultMessage: 'Return to dashboard',
defaultMessage: 'Ok',
},
});

View File

@@ -13,18 +13,19 @@ export const reasonKeys = StrictDict({
quality: 'quality',
easy: 'easy',
custom: 'custom',
preferNotToSay: 'prefer-not-to-say',
});
export const order = [
reasonKeys.prereqs,
reasonKeys.difficulty,
reasonKeys.easy,
reasonKeys.goals,
reasonKeys.broken,
reasonKeys.time,
reasonKeys.browse,
reasonKeys.support,
reasonKeys.quality,
reasonKeys.easy,
];
const messages = defineMessages({
@@ -78,6 +79,11 @@ const messages = defineMessages({
description: 'Unenroll custom reason option placeholder text',
defaultMessage: 'Other',
},
[reasonKeys.preferNotToSay]: {
id: 'learner-dash.unenrollConfirm.reasons.prefer-not-to-say',
description: 'Unenroll reason option - prefer not to say',
defaultMessage: 'I prefer not to say',
},
});
export default {

View File

@@ -24,7 +24,7 @@ export const useUnenrollData = ({ closeModal, cardId }) => {
let modalState;
if (isConfirmed) {
modalState = (reason.isSubmitted || reason.isSkipped)
modalState = (reason.isSubmitted)
? modalStates.finished : modalStates.reason;
} else {
modalState = modalStates.confirm;

View File

@@ -9,10 +9,10 @@ import { StrictDict } from 'utils';
import track from 'tracking';
import * as module from './reasons';
import constants from '../constants';
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
});
@@ -21,12 +21,12 @@ export const useUnenrollReasons = ({
cardId,
}) => {
// The selected option element from the menu
const [selectedReason, setSelectedReason] = module.state.selectedReason(null);
const [selectedReason, setSelectedReason] = module.state.selectedReason(
constants.reasonKeys.preferNotToSay,
);
// 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);
@@ -47,15 +47,9 @@ export const useUnenrollReasons = ({
const handleClear = () => {
setSelectedReason(null);
setCustomOption('');
setIsSkipped(false);
setIsSubmitted(false);
};
const handleSkip = () => {
setIsSkipped(true);
unenrollFromCourse();
};
const handleSubmit = (e) => {
handleTrackReasons(e);
setIsSubmitted(true);
@@ -68,10 +62,8 @@ export const useUnenrollReasons = ({
return {
customOption: { value: customOption, onChange: handleCustomOptionChange },
handleClear,
handleSkip,
handleSubmit,
hasReason,
isSkipped,
isSubmitted,
selectOption: handleSelectOption,
submittedReason,

View File

@@ -7,6 +7,7 @@ import {
} from 'hooks';
import * as hooks from './reasons';
import constants from '../constants';
jest.mock('hooks', () => ({
apiHooks: {
@@ -39,7 +40,6 @@ const loadHook = (isEntitlement = false) => {
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);
});
@@ -55,14 +55,11 @@ describe('UnenrollConfirmModal reasons hooks', () => {
describe('behavior', () => {
describe('state fields', () => {
it('initializes selectedReason with null', () => {
state.expectInitializedWith(state.keys.selectedReason, null);
state.expectInitializedWith(state.keys.selectedReason, constants.reasonKeys.preferNotToSay);
});
it('initializes customOption with empty string', () => {
state.expectInitializedWith(state.keys.customOption, '');
});
it('initializes isSkipped with false', () => {
state.expectInitializedWith(state.keys.isSkipped, false);
});
it('initializes isSubmitted with false', () => {
state.expectInitializedWith(state.keys.isSubmitted, false);
});
@@ -140,15 +137,9 @@ describe('UnenrollConfirmModal reasons hooks', () => {
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('handleSkip sets isSkipped and isSubmitted, and unenrolls w/out a reason', () => {
out.handleSkip();
expect(state.setState.isSkipped).toHaveBeenCalledWith(true);
expect(unenrollFromCourse).toHaveBeenCalledWith();
});
describe('handleSubmit', () => {
it('tracks reason event and calls unenroll action', () => {
state.mockVal(state.keys.selectedReason, testValue);
@@ -160,11 +151,6 @@ describe('UnenrollConfirmModal reasons hooks', () => {
expect(unenrollFromCourse).toHaveBeenCalledWith();
});
});
test('isSkipped returns state value', () => {
state.mockVal(state.keys.isSkipped, testValue);
loadHook();
expect(out.isSkipped).toEqual(testValue);
});
test('isSubmitted returns state value', () => {
state.mockVal(state.keys.isSubmitted, testValue);
loadHook();

View File

@@ -38,13 +38,13 @@ export const UnenrollConfirmModal = ({
style={{ textAlign: 'start' }}
>
{(modalState === modalStates.confirm) && (
<ConfirmPane handleClose={close} handleConfirm={confirm} />
<ConfirmPane handleClose={close} handleConfirm={confirm} cardId={cardId} />
)}
{(modalState === modalStates.finished) && (
<FinishedPane handleClose={closeAndRefresh} gaveReason={!reason.isSkipped} />
<FinishedPane handleClose={closeAndRefresh} cardId={cardId} />
)}
{(modalState === modalStates.reason) && (
<ReasonPane reason={reason} />
<ReasonPane reason={reason} handleClose={close} />
)}
</div>
</ModalDialog>

View File

@@ -17,7 +17,6 @@ describe('UnenrollConfirmModal component', () => {
const hookProps = {
confirm: jest.fn().mockName('hooks.confirm'),
reason: {
isSkipped: false,
reasonProps: 'other',
},
close: jest.fn().mockName('hooks.close'),
@@ -49,22 +48,20 @@ describe('UnenrollConfirmModal component', () => {
render(<IntlProvider><UnenrollConfirmModal {...props} /></IntlProvider>);
const finishHeading = screen.getByText(formatMessage(messages.finishHeading));
expect(finishHeading).toBeInTheDocument();
const thanksMsg = screen.getByText((text) => text.includes('Thank you'));
expect(thanksMsg).toBeInTheDocument();
expect(thanksMsg.innerHTML).toContain(formatMessage(messages.finishThanksText));
const finishMsg = screen.getByText((text) => text.includes('You have been unenrolled from the course'));
expect(finishMsg).toBeInTheDocument();
});
it('modalStates.finished, reason skipped', () => {
it('modalStates.finished, cancel unenrollment', () => {
hooks.useUnenrollData.mockReturnValueOnce({
...hookProps,
modalState: hooks.modalStates.finished,
reason: { isSkipped: true },
});
render(<IntlProvider><UnenrollConfirmModal {...props} /></IntlProvider>);
const finishHeading = screen.getByText(formatMessage(messages.finishHeading));
expect(finishHeading).toBeInTheDocument();
const thanksMsg = screen.queryByText((text) => text.includes('Thank you'));
expect(thanksMsg).toBeNull();
const finishMsg = screen.getByText(formatMessage(messages.finishText));
const okButton = screen.queryByText((text) => text.includes('Ok'));
expect(okButton).toBeInTheDocument();
const finishMsg = screen.queryByText('You have been unenrolled from the course');
expect(finishMsg).toBeInTheDocument();
});
it('modalStates.reason, should display correct component with no shadow', () => {