Skip to content

Commit

Permalink
feet: [BD-26] Add support for special exams (openedx#435)
Browse files Browse the repository at this point in the history
* feat: add packages dir to .gitignore

* Investigate exam redirect (openedx#2)

* feat: remove exam redirect

* feat: take control over exam instructions

* refactor: use fedx code structure

* fix: remove debug logging, remove redirect check

Co-authored-by: Vladas Tamoshaitis <vladas.tamoshaitis@raccoongang.com>

* Add state and reducer for check microfrontend_special_exams waffle flag (openedx#4)

* feat: add state and reducer for check microfrontend_special_exams waffle flag

* fix: rename special exams enabled flag

* fix: rename reducer for setting special exams enabled flag

* refactor: timer feature

* feat(tests): extend tests + fix failing ones, fix quality

* fix: revert removing package lock file

Co-authored-by: Vladas Tamoshaitis <vladas.tamoshaitis@raccoongang.com>

* fix: naming of waffle flag helpers to reflect relation with mfe

* fix: change naming of the waffle flag

* fix: revert remove package lock file

* feat: switch to @edx npm package

* fix: Remove redundant references from .gitignore

* fix: add is_mfe_special_exams_enabled to courseMetadata.factory.js

* fix: fix tests for 'Sequence' content wrapped in 'SequenceExamWrapper'

Co-authored-by: Sagirov Eugeniy <sagirov19@gmail.com>
Co-authored-by: Vladas Tamoshaitis <vladas.tamoshaitis@raccoongang.com>
Co-authored-by: Sagirov Evgeniy <34642612+UvgenGen@users.noreply.github.com>
Co-authored-by: Igor Degtiarov <igor.degtiarov@raccoongang.com>
  • Loading branch information
5 people authored May 24, 2021
1 parent 46056a0 commit a5ba565
Show file tree
Hide file tree
Showing 14 changed files with 1,035 additions and 1,172 deletions.
3 changes: 2 additions & 1 deletion .eslintignore
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
coverage/*
dist/
packages/
node_modules/
jest.config.js
jest.config.js
2,000 changes: 911 additions & 1,089 deletions package-lock.json

Large diffs are not rendered by default.

1 change: 1 addition & 0 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -38,6 +38,7 @@
"@edx/brand": "npm:@edx/brand-openedx@1.1.0",
"@edx/frontend-component-footer": "10.1.4",
"@edx/frontend-enterprise": "4.2.3",
"@edx/frontend-lib-special-exams": "^1.0.0",
"@edx/frontend-platform": "1.10.2",
"@edx/paragon": "14.8.0",
"@fortawesome/fontawesome-svg-core": "1.2.34",
Expand Down
3 changes: 3 additions & 0 deletions src/course-home/data/__snapshots__/redux.test.js.snap
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,7 @@ Object {
"courseStatus": "loading",
"sequenceId": null,
"sequenceStatus": "loading",
"specialExamsEnabledWaffleFlag": false,
},
"models": Object {
"courseHomeMeta": Object {
Expand Down Expand Up @@ -307,6 +308,7 @@ Object {
"courseStatus": "loading",
"sequenceId": null,
"sequenceStatus": "loading",
"specialExamsEnabledWaffleFlag": false,
},
"models": Object {
"courseHomeMeta": Object {
Expand Down Expand Up @@ -475,6 +477,7 @@ Object {
"courseStatus": "loading",
"sequenceId": null,
"sequenceStatus": "loading",
"specialExamsEnabledWaffleFlag": false,
},
"models": Object {
"courseHomeMeta": Object {
Expand Down
13 changes: 11 additions & 2 deletions src/courseware/CoursewareContainer.jsx
Original file line number Diff line number Diff line change
Expand Up @@ -121,6 +121,7 @@ class CoursewareContainer extends Component {
sequenceId,
courseStatus,
sequenceStatus,
specialExamsEnabledWaffleFlag,
sequence,
firstSequenceId,
unitViaSequenceId,
Expand Down Expand Up @@ -176,7 +177,9 @@ class CoursewareContainer extends Component {
// Check special exam redirect:
// /course/:courseId/:sequenceId(/:unitId) -> :legacyWebUrl
// because special exams are currently still served in the legacy LMS frontend.
checkSpecialExamRedirect(sequenceStatus, sequence);
if (!specialExamsEnabledWaffleFlag) {
checkSpecialExamRedirect(sequenceStatus, sequence);
}

// Check to sequence to sequence-unit redirect:
// /course/:courseId/:sequenceId -> /course/:courseId/:sequenceId/:unitId
Expand Down Expand Up @@ -362,6 +365,7 @@ CoursewareContainer.propTypes = {
checkBlockCompletion: PropTypes.func.isRequired,
fetchCourse: PropTypes.func.isRequired,
fetchSequence: PropTypes.func.isRequired,
specialExamsEnabledWaffleFlag: PropTypes.bool.isRequired,
};

CoursewareContainer.defaultProps = {
Expand Down Expand Up @@ -461,14 +465,19 @@ const unitViaSequenceIdSelector = createSelector(

const mapStateToProps = (state) => {
const {
courseId, sequenceId, courseStatus, sequenceStatus,
courseId,
sequenceId,
courseStatus,
sequenceStatus,
specialExamsEnabledWaffleFlag,
} = state.courseware;

return {
courseId,
sequenceId,
courseStatus,
sequenceStatus,
specialExamsEnabledWaffleFlag,
course: currentCourseSelector(state),
sequence: currentSequenceSelector(state),
previousSequence: previousSequenceSelector(state),
Expand Down
2 changes: 2 additions & 0 deletions src/courseware/CoursewareContainer.test.jsx
Original file line number Diff line number Diff line change
Expand Up @@ -127,6 +127,8 @@ describe('CoursewareContainer', () => {
sequenceMetadatas.forEach(sequenceMetadata => {
const sequenceMetadataUrl = `${getConfig().LMS_BASE_URL}/api/courseware/sequence/${sequenceMetadata.item_id}`;
axiosMock.onGet(sequenceMetadataUrl).reply(200, sequenceMetadata);
const proctoredExamApiUrl = `${getConfig().LMS_BASE_URL}/api/edx_proctoring/v1/proctored_exam/attempt/course_id/${courseId}/content_id/${sequenceMetadata.item_id}?is_learning_mfe=true`;
axiosMock.onGet(proctoredExamApiUrl).reply(200, { exam: {}, active_attempt: {} });
});
}

Expand Down
2 changes: 1 addition & 1 deletion src/courseware/course/Course.test.jsx
Original file line number Diff line number Diff line change
Expand Up @@ -37,7 +37,7 @@ describe('Course', () => {
it('loads learning sequence', async () => {
render(<Course {...mockData} />);
expect(screen.getByRole('navigation', { name: 'breadcrumb' })).toBeInTheDocument();
expect(screen.getByText('Loading learning sequence...')).toBeInTheDocument();
expect(await screen.findByText('Loading learning sequence...')).toBeInTheDocument();

expect(screen.queryByRole('alert')).not.toBeInTheDocument();
expect(screen.queryByRole('dialog')).not.toBeInTheDocument();
Expand Down
152 changes: 80 additions & 72 deletions src/courseware/course/sequence/Sequence.jsx
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@ import {
import { injectIntl, intlShape } from '@edx/frontend-platform/i18n';
import { useSelector } from 'react-redux';
import { history } from '@edx/frontend-platform';
import SequenceExamWrapper from '@edx/frontend-lib-special-exams';

import PageLoading from '../../../generic/PageLoading';
import { UserMessagesContext, ALERT_TYPES } from '../../../generic/user-messages';
Expand Down Expand Up @@ -46,6 +47,7 @@ function Sequence({
const sequence = useModel('sequences', sequenceId);
const unit = useModel('units', unitId);
const sequenceStatus = useSelector(state => state.courseware.sequenceStatus);
const specialExamsEnabledWaffleFlag = useSelector(state => state.courseware.specialExamsEnabledWaffleFlag);
const shouldDisplaySidebarButton = useWindowSize().width < responsiveBreakpoints.small.minWidth;

const handleNext = () => {
Expand Down Expand Up @@ -138,7 +140,7 @@ function Sequence({
because we expect CoursewareContainer to be performing a redirect to the legacy experience while
we're waiting. That redirect may take a few seconds, so we show the spinner in the meantime.
*/
if (sequenceStatus === 'loaded' && sequence.isTimeLimited) {
if (sequenceStatus === 'loaded' && sequence.isTimeLimited && !specialExamsEnabledWaffleFlag) {
return (
<PageLoading
srMessage={intl.formatMessage(messages['learn.loading.learning.sequence'])}
Expand All @@ -151,83 +153,89 @@ function Sequence({
history.push(`/course/${courseId}/course-end`);
};

if (sequenceStatus === 'loaded') {
return (
<div>
<div className="sequence-container" style={{ display: 'inline-flex', flexDirection: 'row' }}>
<div className={classNames('sequence', { 'position-relative': shouldDisplaySidebarButton })} style={{ width: '100%' }}>
<SequenceNavigation
sequenceId={sequenceId}
unitId={unitId}
className="mb-4"
const defaultContent = (
<div className="sequence-container" style={{ display: 'inline-flex', flexDirection: 'row' }}>
<div className={classNames('sequence', { 'position-relative': shouldDisplaySidebarButton })} style={{ width: '100%' }}>
<SequenceNavigation
sequenceId={sequenceId}
unitId={unitId}
className="mb-4"

/** [MM-P2P] Experiment */
mmp2p={mmp2p}
/** [MM-P2P] Experiment */
mmp2p={mmp2p}

nextSequenceHandler={() => {
logEvent('edx.ui.lms.sequence.next_selected', 'top');
handleNext();
}}
onNavigate={(destinationUnitId) => {
logEvent('edx.ui.lms.sequence.tab_selected', 'top', destinationUnitId);
handleNavigate(destinationUnitId);
}}
previousSequenceHandler={() => {
logEvent('edx.ui.lms.sequence.previous_selected', 'top');
handlePrevious();
}}
goToCourseExitPage={() => goToCourseExitPage()}
isValuePropCookieSet={isValuePropCookieSet}
/>
nextSequenceHandler={() => {
logEvent('edx.ui.lms.sequence.next_selected', 'top');
handleNext();
}}
onNavigate={(destinationUnitId) => {
logEvent('edx.ui.lms.sequence.tab_selected', 'top', destinationUnitId);
handleNavigate(destinationUnitId);
}}
previousSequenceHandler={() => {
logEvent('edx.ui.lms.sequence.previous_selected', 'top');
handlePrevious();
}}
goToCourseExitPage={() => goToCourseExitPage()}
isValuePropCookieSet={isValuePropCookieSet}
/>

{isValuePropCookieSet && shouldDisplaySidebarButton ? (
<SidebarNotificationButton
toggleSidebar={toggleSidebar}
isSidebarVisible={isSidebarVisible}
/>
) : null}
{isValuePropCookieSet && shouldDisplaySidebarButton ? (
<SidebarNotificationButton
toggleSidebar={toggleSidebar}
isSidebarVisible={isSidebarVisible}
/>
) : null}

<div className="unit-container flex-grow-1">
<SequenceContent
courseId={courseId}
gated={gated}
sequenceId={sequenceId}
unitId={unitId}
unitLoadedHandler={handleUnitLoaded}
/** [MM-P2P] Experiment */
mmp2p={mmp2p}
/>
{unitHasLoaded && (
<UnitNavigation
sequenceId={sequenceId}
unitId={unitId}
onClickPrevious={() => {
logEvent('edx.ui.lms.sequence.previous_selected', 'bottom');
handlePrevious();
}}
onClickNext={() => {
logEvent('edx.ui.lms.sequence.next_selected', 'bottom');
handleNext();
}}
goToCourseExitPage={() => goToCourseExitPage()}
/>
)}
</div>
</div>
{sidebarVisible ? (
<Sidebar
toggleSidebar={toggleSidebar}
sidebarVisible={sidebarVisible}
/>
) : null }

{/** [MM-P2P] Experiment */}
{(mmp2p.state.isEnabled && mmp2p.flyover.isVisible) && (
isMobile()
? <MMP2PFlyoverMobile options={mmp2p} />
: <MMP2PFlyover options={mmp2p} />
<div className="unit-container flex-grow-1">
<SequenceContent
courseId={courseId}
gated={gated}
sequenceId={sequenceId}
unitId={unitId}
unitLoadedHandler={handleUnitLoaded}
/** [MM-P2P] Experiment */
mmp2p={mmp2p}
/>
{unitHasLoaded && (
<UnitNavigation
sequenceId={sequenceId}
unitId={unitId}
onClickPrevious={() => {
logEvent('edx.ui.lms.sequence.previous_selected', 'bottom');
handlePrevious();
}}
onClickNext={() => {
logEvent('edx.ui.lms.sequence.next_selected', 'bottom');
handleNext();
}}
goToCourseExitPage={() => goToCourseExitPage()}
/>
)}
</div>
</div>
{sidebarVisible ? (
<Sidebar
toggleSidebar={toggleSidebar}
sidebarVisible={sidebarVisible}
/>
) : null }

{/** [MM-P2P] Experiment */}
{(mmp2p.state.isEnabled && mmp2p.flyover.isVisible) && (
isMobile()
? <MMP2PFlyoverMobile options={mmp2p} />
: <MMP2PFlyover options={mmp2p} />
)}
</div>
);

if (sequenceStatus === 'loaded') {
return (
<div>
<SequenceExamWrapper sequence={sequence} courseId={courseId}>
{defaultContent}
</SequenceExamWrapper>
<CourseLicense license={course.license || undefined} />
</div>
);
Expand Down
18 changes: 11 additions & 7 deletions src/courseware/course/sequence/Sequence.test.jsx
Original file line number Diff line number Diff line change
Expand Up @@ -67,11 +67,11 @@ describe('Sequence', () => {
{ store: testStore },
);

expect(screen.getByText('Loading locked content messaging...')).toBeInTheDocument();
// Only `Previous`, `Next` and `Bookmark` buttons.
expect(screen.getAllByRole('button').length).toEqual(3);
await waitFor(() => expect(screen.queryByText('Loading locked content messaging...')).toBeInTheDocument());
// `Previous`, `Active`, `Next` and `Prerequisite` buttons.
expect(screen.getAllByRole('button').length).toEqual(4);

expect(await screen.findByText('Content Locked')).toBeInTheDocument();
expect(screen.getByText('Content Locked')).toBeInTheDocument();
const unitContainer = container.querySelector('.unit-container');
expect(unitContainer.querySelector('svg')).toHaveClass('fa-lock');
expect(screen.getByText(/You must complete the prerequisite/)).toBeInTheDocument();
Expand Down Expand Up @@ -120,7 +120,7 @@ describe('Sequence', () => {

it('handles loading unit', async () => {
render(<Sequence {...mockData} />);
expect(screen.getByText('Loading learning sequence...')).toBeInTheDocument();
expect(await screen.findByText('Loading learning sequence...')).toBeInTheDocument();
// Renders navigation buttons plus one button for each unit.
expect(screen.getAllByRole('button')).toHaveLength(3 + unitBlocks.length);

Expand Down Expand Up @@ -167,6 +167,7 @@ describe('Sequence', () => {
previousSequenceHandler: jest.fn(),
};
render(<Sequence {...testData} />, { store: testStore });
expect(await screen.findByText('Loading learning sequence...')).toBeInTheDocument();

const sequencePreviousButton = screen.getByRole('button', { name: /previous/i });
fireEvent.click(sequencePreviousButton);
Expand Down Expand Up @@ -202,6 +203,7 @@ describe('Sequence', () => {
nextSequenceHandler: jest.fn(),
};
render(<Sequence {...testData} />, { store: testStore });
expect(await screen.findByText('Loading learning sequence...')).toBeInTheDocument();

const sequenceNextButton = screen.getByRole('button', { name: /next/i });
fireEvent.click(sequenceNextButton);
Expand All @@ -228,7 +230,7 @@ describe('Sequence', () => {
});
});

it('navigates to the previous/next unit if the unit is not in the corner of the sequence', () => {
it('navigates to the previous/next unit if the unit is not in the corner of the sequence', async () => {
const unitNumber = 1;
const testData = {
...mockData,
Expand All @@ -239,6 +241,7 @@ describe('Sequence', () => {
nextSequenceHandler: jest.fn(),
};
render(<Sequence {...testData} />, { store: testStore });
await waitFor(() => expect(screen.queryByText('Loading learning sequence...')).toBeInTheDocument());

fireEvent.click(screen.getByRole('button', { name: /previous/i }));
expect(testData.previousSequenceHandler).not.toHaveBeenCalled();
Expand Down Expand Up @@ -360,7 +363,7 @@ describe('Sequence', () => {
});
});

it('handles unit navigation button', () => {
it('handles unit navigation button', async () => {
const currentTabNumber = 1;
const targetUnitNumber = 2;
const targetUnit = unitBlocks[targetUnitNumber - 1];
Expand All @@ -371,6 +374,7 @@ describe('Sequence', () => {
unitNavigationHandler: jest.fn(),
};
render(<Sequence {...testData} />, { store: testStore });
await waitFor(() => expect(screen.queryByText('Loading learning sequence...')).toBeInTheDocument());

fireEvent.click(screen.getByRole('button', { name: targetUnit.display_name }));
expect(testData.unitNavigationHandler).toHaveBeenCalledWith(targetUnit.id);
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -56,4 +56,5 @@ Factory.define('courseMetadata')
verification_status: 'none',
linkedin_add_to_profile_url: null,
related_programs: null,
is_mfe_special_exams_enabled: false,
});
1 change: 1 addition & 0 deletions src/courseware/data/api.js
Original file line number Diff line number Diff line change
Expand Up @@ -151,6 +151,7 @@ function normalizeMetadata(metadata) {
verificationStatus: metadata.verification_status,
linkedinAddToProfileUrl: metadata.linkedin_add_to_profile_url,
relatedPrograms: camelCaseObject(metadata.related_programs),
specialExamsEnabledWaffleFlag: metadata.is_mfe_special_exams_enabled,
};
}

Expand Down
Loading

0 comments on commit a5ba565

Please sign in to comment.