diff --git a/src/containers/CourseCard/CourseCard.scss b/src/containers/CourseCard/CourseCard.scss index 48d232d..8074c9a 100644 --- a/src/containers/CourseCard/CourseCard.scss +++ b/src/containers/CourseCard/CourseCard.scss @@ -2,8 +2,6 @@ .course-card { .card { - overflow: hidden; - .pgn__card-image-cap { border-bottom-left-radius: 0 !important; } diff --git a/src/containers/CourseCard/__snapshots__/index.test.jsx.snap b/src/containers/CourseCard/__snapshots__/index.test.jsx.snap index 6a900cb..d8bb5b1 100644 --- a/src/containers/CourseCard/__snapshots__/index.test.jsx.snap +++ b/src/containers/CourseCard/__snapshots__/index.test.jsx.snap @@ -11,10 +11,12 @@ exports[`CourseCard component snapshot: collapsed 1`] = `
- +
+ +
- {isCollapsed - ? ( - - ) : ( -
- -
- )} +
+ +
diff --git a/src/containers/CourseFilterControls/CourseFilterControls.jsx b/src/containers/CourseFilterControls/CourseFilterControls.jsx index fe1185a..59d00cd 100644 --- a/src/containers/CourseFilterControls/CourseFilterControls.jsx +++ b/src/containers/CourseFilterControls/CourseFilterControls.jsx @@ -3,11 +3,16 @@ import PropTypes from 'prop-types'; import { useIntl } from '@edx/frontend-platform/i18n'; import { - Form, Button, + Form, + Icon, ModalPopup, + Sheet, + breakpoints, + useWindowSize, + ModalCloseButton, } from '@edx/paragon'; -import { Tune } from '@edx/paragon/icons'; +import { Close, Tune } from '@edx/paragon/icons'; import FilterForm from './components/FilterForm'; import SortForm from './components/SortForm'; @@ -35,6 +40,9 @@ export const CourseFilterControls = ({ setFilters, setSortBy, }); + const { width } = useWindowSize(); + const isMobile = width < breakpoints.small.minWidth; + return (
- -
-
-
- -
-
-
- -
-
-
-
+
+ {isMobile + ? ( + +
+ Refine +
+
+
+ +
+
+ +
+
+ + + +
+
+ ) : ( + +
+
+ +
+
+
+ +
+
+
+ )} +
); }; diff --git a/src/containers/CourseFilterControls/index.scss b/src/containers/CourseFilterControls/index.scss index 8da93f9..59264aa 100644 --- a/src/containers/CourseFilterControls/index.scss +++ b/src/containers/CourseFilterControls/index.scss @@ -1,3 +1,12 @@ +.pgn__sheet-component { + max-width: 75% !important; + width: 75% !important; + + .filter-form-heading { + font-weight: bold; + font-size: 18px; + } +} #course-filter-controls-card { width: 512px; height: 288px; diff --git a/src/containers/SelectSessionModal/__snapshots__/index.test.jsx.snap b/src/containers/SelectSessionModal/__snapshots__/index.test.jsx.snap index 08c6ed4..9db0d84 100644 --- a/src/containers/SelectSessionModal/__snapshots__/index.test.jsx.snap +++ b/src/containers/SelectSessionModal/__snapshots__/index.test.jsx.snap @@ -4,6 +4,7 @@ exports[`SelectSessionModal snapshot empty modal with leave option 1`] = ` { isOpen={showModal} onClose={nullMethod} hasCloseButton={false} + isFullscreenOnMobile size="md" className="p-4 px-4.5" title={header} diff --git a/src/containers/UnenrollConfirmModal/__snapshots__/index.test.jsx.snap b/src/containers/UnenrollConfirmModal/__snapshots__/index.test.jsx.snap index 565896c..cff4099 100644 --- a/src/containers/UnenrollConfirmModal/__snapshots__/index.test.jsx.snap +++ b/src/containers/UnenrollConfirmModal/__snapshots__/index.test.jsx.snap @@ -3,6 +3,7 @@ exports[`UnenrollConfirmModal component snapshot: modalStates.confirm 1`] = ` `; -exports[`UnenrollConfirmModal component snapshot: modalStates.reason 1`] = ` +exports[`UnenrollConfirmModal component snapshot: modalStates.reason, should be fullscreen with no shadow 1`] = `
-
+
{(modalState === modalStates.confirm) && ( )} diff --git a/src/containers/UnenrollConfirmModal/index.test.jsx b/src/containers/UnenrollConfirmModal/index.test.jsx index 96acd12..7008a4c 100644 --- a/src/containers/UnenrollConfirmModal/index.test.jsx +++ b/src/containers/UnenrollConfirmModal/index.test.jsx @@ -51,7 +51,7 @@ describe('UnenrollConfirmModal component', () => { }); expect(shallow()).toMatchSnapshot(); }); - test('snapshot: modalStates.reason', () => { + test('snapshot: modalStates.reason, should be fullscreen with no shadow', () => { hooks.useUnenrollData.mockReturnValueOnce({ ...hookProps, modalState: hooks.modalStates.reason }); expect(shallow()).toMatchSnapshot(); }); diff --git a/src/data/constants/requests.js b/src/data/constants/requests.js index fc9d6d1..d02ba56 100644 --- a/src/data/constants/requests.js +++ b/src/data/constants/requests.js @@ -10,8 +10,11 @@ export const RequestStates = StrictDict({ export const RequestKeys = StrictDict({ initialize: 'initialize', refreshList: 'refreshList', - enrollEntitlementSession: 'enrollEntitlementSession', - leaveEntitlementSession: 'leaveEntitlementSession', + newEntitlementEnrollment: 'newEntitlementEnrollment', + leaveEntitlementEnrollment: 'leaveEntitlementEnrollment', + switchEntitlementSession: 'switchEntitlementSession', + unenrollFromCourse: 'unenrollFromCourse', + updateEmailSettings: 'updateEmailSettings', }); export const ErrorCodes = StrictDict({ diff --git a/src/data/redux/thunkActions/app.js b/src/data/redux/thunkActions/app.js index 8452a50..b916025 100644 --- a/src/data/redux/thunkActions/app.js +++ b/src/data/redux/thunkActions/app.js @@ -1,4 +1,6 @@ import { StrictDict } from 'utils'; +import { handleEvent } from 'data/services/segment/utils'; +import { eventNames } from 'data/services/segment/constants'; import { actions, selectors } from 'data/redux'; import { post } from 'data/services/lms/utils'; @@ -36,25 +38,54 @@ export const sendConfirmEmail = () => (dispatch, getState) => post( selectors.app.emailConfirmation(getState()).sendEmailUrl, ); -export const updateEntitlementSession = (cardId, selection) => (dispatch, getState) => { - const entitlement = selectors.app.courseCard.entitlement(getState(), cardId); - const { uuid } = entitlement; - console.log({ - cardId, - selection, - entitlement, - uuid, +export const newEntitlementEnrollment = (cardId, selection) => (dispatch, getState) => { + const { uuid } = selectors.app.courseCard.entitlement(getState(), cardId); + handleEvent(eventNames.sessionChange({ action: 'new' }), { + fromCourseRun: null, + toCourseRun: selection, }); + return dispatch(requests.newEntitlementEnrollment({ uuid, courseId: selection })); }; -export const unenroll = (courseId) => (dispatch, getState) => post( - selectors.app.courseCard.courseRun(getState(), courseId), -).then(() => dispatch(module.refreshList())); +export const switchEntitlementEnrollment = (cardId, selection) => (dispatch, getState) => { + const { courseId } = selectors.app.courseCard.courseRun(getState(), cardId); + const { uuid } = selectors.app.courseCard.entitlement(getState(), cardId); + handleEvent(eventNames.sessionChange({ action: 'switch' }), { + fromCourseRun: courseId, + toCourseRun: selection, + }); + return dispatch(requests.switchEntitlementEnrollment({ uuid, courseId: selection })); +}; + +export const leaveEntitlementSession = (cardId) => (dispatch, getState) => { + const { courseId } = selectors.app.courseCard.courseRun(getState(), cardId); + const { uuid } = selectors.app.courseCard.entitlement(getState(), cardId); + handleEvent(eventNames.entitlementUnenroll({ action: 'leave' }), { + fromCourseRun: courseId, + toCourseRun: null, + }); + return dispatch(requests.leaveEntitlementSession({ uuid })); +}; + +export const unenrollFromCourse = (courseId, reason) => (dispatch) => { + handleEvent(eventNames.unenrollReason, { + category: 'user-engagement', + displayName: 'v1', + label: reason, + course_id: courseId, + }); + dispatch(requests.unenrollFromCourse({ + courseId, + onSuccess: () => dispatch(module.refreshList()), + })); +}; export default StrictDict({ initialize, refreshList, sendConfirmEmail, - updateEntitlementSession, - unenroll, + newEntitlementEnrollment, + switchEntitlementEnrollment, + leaveEntitlementSession, + unenrollFromCourse, }); diff --git a/src/data/redux/thunkActions/requests.js b/src/data/redux/thunkActions/requests.js index 4a10f09..92ba438 100644 --- a/src/data/redux/thunkActions/requests.js +++ b/src/data/redux/thunkActions/requests.js @@ -4,7 +4,7 @@ import { RequestKeys } from 'data/constants/requests'; import { actions } from 'data/redux'; import api from 'data/services/lms/api'; -// import * as module from './requests'; +import * as module from './requests'; /** * Wrapper around a network request promise, that sends actions to the redux store to @@ -33,44 +33,62 @@ export const networkRequest = ({ }); }; -export const initializeList = ({ onSuccess, onFailure }) => (dispatch) => { - dispatch(networkRequest({ - requestKey: RequestKeys.initialize, - onFailure, - onSuccess, - promise: api.initializeList(), - })); -}; +export const networkAction = (requestKey, promise, options) => (dispatch) => ( + dispatch(module.networkRequest({ + requestKey, + promise, + ...options, + }))); -export const updateEntitlementEnrollment = ({ +export const initializeList = (options) => module.networkAction( + RequestKeys.initialize, + api.initializeList(), + options, +); + +export const newEntitlementEnrollment = ({ uuid, courseId, - onSuccess, - onFailure, -}) => (dispatch) => { - dispatch(networkRequest({ - requestKey: RequestKeys.enrollEntitlementSession, - onFailure, - onSuccess, - promise: api.updateEntitlementEnrollment({ uuid, courseId }), - })); -}; + ...options +}) => module.networkAction( + RequestKeys.newEntitlementEnrollment, + api.updateEntitlementEnrollment({ uuid, courseId }), + options, +); -export const leaveEntitlementSession = ({ +export const switchEntitlementEnrollment = ({ uuid, - onSuccess, - onFailure, -}) => (dispatch) => { - dispatch(networkRequest({ - requestKey: RequestKeys.leaveEntitlementSession, - onFailure, - onSuccess, - promise: api.leaveEntitlementEnrollment({ uuid }), - })); -}; + courseId, + ...options +}) => module.networkAction( + RequestKeys.switchEntitlementSession, + api.updateEntitlementEnrollment({ uuid, courseId }), + options, +); + +export const leaveEntitlementSession = ({ uuid, ...options }) => module.networkAction( + RequestKeys.leaveEntitlementSession, + api.leaveEntitlementEnrollment({ uuid }), + options, +); + +export const unenrollFromCourse = ({ courseId, ...options }) => module.networkAction( + RequestKeys.unenrollFromCourse, + api.unenrollFromCourse({ courseId }), + options, +); + +export const updateEmailSettings = ({ courseId, enable, ...options }) => module.networkAction( + RequestKeys.updateEmailSettings, + api.updateEmailSettings({ courseId, enable }), + options, +); export default StrictDict({ initializeList, - updateEntitlementEnrollment, leaveEntitlementSession, + newEntitlementEnrollment, + switchEntitlementEnrollment, + unenrollFromCourse, + updateEmailSettings, }); diff --git a/src/data/services/lms/api.js b/src/data/services/lms/api.js index f4fc78a..a21b386 100644 --- a/src/data/services/lms/api.js +++ b/src/data/services/lms/api.js @@ -21,8 +21,20 @@ const deleteEntitlementEnrollment = ({ uuid }) => client().delete(stringifyUrl( { course_run_id: null }, )); +const updateEmailSettings = ({ courseId, enable }) => post(stringifyUrl( + urls.updateEmailSettings, + { course_id: courseId, ...(enable && { receive_emails: 'on' }) }, +)); + +const unenrollFromCourse = ({ courseId }) => post(stringifyUrl( + urls.unenrollFromCourse, + { course_id: courseId, enrollment_action: 'unenroll' }, +)); + export default { initializeList, + unenrollFromCourse, + updateEmailSettings, updateEntitlementEnrollment, deleteEntitlementEnrollment, }; diff --git a/src/data/services/lms/urls.js b/src/data/services/lms/urls.js index fb6b587..1463f78 100644 --- a/src/data/services/lms/urls.js +++ b/src/data/services/lms/urls.js @@ -6,10 +6,14 @@ const baseUrl = `${configuration.LMS_BASE_URL}`; const api = `${baseUrl}/api/`; const init = `${api}learner_home/mock/init`; +const courseUnenroll = `${api}/courses/unenroll`; // TODO: Fix +const updateEmailSettings = `${api}/change_email_settings`; const entitlementEnrollment = (uuid) => `${api}/entitlements/v1/entitlements/${uuid}/enrollments`; export default StrictDict({ api, init, + courseUnenroll, + updateEmailSettings, entitlementEnrollment, }); diff --git a/src/data/services/segment/constants.js b/src/data/services/segment/constants.js new file mode 100644 index 0000000..a6a39c3 --- /dev/null +++ b/src/data/services/segment/constants.js @@ -0,0 +1,19 @@ +import { StrictDict } from 'utils'; + +export const events = StrictDict({ + courseEnroll: 'courseEnroll', + entitlementUnenroll: 'entitlementUnenroll', + sessionChange: 'sessionChange', + unenrollReason: 'unenrollReason', +}); + +export const eventNames = StrictDict({ + [events.courseEnroll]: 'edx.bi.user.program-details.enrollment', + [events.entitlementUnenroll]: 'entitlement_unenrollment_reason.selected', + [events.sessionChange]: ({ action }) => `course-dashboard.${action}-session`, // 'switch', 'new', 'leave' + [events.unenrollReason]: 'unenrollment_reason.selected', +}); + +export const trackingCategory = 'learner-home'; + +export const pageViewEvent = { category: trackingCategory }; diff --git a/src/data/services/segment/utils.js b/src/data/services/segment/utils.js new file mode 100755 index 0000000..6237c70 --- /dev/null +++ b/src/data/services/segment/utils.js @@ -0,0 +1,18 @@ +/* eslint-disable import/prefer-default-export */ +import { trackEvent } from '@redux-beacon/segment'; +import { trackingCategory as category } from './constants'; + +export const handleEvent = (name, options = {}) => trackEvent( + (event = {}) => { + const { payload } = event; + const { propsFn, extrasFn } = options; + return { + name, + ...(extrasFn && extrasFn(payload)), + properties: { + category, + ...(propsFn && propsFn(payload)), + }, + }; + }, +); diff --git a/src/data/services/segment/utils.test.js b/src/data/services/segment/utils.test.js new file mode 100644 index 0000000..58ebfe2 --- /dev/null +++ b/src/data/services/segment/utils.test.js @@ -0,0 +1,49 @@ +import * as constants from './constants'; +import { handleEvent } from './utils'; + +jest.mock('@redux-beacon/segment', () => ({ + trackEvent: (handleFn) => ({ trackEvent: handleFn }), +})); + +const category = 'AFakeCategory'; +describe('segment service utils', () => { + beforeAll(() => { + global.window = Object.create(window); + constants.trackingCategory = category; + }); + + describe('handleEvent', () => { + const name = 'aName'; + const payload = { field1: 'some data', field2: 'other data' }; + describe('when called with just a name', () => { + it('returns a TrackEvent call with the name and tracking category', () => { + const handler = handleEvent(name).trackEvent; + expect(handler(payload)).toEqual({ + name, + properties: { category }, + }); + }); + }); + describe('when a propsFn is provided', () => { + it('adds the output of propsFn(event.payload) to properties', () => { + const propsFn = ({ field1 }) => ({ field1 }); + const handler = handleEvent(name, { propsFn }).trackEvent; + expect(handler({ payload })).toEqual({ + name, + properties: { category, field1: payload.field1 }, + }); + }); + }); + describe('when an extrasFn object is provided', () => { + it('adds the output of extrasFn(event.payload) to top-level object', () => { + const extrasFn = ({ field2 }) => ({ field2 }); + const handler = handleEvent(name, { extrasFn }).trackEvent; + expect(handler({ payload })).toEqual({ + name, + field2: payload.field2, + properties: { category }, + }); + }); + }); + }); +});