fix: stop user from unenroll after earned the certificate (#162)

This commit is contained in:
leangseu-edx
2023-07-06 13:30:36 -04:00
committed by GitHub
parent e7d9255fe5
commit 4e47018a81
7 changed files with 294 additions and 123 deletions

View File

@@ -1,8 +1,10 @@
// Jest Snapshot v1, https://goo.gl/fbAQLP
exports[`CourseCardMenu enrolled, share enabled, email setting enable snapshot 1`] = `
exports[`CourseCardMenu default snapshot 1`] = `
<Fragment>
<Dropdown>
<Dropdown
onToggle={[MockFunction mockHandleToggleDropdown]}
>
<Dropdown.Toggle
alt="Course actions dropdown"
as="IconButton"
@@ -28,7 +30,7 @@ exports[`CourseCardMenu enrolled, share enabled, email setting enable snapshot 1
</Dropdown.Item>
<FacebookShareButton
className="pgn__dropdown-item dropdown-item"
onClick={[MockFunction facebookShareClick]}
onClick={[MockFunction handleFacebookShare]}
resetButtonStyle={false}
title="I'm taking test-course-name online with facebook-social-brand. Check it out!"
url="facebook-share-url"
@@ -37,7 +39,7 @@ exports[`CourseCardMenu enrolled, share enabled, email setting enable snapshot 1
</FacebookShareButton>
<TwitterShareButton
className="pgn__dropdown-item dropdown-item"
onClick={[MockFunction twitterShareClick]}
onClick={[MockFunction handleTwitterShare]}
resetButtonStyle={false}
title="I'm taking test-course-name online with twitter-social-brand. Check it out!"
url="twitter-share-url"
@@ -61,7 +63,9 @@ exports[`CourseCardMenu enrolled, share enabled, email setting enable snapshot 1
exports[`CourseCardMenu masquerading snapshot 1`] = `
<Fragment>
<Dropdown>
<Dropdown
onToggle={[MockFunction mockHandleToggleDropdown]}
>
<Dropdown.Toggle
alt="Course actions dropdown"
as="IconButton"
@@ -87,7 +91,7 @@ exports[`CourseCardMenu masquerading snapshot 1`] = `
</Dropdown.Item>
<FacebookShareButton
className="pgn__dropdown-item dropdown-item"
onClick={[MockFunction facebookShareClick]}
onClick={[MockFunction handleFacebookShare]}
resetButtonStyle={false}
title="I'm taking test-course-name online with facebook-social-brand. Check it out!"
url="facebook-share-url"
@@ -96,7 +100,7 @@ exports[`CourseCardMenu masquerading snapshot 1`] = `
</FacebookShareButton>
<TwitterShareButton
className="pgn__dropdown-item dropdown-item"
onClick={[MockFunction twitterShareClick]}
onClick={[MockFunction handleTwitterShare]}
resetButtonStyle={false}
title="I'm taking test-course-name online with twitter-social-brand. Check it out!"
url="twitter-share-url"
@@ -118,23 +122,4 @@ exports[`CourseCardMenu masquerading snapshot 1`] = `
</Fragment>
`;
exports[`CourseCardMenu not enrolled, share disabled, email setting disabled snapshot 1`] = `
<Fragment>
<Dropdown>
<Dropdown.Toggle
alt="Course actions dropdown"
as="IconButton"
iconAs="Icon"
id="course-actions-dropdown-test-card-id"
src={[MockFunction icons.MoreVert]}
variant="primary"
/>
<Dropdown.Menu />
</Dropdown>
<UnenrollConfirmModal
cardId="test-card-id"
closeModal={[MockFunction unenrollHide]}
show={false}
/>
</Fragment>
`;
exports[`CourseCardMenu renders null if showDropdown is false 1`] = `""`;

View File

@@ -36,3 +36,36 @@ export const useHandleToggleDropdown = (cardId) => {
if (isOpen) { trackCourseEvent(); }
};
};
export const useCourseCardMenu = (cardId) => {
const { courseName } = reduxHooks.useCardCourseData(cardId);
const { isEnrolled, isEmailEnabled } = reduxHooks.useCardEnrollmentData(cardId);
const { twitter, facebook } = reduxHooks.useCardSocialSettingsData(cardId);
const { isMasquerading } = reduxHooks.useMasqueradeData();
const { isEarned } = reduxHooks.useCardCertificateData(cardId);
const handleTwitterShare = reduxHooks.useTrackCourseEvent(
track.socialShare,
cardId,
'twitter',
);
const handleFacebookShare = reduxHooks.useTrackCourseEvent(
track.socialShare,
cardId,
'facebook',
);
const showUnenrollItem = isEnrolled && !isEarned;
const showDropdown = showUnenrollItem || isEmailEnabled || facebook.isEnabled || twitter.isEnabled;
return {
courseName,
isMasquerading,
isEmailEnabled,
showUnenrollItem,
showDropdown,
facebook,
twitter,
handleTwitterShare,
handleFacebookShare,
};
};

View File

@@ -7,6 +7,11 @@ import * as hooks from './hooks';
jest.mock('hooks', () => ({
reduxHooks: {
useTrackCourseEvent: jest.fn(),
useCardCourseData: jest.fn(),
useCardEnrollmentData: jest.fn(),
useCardSocialSettingsData: jest.fn(),
useMasqueradeData: jest.fn(),
useCardCertificateData: jest.fn(),
},
}));
@@ -14,6 +19,18 @@ const trackCourseEvent = jest.fn();
reduxHooks.useTrackCourseEvent.mockReturnValue(trackCourseEvent);
const state = new MockUseState(hooks);
const defaultSocialShare = {
facebook: {
isEnabled: true,
shareUrl: 'facebook-share-url',
socialBrand: 'facebook-social-brand',
},
twitter: {
isEnabled: true,
shareUrl: 'twitter-share-url',
socialBrand: 'twitter-social-brand',
},
};
const cardId = 'test-card-id';
let out;
@@ -88,4 +105,116 @@ describe('CourseCardMenu hooks', () => {
});
});
});
describe('useCourseCardMenu', () => {
const mockUseCourseCardMenu = ({
courseName,
isEnrolled,
isEmailEnabled,
isMasquerading,
facebook,
twitter,
isEarned,
} = {}) => {
reduxHooks.useCardCourseData.mockReturnValueOnce({ courseName });
reduxHooks.useCardSocialSettingsData.mockReturnValueOnce({
facebook: {
...defaultSocialShare.facebook,
...facebook,
},
twitter: {
...defaultSocialShare.twitter,
...twitter,
},
});
reduxHooks.useCardEnrollmentData.mockReturnValueOnce({
isEnrolled,
isEmailEnabled,
});
reduxHooks.useMasqueradeData.mockReturnValueOnce({ isMasquerading });
reduxHooks.useCardCertificateData.mockReturnValueOnce({ isEarned });
};
afterEach(() => jest.resetAllMocks());
describe('showUnenrollItem', () => {
test('return true', () => {
mockUseCourseCardMenu({ isEnrolled: true, isEarned: false });
out = hooks.useCourseCardMenu(cardId);
expect(out.showUnenrollItem).toBeTruthy();
});
test('return false', () => {
mockUseCourseCardMenu({ isEnrolled: true, isEarned: true });
out = hooks.useCourseCardMenu(cardId);
expect(out.showUnenrollItem).toBeFalsy();
mockUseCourseCardMenu({ isEnrolled: false, isEarned: false });
out = hooks.useCourseCardMenu(cardId);
expect(out.showUnenrollItem).toBeFalsy();
mockUseCourseCardMenu({ isEnrolled: false, isEarned: true });
out = hooks.useCourseCardMenu(cardId);
expect(out.showUnenrollItem).toBeFalsy();
});
});
describe('showDropdown', () => {
test('return false iif everything is false', () => {
mockUseCourseCardMenu({
isEnrolled: false,
isEarned: false,
isEmailEnabled: false,
facebook: { isEnabled: false },
twitter: { isEnabled: false },
});
out = hooks.useCourseCardMenu(cardId);
expect(out.showDropdown).toBeFalsy();
});
test('return true iif at least one is true', () => {
mockUseCourseCardMenu({
isEnrolled: true,
isEarned: false,
isEmailEnabled: false,
facebook: { isEnabled: false },
twitter: { isEnabled: false },
});
out = hooks.useCourseCardMenu(cardId);
expect(out.showDropdown).toBeTruthy();
});
});
test('return correct values', () => {
const expected = {
courseName: 'abitrary-course-name',
isMasquerading: 'abitrary-masquerading-value',
isEmailEnabled: 'abitrary-email-enabled-value',
facebook: { isEnabled: 'abitrary-facebook-value' },
twitter: { isEnabled: 'abitrary-twitter-value' },
};
mockUseCourseCardMenu(expected);
out = hooks.useCourseCardMenu(cardId);
expect(out.courseName).toEqual(expected.courseName);
expect(out.isMasquerading).toEqual(expected.isMasquerading);
expect(out.isEmailEnabled).toEqual(expected.isEmailEnabled);
expect(out.facebook.isEnabled).toEqual(expected.facebook.isEnabled);
expect(out.twitter.isEnabled).toEqual(expected.twitter.isEnabled);
});
test('handleSocialShareClick', () => {
mockUseCourseCardMenu();
out = hooks.useCourseCardMenu(cardId);
expect(reduxHooks.useTrackCourseEvent).toHaveBeenCalledTimes(2);
expect(reduxHooks.useTrackCourseEvent).toHaveBeenCalledWith(
track.socialShare,
cardId,
'facebook',
);
expect(reduxHooks.useTrackCourseEvent).toHaveBeenCalledWith(
track.socialShare,
cardId,
'twitter',
);
});
});
});

