Bw/responsive api actions (#20)

This commit is contained in:
Ben Warzeski
2022-09-21 15:06:46 -04:00
committed by GitHub
18 changed files with 294 additions and 88 deletions

View File

@@ -2,8 +2,6 @@
.course-card {
.card {
overflow: hidden;
.pgn__card-image-cap {
border-bottom-left-radius: 0 !important;
}

View File

@@ -11,10 +11,12 @@ exports[`CourseCard component snapshot: collapsed 1`] = `
<div
className="d-flex flex-column w-100"
>
<CourseCardContent
cardId="test-card-id"
orientation="vertical"
/>
<div>
<CourseCardContent
cardId="test-card-id"
orientation="vertical"
/>
</div>
<div
className="course-card-banners"
data-testid="CourseCardBanners"

View File

@@ -19,14 +19,9 @@ export const CourseCard = ({
<div className="mb-4.5 course-card" data-testid="CourseCard">
<Card orientation={orientation}>
<div className="d-flex flex-column w-100">
{isCollapsed
? (
<CourseCardContent cardId={cardId} orientation={orientation} />
) : (
<div className="d-flex">
<CourseCardContent cardId={cardId} orientation={orientation} />
</div>
)}
<div {...(!isCollapsed && { className: 'd-flex' })}>
<CourseCardContent cardId={cardId} orientation={orientation} />
</div>
<div className="course-card-banners" data-testid="CourseCardBanners">
<CourseCardBanners cardId={cardId} />
</div>

View File

@@ -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 (
<div id="course-filter-controls">
<Button
@@ -45,27 +53,53 @@ export const CourseFilterControls = ({
>
{formatMessage(messages.refine)}
</Button>
<ModalPopup
positionRef={target}
isOpen={isOpen}
onClose={close}
placement="bottom-end"
>
<Form>
<div
id="course-filter-controls-card"
className="bg-white p-3 rounded shadow d-flex flex-row"
>
<div className="filter-form-col">
<FilterForm {...{ filters, handleFilterChange }} />
</div>
<hr className="h-100 bg-primary-200 m-1" />
<div className="filter-form-col text-left m-1">
<SortForm {...{ sortBy, handleSortChange }} />
</div>
</div>
</Form>
</ModalPopup>
<Form>
{isMobile
? (
<Sheet
className="w-75"
position="left"
show={isOpen}
onClose={close}
>
<div className="p-1 mr-3">
<b>Refine</b>
</div>
<hr />
<div className="filter-form-row">
<FilterForm {...{ filters, handleFilterChange }} />
</div>
<div className="filter-form-row text-left m-1">
<SortForm {...{ sortBy, handleSortChange }} />
</div>
<div className="pgn__modal-close-container">
<ModalCloseButton variant="tertiary" onClick={close}>
<Icon src={Close} />
</ModalCloseButton>
</div>
</Sheet>
) : (
<ModalPopup
positionRef={target}
isOpen={isOpen}
onClose={close}
placement="bottom-end"
>
<div
id="course-filter-controls-card"
className="bg-white p-3 rounded shadow d-flex flex-row"
>
<div className="filter-form-col">
<FilterForm {...{ filters, handleFilterChange }} />
</div>
<hr className="h-100 bg-primary-200 m-1" />
<div className="filter-form-col text-left m-1">
<SortForm {...{ sortBy, handleSortChange }} />
</div>
</div>
</ModalPopup>
)}
</Form>
</div>
);
};

View File

@@ -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;

View File

@@ -4,6 +4,7 @@ exports[`SelectSessionModal snapshot empty modal with leave option 1`] = `
<ModalDialog
className="p-4 px-4.5"
hasCloseButton={false}
isFullscreenOnMobile={true}
isOpen={true}
onClose={[MockFunction hooks.nullMethod]}
size="md"
@@ -47,6 +48,7 @@ exports[`SelectSessionModal snapshot modal with leave option 1`] = `
<ModalDialog
className="p-4 px-4.5"
hasCloseButton={false}
isFullscreenOnMobile={true}
isOpen={true}
onClose={[MockFunction hooks.nullMethod]}
size="md"
@@ -114,6 +116,7 @@ exports[`SelectSessionModal snapshot modal without leave option 1`] = `
<ModalDialog
className="p-4 px-4.5"
hasCloseButton={false}
isFullscreenOnMobile={true}
isOpen={true}
onClose={[MockFunction hooks.nullMethod]}
size="md"

View File

@@ -35,6 +35,7 @@ export const SelectSessionModal = () => {
isOpen={showModal}
onClose={nullMethod}
hasCloseButton={false}
isFullscreenOnMobile
size="md"
className="p-4 px-4.5"
title={header}

View File

@@ -3,6 +3,7 @@
exports[`UnenrollConfirmModal component snapshot: modalStates.confirm 1`] = `
<ModalDialog
hasCloseButton={false}
isFullscreenOnMobile={false}
isOpen={true}
onClose={[MockFunction hooks.nullMethod]}
title=""
@@ -26,6 +27,7 @@ exports[`UnenrollConfirmModal component snapshot: modalStates.confirm 1`] = `
exports[`UnenrollConfirmModal component snapshot: modalStates.finished, reason given 1`] = `
<ModalDialog
hasCloseButton={false}
isFullscreenOnMobile={false}
isOpen={true}
onClose={[MockFunction hooks.nullMethod]}
title=""
@@ -49,6 +51,7 @@ exports[`UnenrollConfirmModal component snapshot: modalStates.finished, reason g
exports[`UnenrollConfirmModal component snapshot: modalStates.finished, reason skipped 1`] = `
<ModalDialog
hasCloseButton={false}
isFullscreenOnMobile={false}
isOpen={true}
onClose={[MockFunction hooks.nullMethod]}
title=""
@@ -69,15 +72,16 @@ exports[`UnenrollConfirmModal component snapshot: modalStates.finished, reason s
</ModalDialog>
`;
exports[`UnenrollConfirmModal component snapshot: modalStates.reason 1`] = `
exports[`UnenrollConfirmModal component snapshot: modalStates.reason, should be fullscreen with no shadow 1`] = `
<ModalDialog
hasCloseButton={false}
isFullscreenOnMobile={true}
isOpen={true}
onClose={[MockFunction hooks.nullMethod]}
title=""
>
<div
className="bg-white p-3 rounded shadow"
className="bg-white p-3 rounded"
style={
Object {
"textAlign": "start",

View File

@@ -1,4 +1,5 @@
import React from 'react';
import classNames from 'classnames';
import { useDispatch } from 'react-redux';
import PropTypes from 'prop-types';
@@ -26,14 +27,19 @@ export const UnenrollConfirmModal = ({
close,
modalState,
} = useUnenrollData({ dispatch, closeModal });
const showFullscreen = modalState === modalStates.reason;
return (
<ModalDialog
isOpen={show}
onClose={nullMethod}
hasCloseButton={false}
isFullscreenOnMobile={showFullscreen}
title=""
>
<div className="bg-white p-3 rounded shadow" style={{ textAlign: 'start' }}>
<div
className={classNames('bg-white p-3 rounded', { shadow: !showFullscreen })}
style={{ textAlign: 'start' }}
>
{(modalState === modalStates.confirm) && (
<ConfirmPane handleClose={close} handleConfirm={confirm} />
)}

View File

@@ -51,7 +51,7 @@ describe('UnenrollConfirmModal component', () => {
});
expect(shallow(<UnenrollConfirmModal {...{ closeModal, show }} />)).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(<UnenrollConfirmModal {...{ closeModal, show }} />)).toMatchSnapshot();
});

View File

@@ -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({

View File

@@ -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,
});

View File

@@ -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,
});

View File

@@ -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,
};

View File

@@ -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,
});

View File

@@ -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 };

View File

@@ -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)),
},
};
},
);

View File

@@ -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 },
});
});
});
});
});