Merge branch 'master' into KristinAoki/TNL-8511

This commit is contained in:
Kristin Aoki
2021-08-12 15:10:40 -04:00
24 changed files with 9796 additions and 7070 deletions

5
.env
View File

@@ -1,3 +1,6 @@
# See README.rst for explanations of these.
# If you add a new learning MFE-specific variable, please note it there!
NODE_ENV='production'
ACCESS_TOKEN_COOKIE_NAME=''
BASE_URL=''
@@ -32,4 +35,4 @@ TERMS_OF_SERVICE_URL=''
TWITTER_HASHTAG=''
TWITTER_URL=''
USER_INFO_COOKIE_NAME=''
SESSION_COOKIE_DOMAIN=''
SESSION_COOKIE_DOMAIN=''

View File

@@ -1,3 +1,6 @@
# See README.rst for explanations of these.
# If you add a new learning MFE-specific variable, please note it there!
NODE_ENV='development'
ACCESS_TOKEN_COOKIE_NAME='edx-jwt-cookie-header-payload'
BASE_URL='http://localhost:2000'
@@ -32,4 +35,4 @@ TERMS_OF_SERVICE_URL='https://www.edx.org/edx-terms-service'
TWITTER_HASHTAG='myedxjourney'
TWITTER_URL='https://twitter.com/edXOnline'
USER_INFO_COOKIE_NAME='edx-user-info'
SESSION_COOKIE_DOMAIN='localhost'
SESSION_COOKIE_DOMAIN='localhost'

View File

@@ -1,3 +1,6 @@
# See README.rst for explanations of these.
# If you add a new learning MFE-specific variable, please note it there!
NODE_ENV='test'
ACCESS_TOKEN_COOKIE_NAME='edx-jwt-cookie-header-payload'
BASE_URL='http://localhost:2000'

View File

@@ -11,11 +11,11 @@ jobs:
- 12
steps:
- uses: actions/checkout@v2
- uses: actions/setup-node@v1
- uses: actions/setup-node@v2
with:
node-version: ${{ matrix.node-version }}
- run: make validate.ci
- name: Upload coverage
uses: codecov/codecov-action@v1
uses: codecov/codecov-action@v2
with:
fail_ci_if_error: true

1
.gitignore vendored
View File

@@ -8,6 +8,7 @@ coverage
dist/
src/i18n/transifex_input.json
temp/babel-plugin-react-intl
logs
### pyenv ###
.python-version

4
.husky/pre-commit Executable file
View File

@@ -0,0 +1,4 @@
#!/bin/sh
. "$(dirname "$0")/_/husky.sh"
npm run lint

View File

@@ -1,23 +1,21 @@
|Coveralls| |npm_version| |npm_downloads| |license|
|codecov| |license|
frontend-app-learning
=========================
Please tag **@edx/teaching-and-learning** on any PRs or issues. Thanks.
Introduction
------------
React app for edX learning.
This is the Learning MFE (micro-frontend application), which renders all
learner-facing course pages (like the course outline, the progress page,
actual course content, etc).
.. |Coveralls| image:: https://img.shields.io/coveralls/edx/frontend-app-learning.svg?branch=master
:target: https://coveralls.io/github/edx/frontend-app-learning
.. |npm_version| image:: https://img.shields.io/npm/v/@edx/frontend-app-learning.svg
:target: @edx/frontend-app-learning
.. |npm_downloads| image:: https://img.shields.io/npm/dt/@edx/frontend-app-learning.svg
:target: @edx/frontend-app-learning
.. |license| image:: https://img.shields.io/npm/l/@edx/frontend-app-learning.svg
:target: @edx/frontend-app-learning
Please tag **@edx/engage-squad** on any PRs or issues. Thanks.
.. |codecov| image:: https://codecov.io/gh/edx/frontend-app-learning/branch/master/graph/badge.svg?token=3z7XvuzTq3
:target: https://codecov.io/gh/edx/frontend-app-learning
.. |license| image:: https://img.shields.io/badge/license-AGPL-informational
:target: https://github.com/edx/frontend-app-account/blob/master/LICENSE
Development
-----------
@@ -25,22 +23,10 @@ Development
Start Devstack
^^^^^^^^^^^^^^
To use this application `devstack <https://github.com/edx/devstack>`__ must be running and you must be logged into it.
To use this application, `devstack <https://github.com/edx/devstack>`__ must be running and you must be logged into it.
- Start devstack
- Log in (http://localhost:18000/login)
Start the development server
^^^^^^^^^^^^^^^^^^^^^^^^^^^^
In this project, install requirements and start the development server by running:
.. code:: bash
npm install
npm start # The server will run on port 1995
Once the dev server is up, visit http://localhost:2000/course/course-v1:edX+DemoX+Demo_Course to view the demo course. You can replace ``course-v1:edX+DemoX+Demo_Course`` with a different course key.
- Run ``make dev.up.lms``
- Visit http://localhost:2000/course/course-v1:edX+DemoX+Demo_Course to view the demo course. You can replace ``course-v1:edX+DemoX+Demo_Course`` with a different course key.
Local module development
^^^^^^^^^^^^^^^^^^^^^^^^
@@ -67,3 +53,59 @@ file (which is git-ignored) that defines where to find your local modules, for i
};
See https://github.com/edx/frontend-build#local-module-configuration-for-webpack for more details.
Deployment
----------
The Learning MFE is similar to all the other Open edX MFEs. Read the Open
edX Developer Guide's section on
`MFE applications <https://edx.readthedocs.io/projects/edx-developer-docs/en/latest/developers_guide/micro_frontends_in_open_edx.html>`_.
Environment Variables
^^^^^^^^^^^^^^^^^^^^^
This MFE is configured via environment variables supplied at build time.
All micro-frontends have a shared set of required environment variables,
as documented in the Open edX Developer Guide under
`Required Environment Variables <https://edx.readthedocs.io/projects/edx-developer-docs/en/latest/developers_guide/micro_frontends_in_open_edx.html#required-environment-variables>`_.
The learning micro-frontend also supports the following additional variables:
SOCIAL_UTM_MILESTONE_CAMPAIGN
This value is passed as the ``utm_campaign`` parameter for social-share
links when celebrating learning milestones in the course. Optional.
Example: ``milestone``
SUPPORT_URL_CALCULATOR_MATH
A link that explains how to use the in-course calculator. You can use the
one in the example below, if you don't want to have your own branded version.
Example: https://support.edx.org/hc/en-us/articles/360000038428-Entering-math-expressions-in-assignments-or-the-calculator
SUPPORT_URL_ID_VERIFICATION
A link that explains how to verify your ID. Shown in contexts where you need
to verify yourself to earn a certificate. The example link below is probably too
edx.org-specific to use for your own site.
Example: https://support.edx.org/hc/en-us/articles/206503858-How-do-I-verify-my-identity
SUPPORT_URL_VERIFIED_CERTIFICATE
A link that explains what a verified certificate is. You can use the
one in the example below, if you don't want to have your own branded version.
Optional.
Example: https://support.edx.org/hc/en-us/articles/206502008-What-is-a-verified-certificate
TWITTER_HASHTAG
This value is used in the Twitter social-share link when celebrating learning
milestones in the course. Will prefill the suggested post with this hashtag.
Optional.
Example: ``brandedhashtag``
TWITTER_URL
A link to your Twitter account. The Twitter social-share link won't appear
unless this is set. Optional.
Example: https://twitter.com/edXOnline