View File

@@ -6,14 +6,13 @@ import { useIntl } from '@edx/frontend-platform/i18n';
import { Dropdown, Icon, IconButton } from '@edx/paragon';
import { MoreVert } from '@edx/paragon/icons';
import track from 'tracking';
import { reduxHooks } from 'hooks';
import EmailSettingsModal from 'containers/EmailSettingsModal';
import UnenrollConfirmModal from 'containers/UnenrollConfirmModal';
import {
useEmailSettings,
useUnenrollData,
useHandleToggleDropdown,
useCourseCardMenu,
} from './hooks';
import messages from './messages';
@@ -21,25 +20,26 @@ import messages from './messages';
export const CourseCardMenu = ({ cardId }) => {
const { formatMessage } = useIntl();
const { courseName } = reduxHooks.useCardCourseData(cardId);
const { isEnrolled, isEmailEnabled } = reduxHooks.useCardEnrollmentData(cardId);
const { twitter, facebook } = reduxHooks.useCardSocialSettingsData(cardId);
const { isMasquerading } = reduxHooks.useMasqueradeData();
const handleTwitterShare = reduxHooks.useTrackCourseEvent(
track.socialShare,
cardId,
'twitter',
);
const handleFacebookShare = reduxHooks.useTrackCourseEvent(
track.socialShare,
cardId,
'facebook',
);
const emailSettingsModal = useEmailSettings();
const unenrollModal = useUnenrollData();
const handleToggleDropdown = useHandleToggleDropdown(cardId);
const {
courseName,
isMasquerading,
isEmailEnabled,
showUnenrollItem,
showDropdown,
facebook,
twitter,
handleTwitterShare,
handleFacebookShare,
} = useCourseCardMenu(cardId);
if (!showDropdown) {
return null;
}
return (
<>
<Dropdown onToggle={handleToggleDropdown}>
@@ -52,7 +52,7 @@ export const CourseCardMenu = ({ cardId }) => {
alt={formatMessage(messages.dropdownAlt)}
/>
<Dropdown.Menu>
{isEnrolled && (
{showUnenrollItem && (
<Dropdown.Item
disabled={isMasquerading}
onClick={unenrollModal.show}

View File

@@ -1,26 +1,19 @@
import { shallow } from 'enzyme';
import { reduxHooks } from 'hooks';
import { useEmailSettings, useUnenrollData } from './hooks';
import {
useEmailSettings, useUnenrollData, useCourseCardMenu,
} from './hooks';
import CourseCardMenu from '.';
jest.mock('react-share', () => ({
FacebookShareButton: () => 'FacebookShareButton',
TwitterShareButton: () => 'TwitterShareButton',
}));
jest.mock('hooks', () => ({
reduxHooks: {
useCardCourseData: jest.fn(),
useCardEnrollmentData: jest.fn(),
useCardSocialSettingsData: jest.fn(),
useMasqueradeData: jest.fn(),
useTrackCourseEvent: (_, __, site) => jest.fn().mockName(`${site}ShareClick`),
},
}));
jest.mock('./hooks', () => ({
useEmailSettings: jest.fn(),
useUnenrollData: jest.fn(),
useHandleToggleDropdown: jest.fn(),
useCourseCardMenu: jest.fn(),
useHandleToggleDropdown: () => jest.fn().mockName('mockHandleToggleDropdown'),
}));
const props = {
@@ -48,77 +41,105 @@ const defaultSocialShare = {
socialBrand: 'twitter-social-brand',
},
};
const courseName = 'test-course-name';
const defaultUseCourseCardMenu = {
courseName: 'test-course-name',
isMasquerading: false,
isEmailEnabled: true,
showUnenrollItem: true,
showDropdown: true,
handleTwitterShare: jest.fn().mockName('handleTwitterShare'),
handleFacebookShare: jest.fn().mockName('handleFacebookShare'),
};
let wrapper;
let el;
describe('CourseCardMenu', () => {
beforeEach(() => {
useEmailSettings.mockReturnValue(defaultEmailSettingsModal);
useUnenrollData.mockReturnValue(defaultUnenrollModal);
reduxHooks.useCardSocialSettingsData.mockReturnValue(defaultSocialShare);
reduxHooks.useCardCourseData.mockReturnValue({ courseName });
reduxHooks.useCardEnrollmentData.mockReturnValue({ isEnrolled: true, isEmailEnabled: true });
reduxHooks.useMasqueradeData.mockReturnValue({ isMasquerading: false });
useEmailSettings.mockReturnValue(defaultEmailSettingsModal);
useUnenrollData.mockReturnValue(defaultUnenrollModal);
const mockUseCourseCardMenu = ({
isMasquerading,
isEmailEnabled,
showUnenrollItem,
showDropdown,
facebook,
twitter,
}) => {
useCourseCardMenu.mockReturnValueOnce({
...defaultUseCourseCardMenu,
isMasquerading,
isEmailEnabled,
showUnenrollItem,
showDropdown,
facebook,
twitter,
});
return shallow(<CourseCardMenu {...props} />);
};
test('default snapshot', () => {
wrapper = mockUseCourseCardMenu({
isMasquerading: false,
isEmailEnabled: true,
showUnenrollItem: true,
showDropdown: true,
...defaultSocialShare,
});
expect(wrapper).toMatchSnapshot();
});
describe('enrolled, share enabled, email setting enable', () => {
beforeEach(() => {
wrapper = shallow(<CourseCardMenu {...props} />);
});
test('snapshot', () => {
expect(wrapper).toMatchSnapshot();
});
it('renders share buttons', () => {
el = wrapper.find('FacebookShareButton');
expect(el.length).toEqual(1);
expect(el.prop('url')).toEqual('facebook-share-url');
el = wrapper.find('TwitterShareButton');
expect(el.length).toEqual(1);
expect(el.prop('url')).toEqual('twitter-share-url');
});
it('renders enabled unenroll modal toggle', () => {
el = wrapper.find({ 'data-testid': 'unenrollModalToggle' });
expect(el.props().disabled).toEqual(false);
});
it('renders enabled email settings modal toggle', () => {
el = wrapper.find({ 'data-testid': 'emailSettingsModalToggle' });
expect(el.props().disabled).toEqual(false);
});
it('renders enabled email settings modal toggle', () => {
el = wrapper.find({ 'data-testid': 'emailSettingsModalToggle' });
expect(el.props().disabled).toEqual(false);
test('renders null if showDropdown is false', () => {
wrapper = mockUseCourseCardMenu({
isMasquerading: true,
isEmailEnabled: true,
showUnenrollItem: true,
showDropdown: false,
...defaultSocialShare,
});
expect(wrapper).toMatchSnapshot();
expect(wrapper.isEmptyRender()).toEqual(true);
});
describe('not enrolled, share disabled, email setting disabled', () => {
beforeEach(() => {
reduxHooks.useCardSocialSettingsData.mockReturnValueOnce({
...defaultSocialShare,
twitter: { ...defaultSocialShare.twitter, isEnabled: false },
facebook: { ...defaultSocialShare.facebook, isEnabled: false },
describe('disable state options', () => {
beforeAll(() => {
wrapper = mockUseCourseCardMenu({
isMasquerading: false,
isEmailEnabled: false,
showUnenrollItem: false,
showDropdown: true, // set to true for testing
facebook: {
isEnabled: false,
},
twitter: {
isEnabled: false,
},
});
reduxHooks.useCardEnrollmentData.mockReturnValueOnce({ isEnrolled: false, isEmailEnabled: false });
wrapper = shallow(<CourseCardMenu {...props} />);
});
test('snapshot', () => {
expect(wrapper).toMatchSnapshot();
// to make sure it try to render the dropdown
it('render dropdown base on showDropdown', () => {
expect(wrapper.isEmptyRender()).toEqual(false);
expect(wrapper.find('Dropdown').length).toEqual(1);
});
it('does not renders share buttons', () => {
it('not renders email settings modal toggle', () => {
el = wrapper.find({ 'data-testid': 'emailSettingsModalToggle' });
expect(el.length).toEqual(0);
});
it('not renders unenroll modal toggle', () => {
el = wrapper.find({ 'data-testid': 'unenrollModalToggle' });
expect(el.length).toEqual(0);
});
it('not renders share buttons', () => {
expect(wrapper.find('FacebookShareButton').length).toEqual(0);
expect(wrapper.find('TwitterShareButton').length).toEqual(0);
});
it('does not render unenroll modal toggle', () => {
el = wrapper.find({ 'data-testid': 'unenrollModalToggle' });
expect(el.length).toEqual(0);
});
it('does not render email settings modal toggle', () => {
el = wrapper.find({ 'data-testid': 'emailSettingsModalToggle' });
expect(el.length).toEqual(0);
});
});
describe('masquerading', () => {
beforeEach(() => {
reduxHooks.useMasqueradeData.mockReturnValue({ isMasquerading: true });
wrapper = shallow(<CourseCardMenu {...props} />);
wrapper = mockUseCourseCardMenu({
isMasquerading: true,
isEmailEnabled: true,
showUnenrollItem: true,
showDropdown: true,
...defaultSocialShare,
});
});
test('snapshot', () => {
expect(wrapper).toMatchSnapshot();

View File

@@ -15,17 +15,14 @@ export const loadDateVal = (date) => (date ? new Date(date) : null);
export const courseCard = StrictDict({
certificate: mkCardSelector(
cardSimpleSelectors.certificate,
(certificate) => {
const availableDate = new Date(certificate.availableDate);
const isAvailable = availableDate <= new Date();
return {
availableDate,
certPreviewUrl: baseAppUrl(certificate.certPreviewUrl),
isDownloadable: certificate.isDownloadable,
isEarnedButUnavailable: certificate.isEarned && !isAvailable,
isRestricted: certificate.isRestricted,
};
},
(certificate) => (certificate === null ? {} : ({
availableDate: new Date(certificate.availableDate),
certPreviewUrl: baseAppUrl(certificate.certPreviewUrl),
isDownloadable: certificate.isDownloadable,
isEarnedButUnavailable: certificate.isEarned && new Date(certificate.availableDate) > new Date(),
isRestricted: certificate.isRestricted,
isEarned: certificate.isEarned,
})),
),
course: mkCardSelector(
cardSimpleSelectors.course,

View File

@@ -79,6 +79,9 @@ describe('courseCard selectors module', () => {
it('returns a card selector based on certificate cardSimpleSelector', () => {
expect(simpleSelector).toEqual(cardSimpleSelectors.certificate);
});
it('returns {} object if null certificate received', () => {
expect(selector(null)).toEqual({});
});
it('passes availableDate, converted to a date', () => {
expect(selected.availableDate).toMatchObject(new Date(testData.availableDate));
});
@@ -162,6 +165,9 @@ describe('courseCard selectors module', () => {
it('returns a card selector based on courseRun cardSimpleSelector', () => {
expect(simpleSelector).toEqual(cardSimpleSelectors.courseRun);
});
it('returns {} object if null courseRun received', () => {
expect(selector(null)).toEqual({});
});
it('passes [endDate, startDate], converted to dates', () => {
expect(selected.endDate).toEqual(new Date(testData.endDate));
expect(selected.startDate).toEqual(new Date(testData.startDate));