Skip to content

Commit 57c3f30

Browse files
feat: AA-1205: Enable Entrance Exam support for Learning MFE (#840)
Adds an alert to the courseware if the section is an Entrance Exam. Also adds a listener to reload the page upon receiving a message from the LMS indicating the user has now passed the exam. Commit also contains misc. clean up for i18n messages switching to variable names.
1 parent 385635f commit 57c3f30

File tree

9 files changed

+207
-46
lines changed

9 files changed

+207
-46
lines changed
Lines changed: 56 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,56 @@
1+
import { useSelector } from 'react-redux';
2+
3+
import { useModel } from '../../generic/model-store';
4+
import { ALERT_TYPES, useAlert } from '../../generic/user-messages';
5+
6+
import messages from './messages';
7+
8+
function useSequenceBannerTextAlert(sequenceId) {
9+
const sequence = useModel('sequences', sequenceId);
10+
const sequenceStatus = useSelector(state => state.courseware.sequenceStatus);
11+
12+
// Show Alert that comes along with the sequence
13+
useAlert(sequenceStatus === 'loaded' && sequence.bannerText, {
14+
code: null,
15+
dismissible: false,
16+
text: sequence.bannerText,
17+
type: ALERT_TYPES.INFO,
18+
topic: 'sequence',
19+
});
20+
}
21+
22+
function useSequenceEntranceExamAlert(courseId, sequenceId, intl) {
23+
const course = useModel('coursewareMeta', courseId);
24+
const sequence = useModel('sequences', sequenceId);
25+
const sequenceStatus = useSelector(state => state.courseware.sequenceStatus);
26+
const {
27+
entranceExamCurrentScore,
28+
entranceExamEnabled,
29+
entranceExamId,
30+
entranceExamMinimumScorePct,
31+
entranceExamPassed,
32+
} = course.entranceExamData || {};
33+
const entranceExamAlertVisible = sequenceStatus === 'loaded' && entranceExamEnabled && entranceExamId === sequence.sectionId;
34+
let entranceExamText;
35+
36+
if (entranceExamPassed) {
37+
entranceExamText = intl.formatMessage(
38+
messages.entranceExamTextPassed, { entranceExamCurrentScore: entranceExamCurrentScore * 100 },
39+
);
40+
} else {
41+
entranceExamText = intl.formatMessage(messages.entranceExamTextNotPassing, {
42+
entranceExamCurrentScore: entranceExamCurrentScore * 100,
43+
entranceExamMinimumScorePct: entranceExamMinimumScorePct * 100,
44+
});
45+
}
46+
47+
useAlert(entranceExamAlertVisible, {
48+
code: null,
49+
dismissible: false,
50+
text: entranceExamText,
51+
type: ALERT_TYPES.INFO,
52+
topic: 'sequence',
53+
});
54+
}
55+
56+
export { useSequenceBannerTextAlert, useSequenceEntranceExamAlert };
Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,14 @@
1+
import { defineMessages } from '@edx/frontend-platform/i18n';
2+
3+
const messages = defineMessages({
4+
entranceExamTextNotPassing: {
5+
id: 'learn.sequence.entranceExamTextNotPassing',
6+
defaultMessage: 'To access course materials, you must score {entranceExamMinimumScorePct}% or higher on this exam. Your current score is {entranceExamCurrentScore}%.',
7+
},
8+
entranceExamTextPassed: {
9+
id: 'learn.sequence.entranceExamTextPassed',
10+
defaultMessage: 'Your score is {entranceExamCurrentScore}%. You have passed the entrance exam.',
11+
},
12+
});
13+
14+
export default messages;

src/courseware/course/Course.test.jsx

Lines changed: 90 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -220,4 +220,94 @@ describe('Course', () => {
220220
expect(nextSequenceHandler).not.toHaveBeenCalled();
221221
expect(unitNavigationHandler).toHaveBeenCalledTimes(4);
222222
});
223+
224+
describe('Sequence alerts display', () => {
225+
it('renders banner text alert', async () => {
226+
const courseMetadata = Factory.build('courseMetadata');
227+
const sequenceBlocks = [Factory.build(
228+
'block', { type: 'sequential', banner_text: 'Some random banner text to display.' },
229+
)];
230+
const sequenceMetadata = [Factory.build(
231+
'sequenceMetadata', { banner_text: sequenceBlocks[0].banner_text },
232+
{ courseId: courseMetadata.id, sequenceBlock: sequenceBlocks[0] },
233+
)];
234+
235+
const testStore = await initializeTestStore({ courseMetadata, sequenceBlocks, sequenceMetadata });
236+
const testData = {
237+
...mockData,
238+
courseId: courseMetadata.id,
239+
sequenceId: sequenceBlocks[0].id,
240+
};
241+
render(<Course {...testData} />, { store: testStore });
242+
await waitFor(() => expect(screen.getByText('Some random banner text to display.')).toBeInTheDocument());
243+
});
244+
245+
it('renders Entrance Exam alert with passing score', async () => {
246+
const sectionId = 'block-v1:edX+DemoX+Demo_Course+type@chapter+block@entrance_exam';
247+
const testCourseMetadata = Factory.build('courseMetadata', {
248+
entrance_exam_data: {
249+
entrance_exam_current_score: 1.0,
250+
entrance_exam_enabled: true,
251+
entrance_exam_id: sectionId,
252+
entrance_exam_minimum_score_pct: 0.7,
253+
entrance_exam_passed: true,
254+
},
255+
});
256+
const sequenceBlocks = [Factory.build(
257+
'block',
258+
{ type: 'sequential', sectionId },
259+
{ courseId: testCourseMetadata.id },
260+
)];
261+
const sectionBlocks = [Factory.build(
262+
'block',
263+
{ type: 'chapter', children: sequenceBlocks.map(block => block.id), id: sectionId },
264+
{ courseId: testCourseMetadata.id },
265+
)];
266+
267+
const testStore = await initializeTestStore({
268+
courseMetadata: testCourseMetadata, sequenceBlocks, sectionBlocks,
269+
});
270+
const testData = {
271+
...mockData,
272+
courseId: testCourseMetadata.id,
273+
sequenceId: sequenceBlocks[0].id,
274+
};
275+
render(<Course {...testData} />, { store: testStore });
276+
await waitFor(() => expect(screen.getByText('Your score is 100%. You have passed the entrance exam.')).toBeInTheDocument());
277+
});
278+
279+
it('renders Entrance Exam alert with non-passing score', async () => {
280+
const sectionId = 'block-v1:edX+DemoX+Demo_Course+type@chapter+block@entrance_exam';
281+
const testCourseMetadata = Factory.build('courseMetadata', {
282+
entrance_exam_data: {
283+
entrance_exam_current_score: 0.3,
284+
entrance_exam_enabled: true,
285+
entrance_exam_id: sectionId,
286+
entrance_exam_minimum_score_pct: 0.7,
287+
entrance_exam_passed: false,
288+
},
289+
});
290+
const sequenceBlocks = [Factory.build(
291+
'block',
292+
{ type: 'sequential', sectionId },
293+
{ courseId: testCourseMetadata.id },
294+
)];
295+
const sectionBlocks = [Factory.build(
296+
'block',
297+
{ type: 'chapter', children: sequenceBlocks.map(block => block.id), id: sectionId },
298+
{ courseId: testCourseMetadata.id },
299+
)];
300+
301+
const testStore = await initializeTestStore({
302+
courseMetadata: testCourseMetadata, sequenceBlocks, sectionBlocks,
303+
});
304+
const testData = {
305+
...mockData,
306+
courseId: testCourseMetadata.id,
307+
sequenceId: sequenceBlocks[0].id,
308+
};
309+
render(<Course {...testData} />, { store: testStore });
310+
await waitFor(() => expect(screen.getByText('To access course materials, you must score 70% or higher on this exam. Your current score is 30%.')).toBeInTheDocument());
311+
});
312+
});
223313
});