15713
package-lock.json generated

File diff suppressed because it is too large Load Diff

View File

@@ -16,15 +16,11 @@
"is-es5": "es-check es5 ./dist/*.js",
"lint": "fedx-scripts eslint --ext .js --ext .jsx .",
"lint:fix": "fedx-scripts eslint --fix --ext .js --ext .jsx .",
"prepare": "husky install",
"snapshot": "fedx-scripts jest --updateSnapshot",
"start": "fedx-scripts webpack-dev-server --progress",
"test": "fedx-scripts jest --coverage --passWithNoTests"
},
"husky": {
"hooks": {
"pre-commit": "npm run lint"
}
},
"author": "edX",
"license": "AGPL-3.0",
"homepage": "https://github.com/edx/frontend-app-learning#readme",
@@ -36,49 +32,51 @@
},
"dependencies": {
"@edx/brand": "npm:@edx/brand-openedx@1.1.0",
"@edx/frontend-component-footer": "10.1.5",
"@edx/frontend-enterprise": "4.2.3",
"@edx/frontend-lib-special-exams": "1.11.0",
"@edx/frontend-platform": "1.11.0",
"@edx/paragon": "16.5.0",
"@fortawesome/fontawesome-svg-core": "1.2.34",
"@fortawesome/free-brands-svg-icons": "5.13.1",
"@fortawesome/free-regular-svg-icons": "5.13.1",
"@fortawesome/free-solid-svg-icons": "5.13.1",
"@fortawesome/react-fontawesome": "0.1.14",
"@reduxjs/toolkit": "1.3.6",
"classnames": "2.2.6",
"core-js": "3.6.5",
"@edx/frontend-component-footer": "10.1.6",
"@edx/frontend-enterprise-utils": "0.1.7",
"@edx/frontend-lib-special-exams": "1.12.0",
"@edx/frontend-platform": "1.12.3",
"@edx/paragon": "16.7.0",
"@fortawesome/fontawesome-svg-core": "1.2.36",
"@fortawesome/free-brands-svg-icons": "5.15.4",
"@fortawesome/free-regular-svg-icons": "5.15.4",
"@fortawesome/free-solid-svg-icons": "5.15.4",
"@fortawesome/react-fontawesome": "0.1.15",
"@pact-foundation/pact": "9.16.0",
"@reduxjs/toolkit": "1.6.1",
"classnames": "2.3.1",
"core-js": "3.16.1",
"js-cookie": "2.2.1",
"lodash.camelcase": "^4.3.0",
"lodash.camelcase": "4.3.0",
"prop-types": "15.7.2",
"react": "16.13.1",
"react": "17.0.2",
"react-break": "1.3.2",
"react-dom": "16.13.1",
"react-helmet": "6.0.0",
"react-dom": "17.0.2",
"react-helmet": "6.1.0",
"react-redux": "7.2.4",
"react-router": "5.2.0",
"react-router-dom": "5.2.0",
"react-share": "4.2.1",
"redux": "4.0.5",
"regenerator-runtime": "0.13.7",
"react-share": "4.4.0",
"redux": "4.1.1",
"regenerator-runtime": "0.13.9",
"reselect": "4.0.0",
"truncate-html": "1.0.3"
"truncate-html": "1.0.4",
"util": "0.12.4"
},
"devDependencies": {
"@edx/frontend-build": "5.5.5",
"@edx/frontend-build": "8.0.0",
"@testing-library/dom": "7.16.3",
"@testing-library/jest-dom": "5.10.1",
"@testing-library/jest-dom": "5.14.1",
"@testing-library/react": "10.3.0",
"@testing-library/user-event": "12.0.17",
"axios-mock-adapter": "1.18.2",
"codecov": "3.8.2",
"es-check": "5.1.4",
"@testing-library/user-event": "12.8.3",
"axios-mock-adapter": "1.19.0",
"codecov": "3.8.3",
"es-check": "5.2.4",
"glob": "7.1.7",
"husky": "3.1.0",
"jest": "24.9.0",
"husky": "7.0.1",
"jest": "27.0.6",
"jest-chain": "1.1.5",
"reactifex": "1.1.1",
"rosie": "2.0.1"
"rosie": "2.1.0"
}
}

View File