src/courseware/course/sequence/Sequence.jsx

Lines changed: 16 additions & 22 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
/* eslint-disable no-use-before-define */
22
import React, {
3-
useEffect, useContext, useState,
3+
useEffect, useState,
44
} from 'react';
55
import PropTypes from 'prop-types';
66
import classNames from 'classnames';
@@ -15,8 +15,8 @@ import SequenceExamWrapper from '@edx/frontend-lib-special-exams';
1515
import { breakpoints, useWindowSize } from '@edx/paragon';
1616

1717
import PageLoading from '../../../generic/PageLoading';
18-
import { UserMessagesContext, ALERT_TYPES } from '../../../generic/user-messages';
1918
import { useModel } from '../../../generic/model-store';
19+
import { useSequenceBannerTextAlert, useSequenceEntranceExamAlert } from '../../../alerts/sequence-alerts/hooks';
2020

2121
import CourseLicense from '../course-license';
2222
import Sidebar from '../sidebar/Sidebar';
@@ -89,26 +89,20 @@ function Sequence({
8989
sendTrackingLogEvent(eventName, payload);
9090
};
9191

92-
const { add, remove } = useContext(UserMessagesContext);
92+
useSequenceBannerTextAlert(sequenceId);
93+
useSequenceEntranceExamAlert(courseId, sequenceId, intl);
94+
9395
useEffect(() => {
94-
let id = null;
95-
if (sequenceStatus === 'loaded') {
96-
if (sequence.bannerText) {
97-
id = add({
98-
code: null,
99-
dismissible: false,
100-
text: sequence.bannerText,
101-
type: ALERT_TYPES.INFO,
102-
topic: 'sequence',
103-
});
96+
function receiveMessage(event) {
97+
const { type } = event.data;
98+
if (type === 'entranceExam.passed') {
99+
// I know this seems (is) intense. It is implemented this way since we need to refetch the underlying
100+
// course blocks that were originally hidden because the Entrance Exam was not passed.
101+
global.location.reload();
104102
}
105103
}
106-
return () => {
107-
if (id) {
108-
remove(id);
109-
}
110-
};
111-
}, [sequenceStatus, sequence]);
104+
global.addEventListener('message', receiveMessage);
105+
}, []);
112106

113107
const [unitHasLoaded, setUnitHasLoaded] = useState(false);
114108
const handleUnitLoaded = () => {
@@ -130,11 +124,11 @@ function Sequence({
130124
const loading = sequenceStatus === 'loading' || (sequenceStatus === 'failed' && sequenceMightBeUnit);
131125
if (loading) {
132126
if (!sequenceId) {
133-
return (<div> {intl.formatMessage(messages['learn.sequence.no.content'])} </div>);
127+
return (<div> {intl.formatMessage(messages.noContent)} </div>);
134128
}
135129
return (
136130
<PageLoading
137-
srMessage={intl.formatMessage(messages['learn.loading.learning.sequence'])}
131+
srMessage={intl.formatMessage(messages.loadingSequence)}
138132
/>
139133
);
140134
}
@@ -236,7 +230,7 @@ function Sequence({
236230
// sequence status 'failed' and any other unexpected sequence status.
237231
return (
238232
<p className="text-center py-5 mx-auto" style={{ maxWidth: '30em' }}>
239-
{intl.formatMessage(messages['learn.course.load.failure'])}
233+
{intl.formatMessage(messages.loadFailure)}
240234
</p>
241235
);
242236
}

src/courseware/course/sequence/SequenceContent.jsx

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -31,7 +31,7 @@ function SequenceContent({
3131
<Suspense
3232
fallback={(
3333
<PageLoading
34-
srMessage={intl.formatMessage(messages['learn.loading.content.lock'])}
34+
srMessage={intl.formatMessage(messages.loadingLockedContent)}
3535
/>
3636
)}
3737
>
@@ -49,7 +49,7 @@ function SequenceContent({
4949
if (!unitId || !unit) {
5050
return (
5151
<div>
52-
{intl.formatMessage(messages['learn.sequence.no.content'])}
52+
{intl.formatMessage(messages.noContent)}
5353
</div>
5454
);
5555
}

src/courseware/course/sequence/Unit.jsx

Lines changed: 4 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -146,7 +146,7 @@ function Unit({
146146
return (
147147
<div className="unit">
148148
<h1 className="mb-0 h3">{unit.title}</h1>
149-
<h2 className="sr-only">{intl.formatMessage(messages['learn.header.h2.placeholder'])}</h2>
149+
<h2 className="sr-only">{intl.formatMessage(messages.headerPlaceholder)}</h2>
150150
<BookmarkButton
151151
unitId={unit.id}
152152
isBookmarked={unit.bookmarked}
@@ -156,7 +156,7 @@ function Unit({
156156
<Suspense
157157
fallback={(
158158
<PageLoading
159-
srMessage={intl.formatMessage(messages['learn.loading.content.lock'])}
159+
srMessage={intl.formatMessage(messages.loadingLockedContent)}
160160
/>
161161
)}
162162
>
@@ -171,7 +171,7 @@ function Unit({
171171
<Suspense
172172
fallback={(
173173
<PageLoading
174-
srMessage={intl.formatMessage(messages['learn.loading.honor.code'])}
174+
srMessage={intl.formatMessage(messages.loadingHonorCode)}
175175
/>
176176
)}
177177
>
@@ -181,7 +181,7 @@ function Unit({
181181
{ /** [MM-P2P] Experiment (conditional) */ }
182182
{!mmp2p.meta.blockContent && !shouldDisplayHonorCode && !hasLoaded && !showError && (
183183
<PageLoading
184-
srMessage={intl.formatMessage(messages['learn.loading.learning.sequence'])}
184+
srMessage={intl.formatMessage(messages.loadingSequence)}
185185
/>
186186
)}
187187
{!mmp2p.meta.blockContent && !shouldDisplayHonorCode && !hasLoaded && showError && (
Lines changed: 17 additions & 18 deletions
Original file line numberDiff line numberDiff line change
@@ -1,37 +1,36 @@
11
import { defineMessages } from '@edx/frontend-platform/i18n';
22

33
const messages = defineMessages({
4-
'learn.loading.content.lock': {
5-
id: 'learn.loading.content.lock',
6-
defaultMessage: 'Loading locked content messaging...',
7-
description: 'Message shown when an interface about locked content is being loaded',
4+
headerPlaceholder: {
5+
id: 'learn.header.h2.placeholder',
6+
defaultMessage: 'Level 2 headings may be created by course providers in the future.',
7+
description: 'Message spoken by a screenreader indicating that the h2 tag is a placeholder.',
88
},
9-
'learn.loading.honor.code': {
9+
loadFailure: {
10+
id: 'learn.course.load.failure',
11+
defaultMessage: 'There was an error loading this course.',
12+
description: 'Message when a course fails to load',
13+
},
14+
loadingHonorCode: {
1015
id: 'learn.loading.honor.codk',
1116
defaultMessage: 'Loading honor code messaging...',
1217
description: 'Message shown when an interface about the honor code is being loaded',
1318
},
14-
'learn.loading.learning.sequence': {
19+
loadingLockedContent: {
20+
id: 'learn.loading.content.lock',
21+
defaultMessage: 'Loading locked content messaging...',
22+
description: 'Message shown when an interface about locked content is being loaded',
23+
},
24+
loadingSequence: {
1525
id: 'learn.loading.learning.sequence',
1626
defaultMessage: 'Loading learning sequence...',
1727
description: 'Message when learning sequence is being loaded',
1828
},
19-
'learn.course.load.failure': {
20-
id: 'learn.course.load.failure',
21-
defaultMessage: 'There was an error loading this course.',
22-
description: 'Message when a course fails to load',
23-
},
24-
'learn.sequence.no.content': {
29+
noContent: {
2530
id: 'learn.sequence.no.content',
2631
defaultMessage: 'There is no content here.',
2732
description: 'Message shown when there is no content to show a user inside a learning sequence.',
2833
},
29-
'learn.header.h2.placeholder': {
30-
id: 'learn.header.h2.placeholder',
31-
defaultMessage: 'Level 2 headings may be created by course providers in the future.',
32-
description: 'Message spoken by a screenreader indicating that the h2 tag is a placeholder.',
33-
34-
},
3534
});
3635

3736
export default messages;

src/courseware/data/__factories__/courseMetadata.factory.js

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -52,6 +52,13 @@ Factory.define('courseMetadata')
5252
course_exit_page_is_active: true,
5353
user_has_passing_grade: false,
5454
certificate_data: null,
55+
entrance_exam_data: {
56+
entrance_exam_current_score: 0.0,
57+
entrance_exam_enabled: false,
58+
entrance_exam_id: '',
59+
entrance_exam_minimum_score_pct: 0.65,
60+
entrance_exam_passed: true,
61+
},
5562
verify_identity_url: null,
5663
verification_status: 'none',
5764
linkedin_add_to_profile_url: null,

src/courseware/data/api.js

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -117,6 +117,7 @@ function normalizeMetadata(metadata) {
117117
userHasPassingGrade: data.user_has_passing_grade,
118118
courseExitPageIsActive: data.course_exit_page_is_active,
119119
certificateData: camelCaseObject(data.certificate_data),
120+
entranceExamData: camelCaseObject(data.entrance_exam_data),
120121
timeOffsetMillis: getTimeOffsetMillis(headers && headers.date, requestTime, responseTime),
121122
verifyIdentityUrl: data.verify_identity_url,
122123
verificationStatus: data.verification_status,

0 commit comments

Comments
 (0)