@@ -9,7 +9,7 @@ import { faSpinner } from '@fortawesome/free-solid-svg-icons';
import { useModel } from '../../generic/model-store';
import messages from './messages';
import { useEnrollClickHandler } from './hooks';
import useEnrollClickHandler from './clickHook';
function EnrollmentAlert({ intl, payload }) {
const {

View File

@@ -0,0 +1,35 @@
import { useContext, useState, useCallback } from 'react';
import { sendTrackEvent } from '@edx/frontend-platform/analytics';
import { UserMessagesContext, ALERT_TYPES } from '../../generic/user-messages';
import { postCourseEnrollment } from './data/api';
// Separated into its own file to avoid a circular dependency inside this directory
function useEnrollClickHandler(courseId, orgId, successText) {
const [loading, setLoading] = useState(false);
const { addFlash } = useContext(UserMessagesContext);
const enrollClickHandler = useCallback(() => {
setLoading(true);
postCourseEnrollment(courseId).then(() => {
addFlash({
dismissible: true,
flash: true,
text: successText,
type: ALERT_TYPES.SUCCESS,
topic: 'course',
});
setLoading(false);
sendTrackEvent('edx.bi.user.course-home.enrollment', {
org_key: orgId,
courserun_key: courseId,
});
global.location.reload();
});
}, [courseId]);
return { enrollClickHandler, loading };
}
export default useEnrollClickHandler;

View File

@@ -1,15 +1,12 @@
/* eslint-disable import/prefer-default-export */
import React, {
useContext, useState, useCallback, useMemo,
useContext, useMemo,
} from 'react';
import { sendTrackEvent } from '@edx/frontend-platform/analytics';
import { AppContext } from '@edx/frontend-platform/react';
import { UserMessagesContext, ALERT_TYPES, useAlert } from '../../generic/user-messages';
import { useAlert } from '../../generic/user-messages';
import { useModel } from '../../generic/model-store';
import { postCourseEnrollment } from './data/api';
const EnrollmentAlert = React.lazy(() => import('./EnrollmentAlert'));
export function useEnrollmentAlert(courseId) {
@@ -40,28 +37,3 @@ export function useEnrollmentAlert(courseId) {
return { clientEnrollmentAlert: EnrollmentAlert };
}
export function useEnrollClickHandler(courseId, orgId, successText) {
const [loading, setLoading] = useState(false);
const { addFlash } = useContext(UserMessagesContext);
const enrollClickHandler = useCallback(() => {
setLoading(true);
postCourseEnrollment(courseId).then(() => {
addFlash({
dismissible: true,
flash: true,
text: successText,
type: ALERT_TYPES.SUCCESS,
topic: 'course',
});
setLoading(false);
sendTrackEvent('edx.bi.user.course-home.enrollment', {
org_key: orgId,
courserun_key: courseId,
});
global.location.reload();
});
}, [courseId]);
return { enrollClickHandler, loading };
}

View File

@@ -18,9 +18,7 @@ function AuthenticatedUserDropdown({ enterpriseLearnerPortalLink, intl, username
);
if (enterpriseLearnerPortalLink && Object.keys(enterpriseLearnerPortalLink).length > 0) {
dashboardMenuItem = (
<Dropdown.Item
href={enterpriseLearnerPortalLink.href}
>
<Dropdown.Item href={enterpriseLearnerPortalLink.href}>
{enterpriseLearnerPortalLink.content}
</Dropdown.Item>
);
@@ -62,13 +60,17 @@ function AuthenticatedUserDropdown({ enterpriseLearnerPortalLink, intl, username
}
AuthenticatedUserDropdown.propTypes = {
enterpriseLearnerPortalLink: PropTypes.string,
intl: intlShape.isRequired,
username: PropTypes.string.isRequired,
enterpriseLearnerPortalLink: PropTypes.shape({
type: PropTypes.string,
href: PropTypes.string,
content: PropTypes.string,
}),
};
AuthenticatedUserDropdown.defaultProps = {
enterpriseLearnerPortalLink: '',
enterpriseLearnerPortalLink: undefined,
};
export default injectIntl(AuthenticatedUserDropdown);

View File

@@ -1,6 +1,6 @@
import React, { useContext } from 'react';
import PropTypes from 'prop-types';
import { useEnterpriseConfig } from '@edx/frontend-enterprise';
import { useEnterpriseConfig } from '@edx/frontend-enterprise-utils';
import { getConfig } from '@edx/frontend-platform';
import { injectIntl, intlShape } from '@edx/frontend-platform/i18n';
import { AppContext } from '@edx/frontend-platform/react';

View File

@@ -11,7 +11,7 @@ import enrollmentMessages from '../../../../alerts/enrollment-alert/messages';
import genericMessages from '../../../../generic/messages';
import messages from './messages';
import outlineMessages from '../../messages';
import { useEnrollClickHandler } from '../../../../alerts/enrollment-alert/hooks';
import useEnrollClickHandler from '../../../../alerts/enrollment-alert/clickHook';
import { useModel } from '../../../../generic/model-store';
function PrivateCourseAlert({ intl, payload }) {

View File

@@ -83,17 +83,21 @@ function CertificateStatus({ intl }) {
const idVerificationSupportLink = <IdVerificationSupportLink />;
const profileLink = <ProfileLink />;
// Some learners have a valid ("downloadable") certificate without being in a passing
// state (e.g. learners who have been added to a course's allowlist), so we need to
// skip grade validation for these learners
const certIsDownloadable = certStatus === 'downloadable';
if (mode === COURSE_EXIT_MODES.disabled) {
certEventName = 'certificate_status_disabled';
} else if (mode === COURSE_EXIT_MODES.nonPassing) {
} else if (mode === COURSE_EXIT_MODES.nonPassing && !certIsDownloadable) {
certCase = 'notPassing';
certEventName = 'not_passing';
body = intl.formatMessage(messages[`${certCase}Body`]);
} else if (mode === COURSE_EXIT_MODES.inProgress) {
} else if (mode === COURSE_EXIT_MODES.inProgress && !certIsDownloadable) {
certCase = 'inProgress';
certEventName = 'has_scheduled_content';
body = intl.formatMessage(messages[`${certCase}Body`]);
} else if (mode === COURSE_EXIT_MODES.celebration) {
} else if (mode === COURSE_EXIT_MODES.celebration || certIsDownloadable) {
switch (certStatus) {
case 'requesting':
certCase = 'requestable';

View File

@@ -64,7 +64,7 @@ function SubsectionTitleCell({ intl, subsection }) {
{displayName}
</a>
) : (
<span className="small">{displayName}</span>
<span className="greyed-out small">{displayName}</span>
)}
</span>
</Row>

View File

@@ -244,7 +244,7 @@ function Sequence({
if (sequenceStatus === 'loaded') {
return (
<div>
<SequenceExamWrapper sequence={sequence} courseId={courseId}>
<SequenceExamWrapper sequence={sequence} courseId={courseId} isStaff={course.isStaff}>
{defaultContent}
</SequenceExamWrapper>
<CourseLicense license={course.license || undefined} />

View File

@@ -0,0 +1,423 @@
{
"consumer": {
"name": "frontend-app-learning"
},
"provider": {
"name": "lms"
},
"interactions": [
{
"description": "a request to get course blocks",
"providerState": "Blocks data exists for course_id course-v1:edX+DemoX+Demo_Course",
"request": {
"method": "GET",
"path": "/api/courses/v2/blocks/",
"query": "course_id=course-v1%3AedX%2BDemoX%2BDemo_Course&username=Mock+User&depth=3&requested_fields=children%2Ceffort_activities%2Ceffort_time%2Cshow_gated_sections%2Cgraded%2Cspecial_exam_info%2Chas_scheduled_content"
},
"response": {
"status": 200,
"headers": {
},
"body": {
"root": "block-v1:edX+DemoX+Demo_Course+type@course+block@course",
"blocks": {
"block-v1:edX+DemoX+Demo_Course+type@course+block@course": {
"id": "block-v1:edX+DemoX+Demo_Course+type@course+block@course",
"block_id": "course",
"lms_web_url": "/courses/course-v1:edX+DemoX+Demo_Course/jump_to/block-v1:edX+DemoX+Demo_Course+type@course+block@course",
"legacy_web_url": "/courses/course-v1:edX+DemoX+Demo_Course/jump_to/block-v1:edX+DemoX+Demo_Course+type@course+block@course?experience=legacy",
"student_view_url": "/xblock/block-v1:edX+DemoX+Demo_Course+type@course+block@course",
"type": "course",
"display_name": "Demonstration Course"
}
}
},
"matchingRules": {
"$.body.root": {
"match": "type"
},
"$.body.blocks": {
"match": "type"
}
}
}
},
{
"description": "a request to get course metadata",
"providerState": "course metadata exists for course_id course-v1:edX+DemoX+Demo_Course",
"request": {
"method": "GET",
"path": "/api/courseware/course/course-v1:edX+DemoX+Demo_Course",
"query": "browser_timezone=Asia%2FKarachi"
},
"response": {
"status": 200,
"headers": {
},
"body": {
"access_expiration": {
"expiration_date": "2013-02-05T05:00:00Z",
"masquerading_expired_course": false,
"upgrade_deadline": "2013-02-05T05:00:00Z",
"upgrade_url": "link"
},
"can_show_upgrade_sock": false,
"content_type_gating_enabled": false,
"end": "2013-02-05T05:00:00Z",
"enrollment": {
"mode": "audit",
"is_active": true
},
"enrollment_start": "2013-02-05T05:00:00Z",
"enrollment_end": "2013-02-05T05:00:00Z",
"id": "course-v1:edX+DemoX+Demo_Course",
"license": "all-rights-reserved",
"name": "Demonstration Course",
"number": "DemoX",
"offer": {
"code": "code",
"expiration_date": "2013-02-05T05:00:00Z",
"original_price": "$99",
"discounted_price": "$99",
"percentage": 50,
"upgrade_url": "url"
},
"org": "edX",
"related_programs": null,
"short_description": "",
"start": "2013-02-05T05:00:00Z",
"tabs": [
{
"title": "Course",
"slug": "courseware",
"priority": 0,
"type": "courseware",
"url": "http://localhost:2000/course/course-v1:edX+DemoX+Demo_Course/home"
}
],
"user_timezone": null,
"verified_mode": {
"access_expiration_date": null,
"currency": "USD",
"currency_symbol": "$",
"price": 149,
"sku": "8CF08E5",
"upgrade_url": "http://localhost:18130/basket/add/?sku=8CF08E5"
},
"show_calculator": false,
"original_user_is_staff": true,
"can_view_legacy_courseware": true,
"is_staff": true,
"course_access": {
"has_access": true,
"error_code": null,
"developer_message": null,
"user_message": null,
"additional_context_user_message": null,
"user_fragment": null
},
"notes": {
"enabled": false,
"visible": true
},
"marketing_url": null,
"celebrations": {
"irst_section": false,
"streak_length_to_celebrate": null,
"streak_discount_experiment_enabled": false
},
"user_has_passing_grade": false,
"course_exit_page_is_active": false,
"certificate_data": {
"cert_status": "audit_passing",
"cert_web_view_url": null,
"download_url": null,
"certificate_available_date": null
},
"verify_identity_url": null,
"verification_status": "none",
"linkedin_add_to_profile_url": null,
"is_mfe_special_exams_enabled": false,
"is_mfe_proctored_exams_enabled": false,
"user_needs_integrity_signature": false
},
"matchingRules": {
"$.body.access_expiration.expiration_date": {
"match": "regex",
"regex": "^(?:[1-9]\\d{3}-(?:(?:0[1-9]|1[0-2])-(?:0[1-9]|1\\d|2[0-8])|(?:0[13-9]|1[0-2])-(?:29|30)|(?:0[13578]|1[02])-31)|(?:[1-9]\\d(?:0[48]|[2468][048]|[13579][26])|(?:[2468][048]|[13579][26])00)-02-29)T(?:[01]\\d|2[0-3]):[0-5]\\d:[0-5]\\d(?:Z|[+-][01]\\d:[0-5]\\d)$"
},
"$.body.access_expiration.masquerading_expired_course": {
"match": "type"
},
"$.body.access_expiration.upgrade_deadline": {
"match": "regex",
"regex": "^(?:[1-9]\\d{3}-(?:(?:0[1-9]|1[0-2])-(?:0[1-9]|1\\d|2[0-8])|(?:0[13-9]|1[0-2])-(?:29|30)|(?:0[13578]|1[02])-31)|(?:[1-9]\\d(?:0[48]|[2468][048]|[13579][26])|(?:[2468][048]|[13579][26])00)-02-29)T(?:[01]\\d|2[0-3]):[0-5]\\d:[0-5]\\d(?:Z|[+-][01]\\d:[0-5]\\d)$"
},
"$.body.access_expiration.upgrade_url": {
"match": "type"
},
"$.body.can_show_upgrade_sock": {
"match": "type"
},
"$.body.content_type_gating_enabled": {
"match": "type"
},
"$.body.end": {
"match": "regex",
"regex": "^(?:[1-9]\\d{3}-(?:(?:0[1-9]|1[0-2])-(?:0[1-9]|1\\d|2[0-8])|(?:0[13-9]|1[0-2])-(?:29|30)|(?:0[13578]|1[02])-31)|(?:[1-9]\\d(?:0[48]|[2468][048]|[13579][26])|(?:[2468][048]|[13579][26])00)-02-29)T(?:[01]\\d|2[0-3]):[0-5]\\d:[0-5]\\d(?:Z|[+-][01]\\d:[0-5]\\d)$"
},
"$.body.enrollment.mode": {
"match": "regex",
"regex": "^(audit|verified)$"
},
"$.body.enrollment.is_active": {
"match": "type"
},
"$.body.enrollment_start": {
"match": "regex",
"regex": "^(?:[1-9]\\d{3}-(?:(?:0[1-9]|1[0-2])-(?:0[1-9]|1\\d|2[0-8])|(?:0[13-9]|1[0-2])-(?:29|30)|(?:0[13578]|1[02])-31)|(?:[1-9]\\d(?:0[48]|[2468][048]|[13579][26])|(?:[2468][048]|[13579][26])00)-02-29)T(?:[01]\\d|2[0-3]):[0-5]\\d:[0-5]\\d(?:Z|[+-][01]\\d:[0-5]\\d)$"
},
"$.body.enrollment_end": {
"match": "regex",
"regex": "^(?:[1-9]\\d{3}-(?:(?:0[1-9]|1[0-2])-(?:0[1-9]|1\\d|2[0-8])|(?:0[13-9]|1[0-2])-(?:29|30)|(?:0[13578]|1[02])-31)|(?:[1-9]\\d(?:0[48]|[2468][048]|[13579][26])|(?:[2468][048]|[13579][26])00)-02-29)T(?:[01]\\d|2[0-3]):[0-5]\\d:[0-5]\\d(?:Z|[+-][01]\\d:[0-5]\\d)$"
},
"$.body.id": {
"match": "regex",
"regex": "[\\w\\-~.:]"
},
"$.body.license": {
"match": "type"
},
"$.body.name": {
"match": "type"
},
"$.body.number": {
"match": "type"
},
"$.body.offer.code": {
"match": "type"
},
"$.body.offer.expiration_date": {
"match": "regex",
"regex": "^(?:[1-9]\\d{3}-(?:(?:0[1-9]|1[0-2])-(?:0[1-9]|1\\d|2[0-8])|(?:0[13-9]|1[0-2])-(?:29|30)|(?:0[13578]|1[02])-31)|(?:[1-9]\\d(?:0[48]|[2468][048]|[13579][26])|(?:[2468][048]|[13579][26])00)-02-29)T(?:[01]\\d|2[0-3]):[0-5]\\d:[0-5]\\d(?:Z|[+-][01]\\d:[0-5]\\d)$"
},
"$.body.offer.original_price": {
"match": "type"
},
"$.body.offer.discounted_price": {
"match": "type"
},
"$.body.offer.percentage": {
"match": "type"
},
"$.body.offer.upgrade_url": {
"match": "type"
},
"$.body.org": {
"match": "type"
},
"$.body.short_description": {
"match": "type"
},
"$.body.start": {
"match": "regex",
"regex": "^(?:[1-9]\\d{3}-(?:(?:0[1-9]|1[0-2])-(?:0[1-9]|1\\d|2[0-8])|(?:0[13-9]|1[0-2])-(?:29|30)|(?:0[13578]|1[02])-31)|(?:[1-9]\\d(?:0[48]|[2468][048]|[13579][26])|(?:[2468][048]|[13579][26])00)-02-29)T(?:[01]\\d|2[0-3]):[0-5]\\d:[0-5]\\d(?:Z|[+-][01]\\d:[0-5]\\d)$"
},
"$.body.tabs": {
"min": 1
},
"$.body.tabs[*].*": {
"match": "type"
},
"$.body.verified_mode": {
"match": "type"
},
"$.body.show_calculator": {
"match": "type"
},
"$.body.original_user_is_staff": {
"match": "type"
},
"$.body.can_view_legacy_courseware": {
"match": "type"
},
"$.body.is_staff": {
"match": "type"
},
"$.body.course_access": {
"match": "type"
},
"$.body.course_access.has_access": {
"match": "type"
},
"$.body.notes.enabled": {
"match": "type"
},
"$.body.notes.visible": {
"match": "type"
},
"$.body.celebrations.irst_section": {
"match": "type"
},
"$.body.celebrations.streak_discount_experiment_enabled": {
"match": "type"
},
"$.body.user_has_passing_grade": {
"match": "type"
},
"$.body.course_exit_page_is_active": {
"match": "type"
},
"$.body.certificate_data.cert_status": {
"match": "type"
},
"$.body.verification_status": {
"match": "type"
},
"$.body.is_mfe_special_exams_enabled": {
"match": "type"
},
"$.body.is_mfe_proctored_exams_enabled": {
"match": "type"
},
"$.body.user_needs_integrity_signature": {
"match": "type"
}
}
}
},
{
"description": "a request to get sequence metadata",
"providerState": "sequence metadata data exists for sequence_id block-v1:edX+DemoX+Demo_Course+type@sequential+block@basic_questions",
"request": {
"method": "GET",
"path": "/api/courseware/sequence/block-v1:edX+DemoX+Demo_Course+type@sequential+block@basic_questions"
},
"response": {
"status": 200,
"headers": {
},
"body": {
"items": [
{
"content": "",
"page_title": "Pointing on a Picture",
"type": "problem",
"id": "block-v1:edX+DemoX+Demo_Course+type@vertical+block@2152d4a4aadc4cb0af5256394a3d1fc7",
"bookmarked": false,
"path": "Example Week 1: Getting Started > Homework - Question Styles > Pointing on a Picture",
"graded": true,
"contains_content_type_gated_content": false,
"href": ""
}
],
"item_id": "block-v1:edX+DemoX+Demo_Course+type@sequential+block@basic_questions",
"is_time_limited": false,
"is_proctored": false,
"position": null,
"tag": "sequential",
"banner_text": null,
"save_position": false,
"show_completion": false,
"gated_content": {
"prereq_id": null,
"prereq_url": null,
"prereq_section_name": null,
"gated": false,
"gated_section_name": "Homework - Question Styles"
},
"display_name": "Homework - Question Styles",
"format": "Homework"
},
"matchingRules": {
"$.body.items": {
"min": 1
},
"$.body.items[*].*": {
"match": "type"
},
"$.body.item_id": {
"match": "type"
},
"$.body.is_time_limited": {
"match": "type"
},
"$.body.is_proctored": {
"match": "type"
},
"$.body.tag": {
"match": "type"
},
"$.body.save_position": {
"match": "type"
},
"$.body.show_completion": {
"match": "type"
},
"$.body.gated_content": {
"match": "type"
},
"$.body.display_name": {
"match": "type"
},
"$.body.format": {
"match": "type"
}
}
}
},
{
"description": "a request to set sequence position against activeUnitIndex",
"providerState": "sequence position data exists for course_id course-v1:edX+DemoX+Demo_Course, sequence_id block-v1:edX+DemoX+Demo_Course+type@sequential+block@basic_questions and activeUnitIndex 0",
"request": {
"method": "POST",
"path": "/courses/course-v1:edX+DemoX+Demo_Course/xblock/block-v1:edX+DemoX+Demo_Course+type@sequential+block@basic_questions/handler/goto_position",
"body": {
"position": 1
}
},
"response": {
"status": 200,
"headers": {
},
"body": {
"success": true
},
"matchingRules": {
"$.body.success": {
"match": "type"
}
}
}
},
{
"description": "a request to get completion block",
"providerState": "completion block data exists for course_id course-v1:edX+DemoX+Demo_Course, sequence_id block-v1:edX+DemoX+Demo_Course+type@sequential+block@basic_questions and usageId block-v1:edX+DemoX+Demo_Course+type@vertical+block@47dbd5f836544e61877a483c0b75606c",
"request": {
"method": "POST",
"path": "/courses/course-v1:edX+DemoX+Demo_Course/xblock/block-v1:edX+DemoX+Demo_Course+type@sequential+block@basic_questions/handler/get_completion",
"body": {
"usage_key": "block-v1:edX+DemoX+Demo_Course+type@vertical+block@47dbd5f836544e61877a483c0b75606c"
}
},
"response": {
"status": 200,
"headers": {
},
"body": {
"complete": true
},
"matchingRules": {
"$.body.complete": {
"match": "type"
}
}
}
}
],
"metadata": {
"pactSpecification": {
"version": "2.0.0"
}
}
}

View File

@@ -0,0 +1,437 @@
import { Pact, Matchers } from '@pact-foundation/pact';
import path from 'path';
import { mergeConfig, getConfig } from '@edx/frontend-platform';
import { getAuthenticatedUser } from '@edx/frontend-platform/auth';
import {
getCourseBlocks, getCourseMetadata, getSequenceMetadata, postSequencePosition, getBlockCompletion,
} from '../api';
import { initializeMockApp } from '../../../setupTest';
const {
somethingLike: like, term, boolean, string, eachLike, integer,
} = Matchers;
const provider = new Pact({
consumer: 'frontend-app-learning',
provider: 'lms',
log: path.resolve(process.cwd(), 'src/courseware/data/pact-tests/logs', 'pact.log'),
dir: path.resolve(process.cwd(), 'src/courseware/data/pact-tests'),
logLevel: 'DEBUG',
cors: true,
});
describe('Courseware Service', () => {
const courseId = 'course-v1:edX+DemoX+Demo_Course';
const sequenceId = 'block-v1:edX+DemoX+Demo_Course+type@sequential+block@basic_questions';
const usageId = 'block-v1:edX+DemoX+Demo_Course+type@vertical+block@47dbd5f836544e61877a483c0b75606c';
const dateRegex = '^(?:[1-9]\\d{3}-(?:(?:0[1-9]|1[0-2])-(?:0[1-9]|1\\d|2[0-8])|(?:0[13-9]|1[0-2])-(?:29|30)|(?:0[13578]|1[02])-31)|(?:[1-9]\\d(?:0[48]|[2468][048]|[13579][26])|(?:[2468][048]|[13579][26])00)-02-29)T(?:[01]\\d|2[0-3]):[0-5]\\d:[0-5]\\d(?:Z|[+-][01]\\d:[0-5]\\d)$';
const opaqueKeysRegex = '[\\w\\-~.:]';
let authenticatedUser;
beforeAll(async () => {
initializeMockApp();
await provider
.setup()
.then((options) => mergeConfig({
LMS_BASE_URL: `http://localhost:${options.port}`,
}, 'Custom app config for pact tests'));
authenticatedUser = getAuthenticatedUser();
});
afterEach(() => provider.verify());
afterAll(() => provider.finalize());
describe('When a request to get course blocks is made', () => {
it('returns normalized course blocks', async () => {
await provider.addInteraction({
state: `Blocks data exists for course_id ${courseId}`,
uponReceiving: 'a request to get course blocks',
withRequest: {
method: 'GET',
path: '/api/courses/v2/blocks/',
query: {
course_id: courseId,
username: authenticatedUser ? authenticatedUser.username : '',
depth: '3',
requested_fields: 'children,effort_activities,effort_time,show_gated_sections,graded,special_exam_info,has_scheduled_content',
},
},
willRespondWith: {
status: 200,
body:
{
root: string('block-v1:edX+DemoX+Demo_Course+type@course+block@course'),
blocks: like({
'block-v1:edX+DemoX+Demo_Course+type@course+block@course': {
id: 'block-v1:edX+DemoX+Demo_Course+type@course+block@course',
block_id: 'course',
lms_web_url: '/courses/course-v1:edX+DemoX+Demo_Course/jump_to/block-v1:edX+DemoX+Demo_Course+type@course+block@course',
legacy_web_url: '/courses/course-v1:edX+DemoX+Demo_Course/jump_to/block-v1:edX+DemoX+Demo_Course+type@course+block@course?experience=legacy',
student_view_url: '/xblock/block-v1:edX+DemoX+Demo_Course+type@course+block@course',
type: 'course',
display_name: 'Demonstration Course',
},
}),
},
},
});
const normalizedCourseBlock = {
'block-v1:edX+DemoX+Demo_Course+type@course+block@course': {
id: 'course-v1:edX+DemoX+Demo_Course',
title: 'Demonstration Course',
sectionIds: [],
hasScheduledContent: false,
},
};
const response = await getCourseBlocks(courseId);
expect(response).toBeTruthy();
expect(response.courses).toEqual(normalizedCourseBlock);
expect(response.sections).toEqual({});
expect(response.sequences).toEqual({});
expect(response.units).toEqual({});
});
});
describe('When a request to get course metadata is made', () => {
it('returns normalized course metadata', async () => {
await provider.addInteraction({
state: `course metadata exists for course_id ${courseId}`,
uponReceiving: 'a request to get course metadata',
withRequest: {
method: 'GET',
path: `/api/courseware/course/${courseId}`,
query: {
browser_timezone: Intl.DateTimeFormat().resolvedOptions().timeZone,
},
},
willRespondWith: {
status: 200,
body:
{
access_expiration: {
expiration_date: term({
generate: '2013-02-05T05:00:00Z',
matcher: dateRegex,
}),
masquerading_expired_course: boolean(false),
upgrade_deadline: term({
generate: '2013-02-05T05:00:00Z',
matcher: dateRegex,
}),
upgrade_url: string('link'),
},
can_show_upgrade_sock: boolean(false),
content_type_gating_enabled: boolean(false),
end: term({
generate: '2013-02-05T05:00:00Z',
matcher: dateRegex,
}),
enrollment: {
mode: term({
generate: 'audit',
matcher: '^(audit|verified)$',
}),
is_active: boolean(true),
},
enrollment_start: term({
generate: '2013-02-05T05:00:00Z',
matcher: dateRegex,
}),
enrollment_end: term({
generate: '2013-02-05T05:00:00Z',
matcher: dateRegex,
}),
id: term({
generate: 'course-v1:edX+DemoX+Demo_Course',
matcher: opaqueKeysRegex,
}),
license: string('all-rights-reserved'),
name: like('Demonstration Course'),
number: like('DemoX'),
offer: {
code: string('code'),
expiration_date: term({
generate: '2013-02-05T05:00:00Z',
matcher: dateRegex,
}),
original_price: string('$99'),
discounted_price: string('$99'),
percentage: integer(50),
upgrade_url: string('url'),
},
org: like('edX'),
related_programs: null,
short_description: like(''),
start: term({
generate: '2013-02-05T05:00:00Z',
matcher: dateRegex,
}),
tabs: eachLike({
title: 'Course', slug: 'courseware', priority: 0, type: 'courseware', url: `${getConfig().BASE_URL}/course/course-v1:edX+DemoX+Demo_Course/home`,
}),
user_timezone: null,
verified_mode: like({
access_expiration_date: null,
currency: 'USD',
currency_symbol: '$',
price: 149,
sku: '8CF08E5',
upgrade_url: `${getConfig().ECOMMERCE_BASE_URL}/basket/add/?sku=8CF08E5`,
}),
show_calculator: boolean(false),
original_user_is_staff: boolean(true),
can_view_legacy_courseware: boolean(true),
is_staff: boolean(true),
course_access: like({
has_access: boolean(true),
error_code: null,
developer_message: null,
user_message: null,
additional_context_user_message: null,
user_fragment: null,
}),
notes: { enabled: boolean(false), visible: boolean(true) },
marketing_url: null,
celebrations: {
irst_section: boolean(false),
streak_length_to_celebrate: null,
streak_discount_experiment_enabled: boolean(false),
},
user_has_passing_grade: boolean(false),
course_exit_page_is_active: boolean(false),
certificate_data: {
cert_status: string('audit_passing'), cert_web_view_url: null, download_url: null, certificate_available_date: null,
},
verify_identity_url: null,
verification_status: string('none'),
linkedin_add_to_profile_url: null,
is_mfe_special_exams_enabled: boolean(false),
is_mfe_proctored_exams_enabled: boolean(false),
user_needs_integrity_signature: boolean(false),
},
},
});
const normalizedCourseMetadata = {
accessExpiration: {
expirationDate: '2013-02-05T05:00:00Z',
masqueradingExpiredCourse: false,
upgradeDeadline: '2013-02-05T05:00:00Z',
upgradeUrl: 'link',
},
canShowUpgradeSock: false,
contentTypeGatingEnabled: false,
id: 'course-v1:edX+DemoX+Demo_Course',
title: 'Demonstration Course',
number: 'DemoX',
offer: {
code: 'code',
discountedPrice: '$99',
expirationDate: '2013-02-05T05:00:00Z',
originalPrice: '$99',
percentage: 50,
upgradeUrl: 'url',
},
org: 'edX',
enrollmentStart: '2013-02-05T05:00:00Z',
enrollmentEnd: '2013-02-05T05:00:00Z',
end: '2013-02-05T05:00:00Z',
start: '2013-02-05T05:00:00Z',
enrollmentMode: 'audit',
isEnrolled: true,
courseAccess: {
hasAccess: true,
errorCode: null,
developerMessage: null,
userMessage: null,
additionalContextUserMessage: null,
userFragment: null,
},
canViewLegacyCourseware: true,
originalUserIsStaff: true,
isStaff: true,
license: 'all-rights-reserved',
verifiedMode: {
accessExpirationDate: null,
currency: 'USD',
currencySymbol: '$',
price: 149,
sku: '8CF08E5',
upgradeUrl: `${getConfig().ECOMMERCE_BASE_URL}/basket/add/?sku=8CF08E5`,
},
tabs: [
{
title: 'Course',
slug: 'courseware',
priority: 0,
type: 'courseware',
url: `${getConfig().BASE_URL}/course/course-v1:edX+DemoX+Demo_Course/home`,
},
],
userTimezone: null,
showCalculator: false,
notes: { enabled: false, visible: true },
marketingUrl: null,
celebrations: {
irstSection: false,
streakLengthToCelebrate: null,
streakDiscountExperimentEnabled: false,
},
userHasPassingGrade: false,
courseExitPageIsActive: false,
certificateData: {
certStatus: 'audit_passing',
certWebViewUrl: null,
downloadUrl: null,
certificateAvailableDate: null,
},
timeOffsetMillis: 0,
verifyIdentityUrl: null,
verificationStatus: 'none',
linkedinAddToProfileUrl: null,
relatedPrograms: null,
userNeedsIntegritySignature: false,
specialExamsEnabledWaffleFlag: false,
proctoredExamsEnabledWaffleFlag: false,
isMasquerading: false,
};
const response = await getCourseMetadata(courseId);
expect(response).toBeTruthy();
expect(response).toEqual(normalizedCourseMetadata);
});
});
describe('When a request to get sequence metadata is made', () => {
it('returns normalized sequence metadata ', async () => {
await provider.addInteraction({
state: `sequence metadata data exists for sequence_id ${sequenceId}`,
uponReceiving: 'a request to get sequence metadata',
withRequest: {
method: 'GET',
path: `/api/courseware/sequence/${sequenceId}`,
},
willRespondWith: {
status: 200,
body:
{
items: eachLike({
content: '',
page_title: 'Pointing on a Picture',
type: 'problem',
id: 'block-v1:edX+DemoX+Demo_Course+type@vertical+block@2152d4a4aadc4cb0af5256394a3d1fc7',
bookmarked: false,
path: 'Example Week 1: Getting Started > Homework - Question Styles > Pointing on a Picture',
graded: true,
contains_content_type_gated_content: false,
href: '',
}),
item_id: string('block-v1:edX+DemoX+Demo_Course+type@sequential+block@basic_questions'),
is_time_limited: boolean(false),
is_proctored: boolean(false),
position: null,
tag: boolean('sequential'),
banner_text: null,
save_position: boolean(false),
show_completion: boolean(false),
gated_content: like({
prereq_id: null,
prereq_url: null,
prereq_section_name: null,
gated: false,
gated_section_name: 'Homework - Question Styles',
}),
display_name: boolean('Homework - Question Styles'),
format: boolean('Homework'),
},
},
});
const normalizedSequenceMetadata = {
sequence: {
id: 'block-v1:edX+DemoX+Demo_Course+type@sequential+block@basic_questions',
blockType: 'sequential',
unitIds: [
'block-v1:edX+DemoX+Demo_Course+type@vertical+block@2152d4a4aadc4cb0af5256394a3d1fc7',
],
bannerText: null,
format: 'Homework',
title: 'Homework - Question Styles',
gatedContent: {
prereqId: null,
prereqUrl: null,
prereqSectionName: null,
gated: false,
gatedSectionName: 'Homework - Question Styles',
},
isTimeLimited: false,
isProctored: false,
activeUnitIndex: 0,
saveUnitPosition: false,
showCompletion: false,
allowProctoringOptOut: undefined,
},
units: [
{
id: 'block-v1:edX+DemoX+Demo_Course+type@vertical+block@2152d4a4aadc4cb0af5256394a3d1fc7',
sequenceId: 'block-v1:edX+DemoX+Demo_Course+type@sequential+block@basic_questions',
bookmarked: false,
complete: undefined,
title: 'Pointing on a Picture',
contentType: 'problem',
graded: true,
containsContentTypeGatedContent: false,
},
],
};
const response = await getSequenceMetadata(sequenceId);
expect(response).toBeTruthy();
expect(response).toEqual(normalizedSequenceMetadata);
});
});
describe('When a request to set sequence position against Unit Index is made', () => {
it('returns if the request was success or failure', async () => {
await provider.addInteraction({
state: `sequence position data exists for course_id ${courseId}, sequence_id ${sequenceId} and activeUnitIndex 0`,
uponReceiving: 'a request to set sequence position against activeUnitIndex',
withRequest: {
method: 'POST',
path: `/courses/${courseId}/xblock/${sequenceId}/handler/goto_position`,
body: { position: 1 }, // Position is 1-indexed on the provider side and 0-indexed in the consumer side.
},
willRespondWith: {
status: 200,
body:
{
success: boolean(true),
},
},
});
const response = await postSequencePosition(courseId, sequenceId, 0);
expect(response).toBeTruthy();
expect(response).toEqual({ success: true });
});
});
describe('When a request to get completion block is made', () => {
it('returns the completion status', async () => {
await provider.addInteraction({
state: `completion block data exists for course_id ${courseId}, sequence_id ${sequenceId} and usageId ${usageId}`,
uponReceiving: 'a request to get completion block',
withRequest: {
method: 'POST',
path: `/courses/${courseId}/xblock/${sequenceId}/handler/get_completion`,
body: { usage_key: usageId },
},
willRespondWith: {
status: 200,
body:
{
complete: boolean(true),
},
},
});
const response = await getBlockCompletion(courseId, sequenceId, usageId);
expect(response).toBeTruthy();
expect(response).toEqual(true);
});
});
});

View File

@@ -32,6 +32,8 @@
"learning.outline.alert.cert.when": "This course ends on {courseEndDateFormatted}. Final grades and certificates are\n scheduled to be available after {certificateAvailableDate}.",
"cert.alert.earned.unavailable.header": "Your grade and certificate will be ready soon!",
"cert.alert.earned.ready.header": "Congratulations! Your certificate is ready.",
"cert.alert.notPassing.header": "You are not eligible for a certificate",
"cert.alert.notPassing.button": "View grades",
"learning.outline.alert.end.short": "ينتهي هذا المساق في غضون {timeRemaining}في {courseEndTime}.",
"learning.outline.alert.end.long": "يبدأ المساق في {timeRemaining} بتاريخ {courseStartDate}.",
"learning.outline.alert.start.short": "يبدأ المساق في غضون {timeRemaining} في {courseStartDate}.",

View File

@@ -32,6 +32,8 @@
"learning.outline.alert.cert.when": "This course ends on {courseEndDateFormatted}. Final grades and certificates are\n scheduled to be available after {certificateAvailableDate}.",
"cert.alert.earned.unavailable.header": "Your grade and certificate will be ready soon!",
"cert.alert.earned.ready.header": "Congratulations! Your certificate is ready.",
"cert.alert.notPassing.header": "You are not eligible for a certificate",
"cert.alert.notPassing.button": "View grades",
"learning.outline.alert.end.short": "Este curso acaba en {timeRemaining} a la/s {courseEndTime}.",
"learning.outline.alert.end.long": "El curso comienza en {timeRemaining} el {courseStartDate}.",
"learning.outline.alert.start.short": "El curso comienza en {timeRemaining} a la/s {courseStartTime}.",

View File

@@ -32,6 +32,8 @@
"learning.outline.alert.cert.when": "This course ends on {courseEndDateFormatted}. Final grades and certificates are\n scheduled to be available after {certificateAvailableDate}.",
"cert.alert.earned.unavailable.header": "Your grade and certificate will be ready soon!",
"cert.alert.earned.ready.header": "Congratulations! Your certificate is ready.",
"cert.alert.notPassing.header": "You are not eligible for a certificate",
"cert.alert.notPassing.button": "View grades",
"learning.outline.alert.end.short": "This course is ending {timeRemaining} at {courseEndTime}.",
"learning.outline.alert.end.long": "Course starts {timeRemaining} on {courseStartDate}.",
"learning.outline.alert.start.short": "Course starts {timeRemaining} at {courseStartTime}.",

View File

@@ -32,6 +32,8 @@
"learning.outline.alert.cert.when": "This course ends on {courseEndDateFormatted}. Final grades and certificates are\n scheduled to be available after {certificateAvailableDate}.",
"cert.alert.earned.unavailable.header": "Your grade and certificate will be ready soon!",
"cert.alert.earned.ready.header": "Congratulations! Your certificate is ready.",
"cert.alert.notPassing.header": "You are not eligible for a certificate",
"cert.alert.notPassing.button": "View grades",
"learning.outline.alert.end.short": "This course is ending {timeRemaining} at {courseEndTime}.",
"learning.outline.alert.end.long": "Course starts {timeRemaining} on {courseStartDate}.",
"learning.outline.alert.start.short": "Course starts {timeRemaining} at {courseStartTime}.",