Compare commits

...

53 Commits

Author SHA1 Message Date
Ihor Romaniuk
5d8dccda85 fix: save scroll position on exit from video xblock fullscreen mode (#1077)
* fix: save scroll position on exit from video xblock fullscreen mode

* fix: update tests
2023-06-21 13:45:52 -04:00
Ghassan Maslamani
9ffc2c0b84 fix: sync LMS_BASE_URL for bookmark API if changed
This change makes it possible to use the latest  LMS_BASE_API
  if it was changed because of dynamic config API, which is the
  default case of tutor.

  This changes closes openedx/wg-build-test-release/issues/270

   Fixes that are simlar to this
  - gradebook openedx/frontend-app-gradebook/pull/290
  - course authoring openedx/frontend-app-course-authoring/pull/389
2023-06-13 14:00:03 +01:00
Sagirov Eugeniy
c7ca1ba57e chore: update frontend-platform version to v4.2.0 2023-05-09 17:35:34 -03:00
Dmytro
d947d598b7 fix: show date on home page (#1052)
Correct display of the number of days before the
start of the course into the Course Home.
2023-05-09 11:17:40 -04:00
Asad Ali
4082b4f90e fix: fix links under contenttools (#1108) 2023-04-27 17:13:56 +05:00
lunyachek
fbaa41a8ae feat: Add border for active tab in course navigation at Live page 2023-03-22 10:37:01 -04:00
ihor-romaniuk
5dab08761c fix: fix alignment in the streak celebration modal 2023-03-01 10:01:18 -05:00
Eugene Dyudyunov
e499280e49 fix: first section celebration
Fix the first section celebration modal showing logic.

On Nutmeg+ it's shown only after the page reload or after going directly
to the second section from the course home. Going through the course
with the Next/Previous buttons has no effect (which worked on Maple).

Notes:
- the weekly goal has the same showing logic, but I assume that is
correct behavior so no changes are added for it in this commit.
- showing a celebration modal for the first section completion when
going directly to the first unit of the second section seems to be a bug
(reproduces on Maple too)
2023-02-15 11:49:04 -05:00
ihor-romaniuk
08c7d2d118 fix: text in goal modal 2023-01-17 10:20:56 -05:00
Ghassan Maslamani
17a102d5cf fix: fix tabs urls on progress tab
This fix does fixes the url links by getting them from the state
 simliar to how tabs navigation gets them.

 This would allow it work for PUBLIC_PATH is not '/', i.e. in
 tutor.

  This was reported in openedx/build-test-release-wg/issues/222
2022-12-07 19:13:41 +00:00
Abderraouf Mehdi Bouhali
77e3b17f03 fix(rtl): mirror new user tour modal background
Mirrors the background image used in the new user tour modal
as it obstructs the readability of the modal title when in RTL
2022-11-14 19:49:23 +00:00
Abderraouf Mehdi Bouhali
b1d51a0468 fix(rtl): use backslash to write fractions (grades) 2022-11-14 19:49:23 +00:00
Jenkins
13f884fc56 chore(i18n): update translations 2022-11-14 19:49:23 +00:00
Andrew Shultz
a701ea5e15 fix: display onboarding expired after expiration (#997)
Currently expiring soon is displayed 28 days before expiration
and forever afterwards. Adds an actual expired state for after.

Also clarifies the expring soon message which assumed other course,
that was not necessarily true.

Also updates the take action lines when you do not have valid
onboarding to make sure they appear for everything not currently valid
or in process, and updates the submitted process lines to not appear
for expired statuses.
2022-11-14 19:49:23 +00:00
Zachary Hancock
108fb314f5 feat: update special exams lib (#992) 2022-11-14 19:49:23 +00:00
Jenkins
75d2abe1a0 chore(i18n): update translations 2022-11-14 19:49:23 +00:00
Diana Olarte
d00961d85c feat: allow runtime configuration (#955)
Allows frontend-app-learning to be configured at
runtime using the LMS's new MFE Configuration API.

Part of openedx/frontend-wg#103
2022-11-14 19:49:23 +00:00
Abderraouf Mehdi Bouhali
96e2c88837 fix(rtl): force (%) symbol to follow text direction 2022-11-14 19:49:23 +00:00
Jenkins
14a4fae421 chore(i18n): update translations 2022-11-14 19:49:23 +00:00
Abderraouf Mehdi Bouhali
0d1f01628e fix(rtl): mirror position of grade rectangles in grade bar (#980)
Translates the rectangles for current and passing
grades when to appear on the right when in RTL.
2022-11-14 19:49:23 +00:00
Kshitij Sobti
95b285d371 feat: update discussion sidebar url to allow grouping by subsection (#968)
To enable grouping by subsection in the discussions MFE, this PR updates
the embed URL to the one that supports grouping.

ref: https://github.com/openedx/frontend-app-discussions/pull/281
2022-11-14 19:49:23 +00:00
Muhammad Abdullah Waheed
1892edaade refactor: updated renovate config to auto update minor and patch versions of edx dependencies (#963) 2022-09-28 10:47:31 -04:00
Sarina Canelake
381be9a26b Fix github url strings (org edx -> openedx) (#971)
* fix: fix github url strings (org edx -> openedx)

* fix: update path to .github workflows to read from openedx org
2022-09-14 10:01:48 -04:00
Shafqat Farhan
b3841ef446 feat: [VAN-1081] - Set weekly goal and start course events via query param (#972) 2022-09-13 11:00:09 +05:00
Jenkins
5a897e4ea1 chore(i18n): update translations 2022-09-11 17:07:42 -04:00
Hammad Ahmad Waqas
96ceab8b2f fix: Redirecting to MFE home page instead of access-denied page to save redirection (#970) 2022-09-07 15:39:12 +05:00
Hammad Ahmad Waqas
f9806d0759 feat: added support to check if active enterprise is same as EnterpriseCourseEnrollment object (#967) 2022-09-07 11:39:03 +05:00
edx-semantic-release
a7b584c566 chore(i18n): update translations 2022-08-07 17:03:35 -04:00
Shafqat Farhan
193a184142 feat: [VAN-1000] - Set weekly goal through welcome email via query param (#956) 2022-07-26 09:07:55 +05:00
Hammad Ahmad Waqas
3e76f7ac78 feat: Added support to redirect to DSC if required. (#958) 2022-07-25 13:14:41 +05:00
Maman Khan
36062ff3a6 fix: removed derpreciated codecov package (#953) 2022-06-20 08:10:46 +05:00
ruzniaievdm
6257cb4b58 refactor: Replace PDF course certificate view code (#946)
Co-authored-by: ruzniaievdm <ruzniaievdm@gmail.com>
2022-06-13 09:15:09 -04:00
edx-semantic-release
792d9eb758 chore(i18n): update translations 2022-06-12 17:04:27 -04:00
edx-semantic-release
cd84a15891 chore(i18n): update translations 2022-06-03 13:57:12 -04:00
Diana Catalina Olarte
cafb881a61 fix: show site name instead of edX 2022-05-18 12:23:43 +01:00
edX requirements bot
fd94da0a43 feat: Add package-lock file version check (#941) 2022-05-06 15:53:47 +05:00
edx-semantic-release
1e41547b3e chore(i18n): update translations 2022-04-24 17:04:22 -04:00
Renovate Bot
bf2f123367 fix(deps): update dependency @edx/paragon to v19.18.3 2022-04-21 19:06:04 +00:00
Renovate Bot
0211ecf45e fix(deps): update dependency core-js to v3.22.2 2022-04-21 16:49:25 +00:00
renovate[bot]
36ac129267 fix(deps): update dependency @edx/paragon to v19.18.0 (#926)
Co-authored-by: Renovate Bot <bot@renovateapp.com>
2022-04-21 12:32:06 -04:00
renovate[bot]
20d4c35d83 fix(deps): update dependency core-js to v3.22.0 (#927)
Co-authored-by: Renovate Bot <bot@renovateapp.com>
2022-04-21 12:30:10 -04:00
Michael Terry
bbff8e719e fix: remove support for the legacy courseware pages
Access to learners for these pages has been removed, so we don't
need to keep any support for it around. Simplifies some code paths.
2022-04-21 08:50:53 -04:00
edx-semantic-release
5461c08169 chore(i18n): update translations 2022-04-17 17:09:11 -04:00
Michael Terry
ee88a12d8f fix: assume that dates & outline legacy tabs don't exist
They've both been removed from the LMS now. It would be harmless
to keep support for them in place, but it's pointless because
any redirects to the LMS will just come right back to us.

AA-799
2022-04-15 12:26:47 -04:00
Muhammad Adeel Tajamul
9b316bd859 feat: added live tab (#923)
Co-authored-by: adeel.tajamul <adeel.tajamul@arbisoft.com>
2022-04-14 10:04:26 +05:00
Renovate Bot
7e7eb83596 fix(deps): update dependency @edx/paragon to v19.15.1 2022-04-13 22:08:11 +00:00
Thomas Tracy
aaa367780d fix: [MICROBA-1769] Cert status before course end (#918)
* fix: [MICROBA-1769] Cert status before course end

Right now, learners who are nonpassing are able to view information
about thier certificates early at the course end screen and progress
pages. This is because we show messaging around the nonpassing state in
some cases before a course ends and certificates are available. This can
also lead to cases where grades are not finalized and students who may
be passing see a scary nonpassing message instead.

This change makes it so during the course exit, a student who finishes a
course before the course is over will see the celebration screen
regardless of passing status. Once the course is over (or if
certificates are available immediately), and they are
still not passing, they will see the nonpassing messaging. The same
change was made for the certificate status alert in the progress tab.
2022-04-13 10:14:00 -04:00
Kshitij Sobti
6d42ee9c6f feat: add discussions tab [BD-38] [TNL-9743] (#879)
* feat: add discussions tab

Adds code to load the discussions MFE in an iframe in the tab so the user isn't redirected to the LMS.

Adds code for the discussions tab, making it dynamically resize based on contents using a postMessage API.

* feat: update path based on user navigation inside discussions MFE

The discussions MFE will send path change events via the postMessage API so that the learning MFE path can be kept in sync. This will allow reloading a page without having the iframe revert to same path each time.
2022-04-13 19:01:29 +05:00
Renovate Bot
41047f4c88 fix(deps): update dependency @edx/paragon to v19.15.0 2022-04-12 09:55:15 -04:00
Renovate Bot
d83551c809 chore(deps): update dependency @testing-library/react to v12.1.5 2022-04-11 23:37:47 +00:00
Renovate Bot
7c3088901d fix(deps): update dependency @edx/frontend-component-footer to v10.2.4 2022-04-11 17:27:50 +00:00
Renovate Bot
518c9ef6c2 chore(deps): update dependency @edx/reactifex to v2 2022-04-11 10:17:28 -04:00
Michael Terry
ae97efaf2b fix: add back es-check & fsevents for now to fix build
A previous commit (7f37575) dropped es-check, which dropped
fsevents, which caused our build system (which is still using
npm@6) to fail with an error like `Unsupported platform for
fsevents` when trying to install fsevents through a dependency
(e.g. when installing npm aliases).

I am reintroducing all the package-lock changes from that commit
to get back fsevents in a state where that error does not occur.

I think a longer-term fix would be to instead upgrade our build
system to node16 / npm6. But this is an easy fix for now to unblock
the builds.
2022-04-11 09:46:10 -04:00
95 changed files with 4785 additions and 8928 deletions

View File

@@ -7,4 +7,4 @@ on:
jobs: jobs:
commitlint: commitlint:
uses: edx/.github/.github/workflows/commitlint.yml@master uses: openedx/.github/.github/workflows/commitlint.yml@master

View File

@@ -0,0 +1,13 @@
#check package-lock file version
name: Lockfile Version check
on:
push:
branches:
- master
pull_request:
jobs:
version-check:
uses: openedx/.github/.github/workflows/lockfileversion-check.yml@master

View File

@@ -15,7 +15,7 @@ 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 .. |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 :target: https://codecov.io/gh/edx/frontend-app-learning
.. |license| image:: https://img.shields.io/badge/license-AGPL-informational .. |license| image:: https://img.shields.io/badge/license-AGPL-informational
:target: https://github.com/edx/frontend-app-account/blob/master/LICENSE :target: https://github.com/openedx/frontend-app-account/blob/master/LICENSE
Development Development
----------- -----------
@@ -23,7 +23,7 @@ Development
Start Devstack 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/openedx/devstack>`__ must be running and you must be logged into it.
- Run ``make dev.up.lms`` - 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. - 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.
@@ -52,7 +52,7 @@ 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. See https://github.com/openedx/frontend-build#local-module-configuration-for-webpack for more details.
Deployment Deployment
---------- ----------

11179
package-lock.json generated

File diff suppressed because it is too large Load Diff

View File

@@ -4,7 +4,7 @@
"description": "Frontend learning application.", "description": "Frontend learning application.",
"repository": { "repository": {
"type": "git", "type": "git",
"url": "git+https://github.com/edx/frontend-app-learning.git" "url": "git+https://github.com/openedx/frontend-app-learning.git"
}, },
"browserslist": [ "browserslist": [
"extends @edx/browserslist-config" "extends @edx/browserslist-config"
@@ -21,20 +21,20 @@
}, },
"author": "edX", "author": "edX",
"license": "AGPL-3.0", "license": "AGPL-3.0",
"homepage": "https://github.com/edx/frontend-app-learning#readme", "homepage": "https://github.com/openedx/frontend-app-learning#readme",
"publishConfig": { "publishConfig": {
"access": "public" "access": "public"
}, },
"bugs": { "bugs": {
"url": "https://github.com/edx/frontend-app-learning/issues" "url": "https://github.com/openedx/frontend-app-learning/issues"
}, },
"dependencies": { "dependencies": {
"@edx/brand": "npm:@edx/brand-openedx@1.1.0", "@edx/brand": "npm:@edx/brand-openedx@1.1.0",
"@edx/frontend-component-footer": "10.2.2", "@edx/frontend-component-footer": "^12.0.0",
"@edx/frontend-component-header": "2.4.6", "@edx/frontend-component-header": "^4.0.0",
"@edx/frontend-lib-special-exams": "1.16.3", "@edx/frontend-lib-special-exams": "^2.16.1",
"@edx/frontend-platform": "1.15.6", "@edx/frontend-platform": "^4.2.0",
"@edx/paragon": "19.14.1", "@edx/paragon": "^20.28.4",
"@fortawesome/fontawesome-svg-core": "1.3.0", "@fortawesome/fontawesome-svg-core": "1.3.0",
"@fortawesome/free-brands-svg-icons": "5.15.4", "@fortawesome/free-brands-svg-icons": "5.15.4",
"@fortawesome/free-regular-svg-icons": "5.15.4", "@fortawesome/free-regular-svg-icons": "5.15.4",
@@ -43,10 +43,12 @@
"@popperjs/core": "2.11.5", "@popperjs/core": "2.11.5",
"@reduxjs/toolkit": "1.8.1", "@reduxjs/toolkit": "1.8.1",
"classnames": "2.3.1", "classnames": "2.3.1",
"core-js": "3.21.1", "core-js": "3.22.2",
"history": "^5.3.0",
"js-cookie": "3.0.1", "js-cookie": "3.0.1",
"lodash.camelcase": "4.3.0", "lodash.camelcase": "4.3.0",
"prop-types": "15.8.1", "prop-types": "15.8.1",
"query-string": "^7.1.1",
"react": "16.14.0", "react": "16.14.0",
"react-dom": "16.14.0", "react-dom": "16.14.0",
"react-helmet": "6.1.0", "react-helmet": "6.1.0",
@@ -63,13 +65,13 @@
"devDependencies": { "devDependencies": {
"@edx/browserslist-config": "1.0.2", "@edx/browserslist-config": "1.0.2",
"@edx/frontend-build": "9.1.4", "@edx/frontend-build": "9.1.4",
"@edx/reactifex": "1.1.0", "@edx/reactifex": "2.0.1",
"@pact-foundation/pact": "9.17.3", "@pact-foundation/pact": "9.17.3",
"@testing-library/jest-dom": "5.16.4", "@testing-library/jest-dom": "5.16.4",
"@testing-library/react": "12.1.4", "@testing-library/react": "12.1.5",
"@testing-library/user-event": "13.5.0", "@testing-library/user-event": "13.5.0",
"axios-mock-adapter": "1.20.0", "axios-mock-adapter": "1.20.0",
"codecov": "3.8.3", "es-check": "6.2.1",
"husky": "7.0.4", "husky": "7.0.4",
"jest": "27.5.1", "jest": "27.5.1",
"rosie": "2.1.0" "rosie": "2.1.0"

View File

@@ -5,5 +5,12 @@
"patch": { "patch": {
"automerge": true "automerge": true
}, },
"rebaseStalePrs": true "rebaseStalePrs": true,
"packageRules": [
{
"matchPackagePatterns": ["@edx"],
"matchUpdateTypes": ["minor", "patch"],
"automerge": true
}
]
} }

View File

@@ -0,0 +1,48 @@
import React from 'react';
import { FormattedMessage, injectIntl, intlShape } from '@edx/frontend-platform/i18n';
import PropTypes from 'prop-types';
import { Alert, Hyperlink } from '@edx/paragon';
import { WarningFilled } from '@edx/paragon/icons';
import { getConfig } from '@edx/frontend-platform';
import genericMessages from './messages';
function ActiveEnterpriseAlert({ intl, payload }) {
const { text, courseId } = payload;
const changeActiveEnterprise = (
<Hyperlink
style={{ textDecoration: 'underline' }}
destination={
`${getConfig().LMS_BASE_URL}/enterprise/select/active/?success_url=${encodeURIComponent(
`${global.location.origin}/course/${courseId}/home`,
)}`
}
>
{intl.formatMessage(genericMessages.changeActiveEnterpriseLowercase)}
</Hyperlink>
);
return (
<Alert variant="warning" icon={WarningFilled}>
{text}
<FormattedMessage
id="learning.activeEnterprise.alert"
description="Prompts the user to log-in with the correct enterprise to access the course content."
defaultMessage=" {changeActiveEnterprise}."
values={{
changeActiveEnterprise,
}}
/>
</Alert>
);
}
ActiveEnterpriseAlert.propTypes = {
intl: intlShape.isRequired,
payload: PropTypes.shape({
text: PropTypes.string,
courseId: PropTypes.string,
}).isRequired,
};
export default injectIntl(ActiveEnterpriseAlert);

View File

@@ -0,0 +1,27 @@
import React from 'react';
import { getConfig } from '@edx/frontend-platform';
import {
initializeTestStore, render, screen,
} from '../../setupTest';
import ActiveEnterpriseAlert from './ActiveEnterpriseAlert';
describe('ActiveEnterpriseAlert', () => {
const mockData = {
payload: {
text: 'test message',
courseId: 'test-course-id',
},
};
beforeAll(async () => {
await initializeTestStore({ excludeFetchCourse: true, excludeFetchSequence: true });
});
it('Shows alert message and links', () => {
render(<ActiveEnterpriseAlert {...mockData} />);
expect(screen.getByRole('alert')).toBeInTheDocument();
expect(screen.getByText('test message', { exact: false })).toBeInTheDocument();
expect(screen.getByRole('link', { name: 'change enterprise now' })).toHaveAttribute(
'href', `${getConfig().LMS_BASE_URL}/enterprise/select/active/?success_url=http%3A%2F%2Flocalhost%2Fcourse%2Ftest-course-id%2Fhome`,
);
});
});

View File

@@ -0,0 +1,28 @@
import React, { useMemo } from 'react';
import { ALERT_TYPES, useAlert } from '../../generic/user-messages';
import { useModel } from '../../generic/model-store';
const ActiveEnterpriseAlert = React.lazy(() => import('./ActiveEnterpriseAlert'));
export default function useActiveEnterpriseAlert(courseId) {
const { courseAccess } = useModel('courseHomeMeta', courseId);
/**
* This alert should render if
* 1. course access code is incorrect_active_enterprise
*/
const isVisible = courseAccess && !courseAccess.hasAccess && courseAccess.errorCode === 'incorrect_active_enterprise';
const payload = {
text: courseAccess && courseAccess.userMessage,
courseId,
};
useAlert(isVisible, {
code: 'clientActiveEnterpriseAlert',
topic: 'outline',
dismissible: false,
type: ALERT_TYPES.ERROR,
payload: useMemo(() => payload, Object.values(payload).sort()),
});
return { clientActiveEnterpriseAlert: ActiveEnterpriseAlert };
}

View File

@@ -0,0 +1,3 @@
import useActiveEnterpriseAlert from './hooks';
export default useActiveEnterpriseAlert;

View File

@@ -0,0 +1,11 @@
import { defineMessages } from '@edx/frontend-platform/i18n';
const messages = defineMessages({
changeActiveEnterpriseLowercase: {
id: 'learning.activeEnterprise.change.alert',
defaultMessage: 'change enterprise now',
description: 'Text in a link, prompting the user to change active enterprise. Used in learning.activeEnterprise.change.alert"',
},
});
export default messages;

View File

@@ -3,7 +3,7 @@ import PropTypes from 'prop-types';
import { import {
FormattedDate, FormattedDate,
FormattedMessage, FormattedMessage,
FormattedRelative, FormattedRelativeTime,
FormattedTime, FormattedTime,
} from '@edx/frontend-platform/i18n'; } from '@edx/frontend-platform/i18n';
import { Alert } from '@edx/paragon'; import { Alert } from '@edx/paragon';
@@ -11,7 +11,9 @@ import { Info } from '@edx/paragon/icons';
import { useModel } from '../../generic/model-store'; import { useModel } from '../../generic/model-store';
const DAY_MS = 24 * 60 * 60 * 1000; // in ms const DAY_SEC = 24 * 60 * 60; // in seconds
const DAY_MS = DAY_SEC * 1000; // in ms
const YEAR_SEC = 365 * DAY_SEC; // in seconds
function CourseStartAlert({ payload }) { function CourseStartAlert({ payload }) {
const { const {
@@ -25,15 +27,18 @@ function CourseStartAlert({ payload }) {
const timezoneFormatArgs = userTimezone ? { timeZone: userTimezone } : {}; const timezoneFormatArgs = userTimezone ? { timeZone: userTimezone } : {};
const delta = new Date(startDate) - new Date();
const timeRemaining = ( const timeRemaining = (
<FormattedRelative <FormattedRelativeTime
key="timeRemaining" key="timeRemaining"
value={startDate} value={delta / 1000}
numeric="auto"
// 1 year interval to help auto format. It won't format without updateIntervalInSeconds.
updateIntervalInSeconds={YEAR_SEC}
{...timezoneFormatArgs} {...timezoneFormatArgs}
/> />
); );
const delta = new Date(startDate) - new Date();
if (delta < DAY_MS) { if (delta < DAY_MS) {
return ( return (
<Alert variant="info" icon={Info}> <Alert variant="info" icon={Info}>

View File

@@ -9,7 +9,7 @@ Factory.define('courseHomeMetadata')
is_self_paced: false, is_self_paced: false,
is_enrolled: false, is_enrolled: false,
is_staff: false, is_staff: false,
can_load_courseware: true, can_view_certificate: true,
celebrations: null, celebrations: null,
course_access: { course_access: {
additional_context_user_message: null, additional_context_user_message: null,

View File

@@ -35,7 +35,6 @@ Factory.define('outlineTabData')
cert_status: null, cert_status: null,
cert_web_view_url: null, cert_web_view_url: null,
certificate_available_date: null, certificate_available_date: null,
download_url: null,
}, },
course_goals: { course_goals: {
goal_options: [], goal_options: [],

View File

@@ -21,7 +21,7 @@ Object {
"models": Object { "models": Object {
"courseHomeMeta": Object { "courseHomeMeta": Object {
"course-v1:edX+DemoX+Demo_Course": Object { "course-v1:edX+DemoX+Demo_Course": Object {
"canLoadCourseware": true, "canViewCertificate": true,
"celebrations": null, "celebrations": null,
"courseAccess": Object { "courseAccess": Object {
"additionalContextUserMessage": null, "additionalContextUserMessage": null,
@@ -339,7 +339,7 @@ Object {
"models": Object { "models": Object {
"courseHomeMeta": Object { "courseHomeMeta": Object {
"course-v1:edX+DemoX+Demo_Course": Object { "course-v1:edX+DemoX+Demo_Course": Object {
"canLoadCourseware": true, "canViewCertificate": true,
"celebrations": null, "celebrations": null,
"courseAccess": Object { "courseAccess": Object {
"additionalContextUserMessage": null, "additionalContextUserMessage": null,
@@ -411,7 +411,6 @@ Object {
"certStatus": null, "certStatus": null,
"certWebViewUrl": null, "certWebViewUrl": null,
"certificateAvailableDate": null, "certificateAvailableDate": null,
"downloadUrl": null,
}, },
"courseBlocks": Object { "courseBlocks": Object {
"courses": Object { "courses": Object {
@@ -445,7 +444,6 @@ Object {
"effortTime": 15, "effortTime": 15,
"icon": null, "icon": null,
"id": "block-v1:edX+DemoX+Demo_Course+type@sequential+block@bcdabcdabcdabcdabcdabcdabcdabcd1", "id": "block-v1:edX+DemoX+Demo_Course+type@sequential+block@bcdabcdabcdabcdabcdabcdabcdabcd1",
"legacyWebUrl": "http://localhost:18000/courses/course-v1:edX+DemoX+Demo_Course/jump_to/block-v1:edX+DemoX+Demo_Course+type@sequential+block@bcdabcdabcdabcdabcdabcdabcdabcd1?experience=legacy",
"sectionId": "block-v1:edX+DemoX+Demo_Course+type@chapter+block@bcdabcdabcdabcdabcdabcdabcdabcd2", "sectionId": "block-v1:edX+DemoX+Demo_Course+type@chapter+block@bcdabcdabcdabcdabcdabcdabcdabcd2",
"showLink": true, "showLink": true,
"title": "Title of Sequence", "title": "Title of Sequence",
@@ -537,7 +535,7 @@ Object {
"models": Object { "models": Object {
"courseHomeMeta": Object { "courseHomeMeta": Object {
"course-v1:edX+DemoX+Demo_Course": Object { "course-v1:edX+DemoX+Demo_Course": Object {
"canLoadCourseware": true, "canViewCertificate": true,
"celebrations": null, "celebrations": null,
"courseAccess": Object { "courseAccess": Object {
"additionalContextUserMessage": null, "additionalContextUserMessage": null,

View File

@@ -148,12 +148,9 @@ export function normalizeOutlineBlocks(courseId, blocks) {
effortTime: block.effort_time, effortTime: block.effort_time,
icon: block.icon, icon: block.icon,
id: block.id, id: block.id,
legacyWebUrl: block.legacy_web_url, // The presence of a URL for the sequence indicates that we want this sequence to be a clickable
// The presence of an legacy URL for the sequence indicates that we want this // link in the outline (even though we ignore the given url and use an internal <Link> to ourselves).
// sequence to be a clickable link in the outline (even though, if the new showLink: !!block.lms_web_url,
// courseware experience is active, we will ignore `legacyWebUrl` and build a
// link to the MFE ourselves).
showLink: !!block.legacy_web_url,
title: block.display_name, title: block.display_name,
}; };
break; break;
@@ -208,10 +205,6 @@ export async function getDatesTabData(courseId) {
return camelCaseObject(data); return camelCaseObject(data);
} catch (error) { } catch (error) {
const { httpErrorStatus } = error && error.customAttributes; const { httpErrorStatus } = error && error.customAttributes;
if (httpErrorStatus === 404) {
global.location.replace(`${getConfig().LMS_BASE_URL}/courses/${courseId}/dates`);
return {};
}
if (httpErrorStatus === 401) { if (httpErrorStatus === 401) {
// The backend sends this for unenrolled and unauthenticated learners, but we handle those cases by examining // The backend sends this for unenrolled and unauthenticated learners, but we handle those cases by examining
// courseAccess in the metadata call, so just ignore this status for now. // courseAccess in the metadata call, so just ignore this status for now.
@@ -297,6 +290,20 @@ export async function getProctoringInfoData(courseId, username) {
} }
} }
export async function getLiveTabIframe(courseId) {
const url = `${getConfig().LMS_BASE_URL}/api/course_live/iframe/${courseId}/`;
try {
const { data } = await getAuthenticatedHttpClient().get(url);
return data;
} catch (error) {
const { httpErrorStatus } = error && error.customAttributes;
if (httpErrorStatus === 404) {
return {};
}
throw error;
}
}
export function getTimeOffsetMillis(headerDate, requestTime, responseTime) { export function getTimeOffsetMillis(headerDate, requestTime, responseTime) {
// Time offset computation should move down into the HttpClient wrapper to maintain a global time correction reference // Time offset computation should move down into the HttpClient wrapper to maintain a global time correction reference
// Requires 'Access-Control-Expose-Headers: Date' on the server response per https://developer.mozilla.org/en-US/docs/Web/HTTP/CORS#access-control-expose-headers // Requires 'Access-Control-Expose-Headers: Date' on the server response per https://developer.mozilla.org/en-US/docs/Web/HTTP/CORS#access-control-expose-headers
@@ -314,21 +321,9 @@ export function getTimeOffsetMillis(headerDate, requestTime, responseTime) {
export async function getOutlineTabData(courseId) { export async function getOutlineTabData(courseId) {
const url = `${getConfig().LMS_BASE_URL}/api/course_home/outline/${courseId}`; const url = `${getConfig().LMS_BASE_URL}/api/course_home/outline/${courseId}`;
let { tabData } = {}; const requestTime = Date.now();
let requestTime = Date.now(); const tabData = await getAuthenticatedHttpClient().get(url);
let responseTime = requestTime; const responseTime = Date.now();
try {
requestTime = Date.now();
tabData = await getAuthenticatedHttpClient().get(url);
responseTime = Date.now();
} catch (error) {
const { httpErrorStatus } = error && error.customAttributes;
if (httpErrorStatus === 404) {
global.location.replace(`${getConfig().LMS_BASE_URL}/courses/${courseId}/course/`);
return {};
}
throw error;
}
const { const {
data, data,

View File

@@ -58,7 +58,6 @@ describe('Course Home Service', () => {
sku: '8CF08E5', sku: '8CF08E5',
upgrade_url: `${getConfig().ECOMMERCE_BASE_URL}/basket/add/?sku=8CF08E5`, upgrade_url: `${getConfig().ECOMMERCE_BASE_URL}/basket/add/?sku=8CF08E5`,
}), }),
can_load_courseware: boolean(true),
celebrations: like({ celebrations: like({
first_section: false, first_section: false,
streak_length_to_celebrate: null, streak_length_to_celebrate: null,
@@ -106,7 +105,6 @@ describe('Course Home Service', () => {
sku: '8CF08E5', sku: '8CF08E5',
upgradeUrl: `${getConfig().ECOMMERCE_BASE_URL}/basket/add/?sku=8CF08E5`, upgradeUrl: `${getConfig().ECOMMERCE_BASE_URL}/basket/add/?sku=8CF08E5`,
}, },
canLoadCourseware: true,
celebrations: { celebrations: {
firstSection: false, firstSection: false,
streakLengthToCelebrate: null, streakLengthToCelebrate: null,

View File

@@ -11,6 +11,7 @@ import {
postWeeklyLearningGoal, postWeeklyLearningGoal,
postDismissWelcomeMessage, postDismissWelcomeMessage,
postRequestCert, postRequestCert,
getLiveTabIframe,
} from './api'; } from './api';
import { import {
@@ -32,46 +33,38 @@ const eventTypes = {
export function fetchTab(courseId, tab, getTabData, targetUserId) { export function fetchTab(courseId, tab, getTabData, targetUserId) {
return async (dispatch) => { return async (dispatch) => {
dispatch(fetchTabRequest({ courseId })); dispatch(fetchTabRequest({ courseId }));
Promise.allSettled([ try {
getCourseHomeCourseMetadata(courseId, 'outline'), const courseHomeCourseMetadata = await getCourseHomeCourseMetadata(courseId, 'outline');
getTabData(courseId, targetUserId),
]).then(([courseHomeCourseMetadataResult, tabDataResult]) => {
const fetchedCourseHomeCourseMetadata = courseHomeCourseMetadataResult.status === 'fulfilled';
const fetchedTabData = tabDataResult.status === 'fulfilled';
if (fetchedCourseHomeCourseMetadata) {
dispatch(addModel({ dispatch(addModel({
modelType: 'courseHomeMeta', modelType: 'courseHomeMeta',
model: { model: {
id: courseId, id: courseId,
...courseHomeCourseMetadataResult.value, ...courseHomeCourseMetadata,
}, },
})); }));
} else { const tabDataResult = getTabData && await getTabData(courseId, targetUserId);
logError(courseHomeCourseMetadataResult.reason); if (tabDataResult) {
}
if (fetchedTabData) {
dispatch(addModel({ dispatch(addModel({
modelType: tab, modelType: tab,
model: { model: {
id: courseId, id: courseId,
...tabDataResult.value, ...tabDataResult,
}, },
})); }));
} else {
logError(tabDataResult.reason);
} }
// Disable the access-denied path for now - it caused a regression // Disable the access-denied path for now - it caused a regression
if (fetchedCourseHomeCourseMetadata && !courseHomeCourseMetadataResult.value.courseAccess.hasAccess) { if (!courseHomeCourseMetadata.courseAccess.hasAccess) {
dispatch(fetchTabDenied({ courseId })); dispatch(fetchTabDenied({ courseId }));
} else if (fetchedCourseHomeCourseMetadata && fetchedTabData) { } else if (tabDataResult || !getTabData) {
dispatch(fetchTabSuccess({ courseId, targetUserId })); dispatch(fetchTabSuccess({
} else { courseId,
dispatch(fetchTabFailure({ courseId })); targetUserId,
}));
}
} catch (e) {
dispatch(fetchTabFailure({ courseId }));
logError(e);
} }
});
}; };
} }
@@ -87,6 +80,14 @@ export function fetchOutlineTab(courseId) {
return fetchTab(courseId, 'outline', getOutlineTabData); return fetchTab(courseId, 'outline', getOutlineTabData);
} }
export function fetchLiveTab(courseId) {
return fetchTab(courseId, 'live', getLiveTabIframe);
}
export function fetchDiscussionTab(courseId) {
return fetchTab(courseId, 'discussion');
}
export function dismissWelcomeMessage(courseId) { export function dismissWelcomeMessage(courseId) {
return async () => postDismissWelcomeMessage(courseId); return async () => postDismissWelcomeMessage(courseId);
} }

View File

@@ -341,12 +341,12 @@ describe('DatesTab', () => {
it('redirects to the home page when unauthenticated', async () => { it('redirects to the home page when unauthenticated', async () => {
await renderDenied('authentication_required'); await renderDenied('authentication_required');
expect(global.location.href).toEqual(`http://localhost/redirect/course-home/${courseMetadata.id}`); expect(global.location.href).toEqual(`http://localhost/course/${courseMetadata.id}/home`);
}); });
it('redirects to the home page when unenrolled', async () => { it('redirects to the home page when unenrolled', async () => {
await renderDenied('enrollment_required'); await renderDenied('enrollment_required');
expect(global.location.href).toEqual(`http://localhost/redirect/course-home/${courseMetadata.id}`); expect(global.location.href).toEqual(`http://localhost/course/${courseMetadata.id}/home`);
}); });
}); });
}); });

View File

@@ -0,0 +1,36 @@
import { getConfig } from '@edx/frontend-platform';
import { injectIntl } from '@edx/frontend-platform/i18n';
import React, { useState } from 'react';
import { useSelector } from 'react-redux';
import { generatePath, useHistory } from 'react-router';
import { useParams } from 'react-router-dom';
import { useIFrameHeight, useIFramePluginEvents } from '../../generic/hooks';
function DiscussionTab() {
const { courseId } = useSelector(state => state.courseHome);
const { path } = useParams();
const [originalPath] = useState(path);
const history = useHistory();
const [, iFrameHeight] = useIFrameHeight();
useIFramePluginEvents({
'discussions.navigate': (payload) => {
const basePath = generatePath('/course/:courseId/discussion', { courseId });
history.push(`${basePath}/${payload.path}`);
},
});
const discussionsUrl = `${getConfig().DISCUSSIONS_MFE_BASE_URL}/${courseId}/${originalPath}`;
return (
<iframe
src={discussionsUrl}
className="d-flex w-100 border-0"
height={iFrameHeight}
style={{ minHeight: '60rem' }}
title="discussion"
/>
);
}
DiscussionTab.propTypes = {};
export default injectIntl(DiscussionTab);

View File

@@ -0,0 +1,61 @@
import { getConfig, history } from '@edx/frontend-platform';
import { getAuthenticatedHttpClient } from '@edx/frontend-platform/auth';
import { AppProvider } from '@edx/frontend-platform/react';
import { render } from '@testing-library/react';
import MockAdapter from 'axios-mock-adapter';
import React from 'react';
import { Route } from 'react-router';
import { Factory } from 'rosie';
import { UserMessagesProvider } from '../../generic/user-messages';
import {
initializeMockApp, messageEvent, screen, waitFor,
} from '../../setupTest';
import initializeStore from '../../store';
import { TabContainer } from '../../tab-page';
import { appendBrowserTimezoneToUrl } from '../../utils';
import { fetchDiscussionTab } from '../data/thunks';
import DiscussionTab from './DiscussionTab';
initializeMockApp();
jest.mock('@edx/frontend-platform/analytics');
describe('DiscussionTab', () => {
let axiosMock;
let store;
let component;
beforeEach(() => {
axiosMock = new MockAdapter(getAuthenticatedHttpClient());
store = initializeStore();
component = (
<AppProvider store={store}>
<UserMessagesProvider>
<Route path="/course/:courseId/discussion">
<TabContainer tab="discussion" fetch={fetchDiscussionTab} slice="courseHome">
<DiscussionTab />
</TabContainer>
</Route>
</UserMessagesProvider>
</AppProvider>
);
});
const courseMetadata = Factory.build('courseHomeMetadata', { user_timezone: 'America/New_York' });
const { id: courseId } = courseMetadata;
let courseMetadataUrl = `${getConfig().LMS_BASE_URL}/api/course_home/course_metadata/${courseId}`;
courseMetadataUrl = appendBrowserTimezoneToUrl(courseMetadataUrl);
beforeEach(() => {
axiosMock.onGet(courseMetadataUrl).reply(200, courseMetadata);
history.push(`/course/${courseId}/discussion`); // so tab can pull course id from url
render(component);
});
it('resizes when it gets a size hint from iframe', async () => {
window.postMessage({ ...messageEvent, payload: { height: 1234 } }, '*');
await waitFor(() => expect(screen.getByTitle('discussion'))
.toHaveAttribute('height', String(1234)));
});
});

View File

@@ -0,0 +1,22 @@
import React, { useEffect } from 'react';
import { useSelector } from 'react-redux';
function LiveTab() {
const { courseId } = useSelector(state => state.courseHome);
const liveModel = useSelector(state => state.models.live);
useEffect(() => {
const iframe = document.getElementById('lti-tab-embed');
if (iframe) {
iframe.className += ' vh-100 w-100 border-0';
}
}, []);
return (
<div
id="live_tab"
// eslint-disable-next-line react/no-danger
dangerouslySetInnerHTML={{ __html: liveModel[courseId]?.iframe }}
/>
);
}
export default LiveTab;

View File

@@ -1,9 +1,10 @@
import React, { useState } from 'react'; import React, { useEffect, useState } from 'react';
import { useLocation } from 'react-router-dom';
import { useSelector } from 'react-redux'; import { useSelector } from 'react-redux';
import { sendTrackEvent } from '@edx/frontend-platform/analytics'; import { sendTrackEvent } from '@edx/frontend-platform/analytics';
import { getAuthenticatedUser } from '@edx/frontend-platform/auth'; import { getAuthenticatedUser } from '@edx/frontend-platform/auth';
import { history } from '@edx/frontend-platform';
import { injectIntl, intlShape } from '@edx/frontend-platform/i18n'; import { injectIntl, intlShape } from '@edx/frontend-platform/i18n';
import { Button } from '@edx/paragon'; import { Button } from '@edx/paragon';
import { AlertList } from '../../generic/user-messages'; import { AlertList } from '../../generic/user-messages';
@@ -109,6 +110,23 @@ function OutlineTab({ intl }) {
/** show post enrolment survey to only B2C learners */ /** show post enrolment survey to only B2C learners */
const learnerType = isEnterpriseUser() ? 'enterprise_learner' : 'b2c_learner'; const learnerType = isEnterpriseUser() ? 'enterprise_learner' : 'b2c_learner';
const location = useLocation();
useEffect(() => {
const currentParams = new URLSearchParams(location.search);
const startCourse = currentParams.get('start_course');
if (startCourse === '1') {
sendTrackEvent('welcome.email.clicked.startcourse', {});
// Deleting the course_start query param as it only needs to be set once
// whenever passed in query params.
currentParams.delete('start_course');
history.replace({
search: currentParams.toString(),
});
}
}, [location.search]);
return ( return (
<> <>
<div data-learner-type={learnerType} className="row w-100 mx-0 my-3 justify-content-between"> <div data-learner-type={learnerType} className="row w-100 mx-0 my-3 justify-content-between">

View File

@@ -2,6 +2,7 @@
* @jest-environment jsdom * @jest-environment jsdom
*/ */
import React from 'react'; import React from 'react';
import { MemoryRouter } from 'react-router-dom';
import { Factory } from 'rosie'; import { Factory } from 'rosie';
import { getConfig } from '@edx/frontend-platform'; import { getConfig } from '@edx/frontend-platform';
import { sendTrackEvent, sendTrackingLogEvent } from '@edx/frontend-platform/analytics'; import { sendTrackEvent, sendTrackingLogEvent } from '@edx/frontend-platform/analytics';
@@ -51,9 +52,14 @@ describe('Outline Tab', () => {
axiosMock.onGet(outlineUrl).reply(200, outlineTabData); axiosMock.onGet(outlineUrl).reply(200, outlineTabData);
} }
async function fetchAndRender() { async function fetchAndRender(path = '') {
await executeThunk(thunks.fetchOutlineTab(courseId), store.dispatch); await executeThunk(thunks.fetchOutlineTab(courseId), store.dispatch);
await act(async () => render(<OutlineTab />, { store })); await act(async () => render(
<MemoryRouter initialEntries={[path]}>
<OutlineTab />
</MemoryRouter>,
{ store },
));
} }
beforeEach(async () => { beforeEach(async () => {
@@ -138,25 +144,8 @@ describe('Outline Tab', () => {
expect(screen.getByTitle('Incomplete section')).toBeInTheDocument(); expect(screen.getByTitle('Incomplete section')).toBeInTheDocument();
}); });
it('SequenceLink displays points to legacy courseware', async () => { it('SequenceLink displays link', async () => {
const { courseBlocks } = await buildMinimalCourseBlocks(courseId, 'Title', { resumeBlock: true }); const { courseBlocks } = await buildMinimalCourseBlocks(courseId, 'Title', { resumeBlock: true });
setMetadata({
can_load_courseware: false,
});
setTabData({
course_blocks: { blocks: courseBlocks.blocks },
});
await fetchAndRender();
const sequenceLink = screen.getByText('Title of Sequence');
expect(sequenceLink.getAttribute('href')).toContain(`/courses/${courseId}`);
});
it('SequenceLink displays points to courseware MFE', async () => {
const { courseBlocks } = await buildMinimalCourseBlocks(courseId, 'Title', { resumeBlock: true });
setMetadata({
can_load_courseware: true,
});
setTabData({ setTabData({
course_blocks: { blocks: courseBlocks.blocks }, course_blocks: { blocks: courseBlocks.blocks },
}); });
@@ -355,6 +344,26 @@ describe('Outline Tab', () => {
expect(spy).toHaveBeenCalledTimes(0); expect(spy).toHaveBeenCalledTimes(0);
}); });
it('post goal via query param', async () => {
setTabData({
course_goals: {
weekly_learning_goal_enabled: true,
},
});
const spy = jest.spyOn(thunks, 'saveWeeklyLearningGoal');
sendTrackEvent.mockClear();
await fetchAndRender('http://localhost/?weekly_goal=3');
expect(spy).toHaveBeenCalledTimes(1);
expect(sendTrackEvent).toHaveBeenCalledWith('welcome.email.clicked.setgoal', {});
});
it('emit start course event via query param', async () => {
sendTrackEvent.mockClear();
await fetchAndRender('http://localhost/?start_course=1');
expect(sendTrackEvent).toHaveBeenCalledWith('welcome.email.clicked.startcourse', {});
});
describe('weekly learning goal is not set', () => { describe('weekly learning goal is not set', () => {
beforeEach(async () => { beforeEach(async () => {
setTabData({ setTabData({
@@ -568,7 +577,7 @@ describe('Outline Tab', () => {
const instructorToolbar = await screen.getByTestId('instructor-toolbar'); const instructorToolbar = await screen.getByTestId('instructor-toolbar');
expect(instructorToolbar).toBeInTheDocument(); expect(instructorToolbar).toBeInTheDocument();
expect(screen.getByText('This learner no longer has access to this course. Their access expired on', { exact: false })).toBeInTheDocument(); expect(screen.getByText('This learner no longer has access to this course. Their access expired on', { exact: false })).toBeInTheDocument();
expect(screen.getByText('1/1/2020')).toBeInTheDocument(); expect(screen.getByText('1/1/2020', { exact: false })).toBeInTheDocument();
}); });
it('does not render banner when not masquerading', async () => { it('does not render banner when not masquerading', async () => {
@@ -659,7 +668,6 @@ describe('Outline Tab', () => {
cert_status: CERT_STATUS_TYPE.EARNED_NOT_AVAILABLE, cert_status: CERT_STATUS_TYPE.EARNED_NOT_AVAILABLE,
cert_web_view_url: null, cert_web_view_url: null,
certificate_available_date: tomorrow.toISOString(), certificate_available_date: tomorrow.toISOString(),
download_url: null,
}, },
}, { }, {
date_blocks: [ date_blocks: [
@@ -687,7 +695,6 @@ describe('Outline Tab', () => {
cert_data: { cert_data: {
cert_status: CERT_STATUS_TYPE.UNVERIFIED, cert_status: CERT_STATUS_TYPE.UNVERIFIED,
cert_web_view_url: null, cert_web_view_url: null,
download_url: null,
}, },
}, { }, {
date_blocks: [ date_blocks: [
@@ -756,7 +763,6 @@ describe('Outline Tab', () => {
cert_data: { cert_data: {
cert_status: CERT_STATUS_TYPE.REQUESTING, cert_status: CERT_STATUS_TYPE.REQUESTING,
cert_web_view_url: null, cert_web_view_url: null,
download_url: null,
}, },
}, { }, {
date_blocks: [ date_blocks: [
@@ -790,50 +796,7 @@ describe('Outline Tab', () => {
org_key: 'edX', org_key: 'edX',
}); });
}); });
it('tracks download cert button', async () => {
sendTrackEvent.mockClear();
const now = new Date();
const yesterday = new Date(now.getFullYear(), now.getMonth(), now.getDate() - 1);
const tomorrow = new Date(now.getFullYear(), now.getMonth(), now.getDate() + 1);
setMetadata({ is_enrolled: true });
setTabData({
cert_data: {
cert_status: CERT_STATUS_TYPE.DOWNLOADABLE,
cert_web_view_url: null,
download_url: null,
},
}, {
date_blocks: [
{
date_type: 'course-end-date',
date: yesterday.toISOString(),
title: 'End',
},
{
date_type: 'certificate-available-date',
date: tomorrow.toISOString(),
title: 'Cert Available',
},
{
date_type: 'verification-deadline-date',
date: tomorrow.toISOString(),
link_text: 'Verify',
title: 'Verification Upgrade Deadline',
},
],
});
await fetchAndRender();
sendTrackEvent.mockClear();
const requestingButton = screen.getByRole('button', { name: 'View my certificate' });
fireEvent.click(requestingButton);
expect(sendTrackEvent).toHaveBeenCalledTimes(1);
expect(sendTrackEvent).toHaveBeenCalledWith('edx.ui.lms.course_outline.certificate_alert_downloadable_button.clicked',
{
courserun_key: courseId,
is_staff: false,
org_key: 'edX',
});
});
it('tracks unverified cert button', async () => { it('tracks unverified cert button', async () => {
sendTrackEvent.mockClear(); sendTrackEvent.mockClear();
const now = new Date(); const now = new Date();
@@ -844,7 +807,6 @@ describe('Outline Tab', () => {
cert_data: { cert_data: {
cert_status: CERT_STATUS_TYPE.UNVERIFIED, cert_status: CERT_STATUS_TYPE.UNVERIFIED,
cert_web_view_url: null, cert_web_view_url: null,
download_url: null,
}, },
}, { }, {
date_blocks: [ date_blocks: [
@@ -932,7 +894,6 @@ describe('Outline Tab', () => {
cert_status: CERT_STATUS_TYPE.DOWNLOADABLE, cert_status: CERT_STATUS_TYPE.DOWNLOADABLE,
cert_web_view_url: 'certificate/testuuid', cert_web_view_url: 'certificate/testuuid',
certificate_available_date: null, certificate_available_date: null,
download_url: null,
}, },
}, { }, {
date_blocks: [ date_blocks: [
@@ -958,7 +919,6 @@ describe('Outline Tab', () => {
cert_status: CERT_STATUS_TYPE.REQUESTING, cert_status: CERT_STATUS_TYPE.REQUESTING,
cert_web_view_url: null, cert_web_view_url: null,
certificate_available_date: null, certificate_available_date: null,
download_url: null,
}, },
}, { }, {
date_blocks: [ date_blocks: [
@@ -975,33 +935,6 @@ describe('Outline Tab', () => {
}); });
}); });
describe('Certificate (pdf) Complete Alert', () => {
it('appears', async () => {
const now = new Date();
const yesterday = new Date(now.getFullYear(), now.getMonth(), now.getDate() - 1);
setMetadata({ is_enrolled: true });
setTabData({
cert_data: {
cert_status: CERT_STATUS_TYPE.DOWNLOADABLE,
cert_web_view_url: null,
certificate_available_date: null,
download_url: 'download/url',
},
}, {
date_blocks: [
{
date_type: 'course-end-date',
date: yesterday.toISOString(),
title: 'End',
},
],
});
await fetchAndRender();
expect(screen.queryByText('Congratulations! Your certificate is ready.')).toBeInTheDocument();
expect(screen.queryByRole('link', { name: 'Download my certificate' })).toBeInTheDocument();
});
});
describe('Proctoring Info Panel', () => { describe('Proctoring Info Panel', () => {
const onboardingReleaseDate = new Date(); const onboardingReleaseDate = new Date();
onboardingReleaseDate.setDate(new Date().getDate() - 7); onboardingReleaseDate.setDate(new Date().getDate() - 7);
@@ -1084,6 +1017,22 @@ describe('Outline Tab', () => {
}); });
it('displays expiration warning', async () => { it('displays expiration warning', async () => {
const expirationDate = new Date();
// This message will render if the expiration date is within 28 days; set the date 10 days in future
expirationDate.setTime(expirationDate.getTime() + 864800000);
axiosMock.onGet(proctoringInfoUrl).reply(200, {
onboarding_status: 'verified',
onboarding_link: 'test',
expiration_date: expirationDate.toString(),
onboarding_release_date: onboardingReleaseDate.toISOString(),
});
await fetchAndRender();
await screen.findByText('This course contains proctored exams');
expect(screen.queryByText('Your onboarding profile has been approved. However, your onboarding status is expiring soon. Please complete onboarding again to ensure that you will be able to continue taking proctored exams.')).toBeInTheDocument();
expect(screen.queryByText('Onboarding profile review can take 2+ business days.')).toBeInTheDocument();
});
it('displays expiration warning for other course', async () => {
const expirationDate = new Date(); const expirationDate = new Date();
// This message will render if the expiration date is within 28 days; set the date 10 days in future // This message will render if the expiration date is within 28 days; set the date 10 days in future
expirationDate.setTime(expirationDate.getTime() + 864800000); expirationDate.setTime(expirationDate.getTime() + 864800000);
@@ -1095,7 +1044,23 @@ describe('Outline Tab', () => {
}); });
await fetchAndRender(); await fetchAndRender();
await screen.findByText('This course contains proctored exams'); await screen.findByText('This course contains proctored exams');
expect(screen.queryByText('Your onboarding profile has been approved in another course. However, your onboarding status is expiring soon. Please complete onboarding again to ensure that you will be able to continue taking proctored exams.')).toBeInTheDocument(); expect(screen.queryByText('Your onboarding profile has been approved. However, your onboarding status is expiring soon. Please complete onboarding again to ensure that you will be able to continue taking proctored exams.')).toBeInTheDocument();
expect(screen.queryByText('Onboarding profile review can take 2+ business days.')).toBeInTheDocument();
});
it('displays expired', async () => {
const expirationDate = new Date();
// This message appears after expiration, set the date 10 days in the past
expirationDate.setTime(expirationDate.getTime() - 864800000);
axiosMock.onGet(proctoringInfoUrl).reply(200, {
onboarding_status: 'verified',
onboarding_link: 'test',
expiration_date: expirationDate.toString(),
onboarding_release_date: onboardingReleaseDate.toISOString(),
});
await fetchAndRender();
await screen.findByText('This course contains proctored exams');
expect(screen.queryByText('Your onboarding status has expired. Please complete onboarding again to continue taking proctored exams.')).toBeInTheDocument();
expect(screen.queryByText('Onboarding profile review can take 2+ business days.')).toBeInTheDocument(); expect(screen.queryByText('Onboarding profile review can take 2+ business days.')).toBeInTheDocument();
}); });

View File

@@ -2,7 +2,6 @@ import React from 'react';
import PropTypes from 'prop-types'; import PropTypes from 'prop-types';
import classNames from 'classnames'; import classNames from 'classnames';
import { Link } from 'react-router-dom'; import { Link } from 'react-router-dom';
import { Hyperlink } from '@edx/paragon';
import { import {
FormattedMessage, FormattedMessage,
FormattedTime, FormattedTime,
@@ -28,25 +27,16 @@ function SequenceLink({
complete, complete,
description, description,
due, due,
legacyWebUrl,
showLink, showLink,
title, title,
} = sequence; } = sequence;
const { const {
userTimezone, userTimezone,
} = useModel('outline', courseId); } = useModel('outline', courseId);
const {
canLoadCourseware,
} = useModel('courseHomeMeta', courseId);
const timezoneFormatArgs = userTimezone ? { timeZone: userTimezone } : {}; const timezoneFormatArgs = userTimezone ? { timeZone: userTimezone } : {};
// canLoadCourseware is true if the Courseware MFE is enabled, false otherwise const coursewareUrl = <Link to={`/course/${courseId}/${id}`}>{title}</Link>;
const coursewareUrl = (
canLoadCourseware
? <Link to={`/course/${courseId}/${id}`}>{title}</Link>
: <Hyperlink destination={legacyWebUrl}>{title}</Hyperlink>
);
const displayTitle = showLink ? coursewareUrl : title; const displayTitle = showLink ? coursewareUrl : title;
return ( return (

View File

@@ -33,7 +33,6 @@ function CertificateStatusAlert({ intl, payload }) {
courseEndDate, courseEndDate,
courseId, courseId,
certURL, certURL,
isWebCert,
userTimezone, userTimezone,
org, org,
notPassingCourseEnded, notPassingCourseEnded,
@@ -79,11 +78,7 @@ function CertificateStatusAlert({ intl, payload }) {
); );
} else if (certStatus === CERT_STATUS_TYPE.DOWNLOADABLE) { } else if (certStatus === CERT_STATUS_TYPE.DOWNLOADABLE) {
alertProps.header = intl.formatMessage(certMessages.certStatusDownloadableHeader); alertProps.header = intl.formatMessage(certMessages.certStatusDownloadableHeader);
if (isWebCert) {
alertProps.buttonMessage = intl.formatMessage(certStatusMessages.viewableButton); alertProps.buttonMessage = intl.formatMessage(certStatusMessages.viewableButton);
} else {
alertProps.buttonMessage = intl.formatMessage(certStatusMessages.downloadableButton);
}
alertProps.buttonVisible = true; alertProps.buttonVisible = true;
alertProps.buttonLink = certURL; alertProps.buttonLink = certURL;
alertProps.buttonAction = () => { alertProps.buttonAction = () => {
@@ -204,7 +199,6 @@ CertificateStatusAlert.propTypes = {
courseEndDate: PropTypes.string, courseEndDate: PropTypes.string,
courseId: PropTypes.string, courseId: PropTypes.string,
certURL: PropTypes.string, certURL: PropTypes.string,
isWebCert: PropTypes.bool,
userTimezone: PropTypes.string, userTimezone: PropTypes.string,
org: PropTypes.string, org: PropTypes.string,
notPassingCourseEnded: PropTypes.bool, notPassingCourseEnded: PropTypes.bool,

View File

@@ -51,10 +51,8 @@ function useCertificateStatusAlert(courseId) {
certStatus, certStatus,
certWebViewUrl, certWebViewUrl,
certificateAvailableDate, certificateAvailableDate,
downloadUrl,
} = certData || {}; } = certData || {};
const endBlock = courseDateBlocks.find(b => b.dateType === 'course-end-date'); const endBlock = courseDateBlocks.find(b => b.dateType === 'course-end-date');
const isWebCert = downloadUrl === null;
const isVerifiedEnrollmentMode = ( const isVerifiedEnrollmentMode = (
enrollmentMode !== null enrollmentMode !== null
&& enrollmentMode !== undefined && enrollmentMode !== undefined
@@ -63,9 +61,6 @@ function useCertificateStatusAlert(courseId) {
let certURL = ''; let certURL = '';
if (certWebViewUrl) { if (certWebViewUrl) {
certURL = `${getConfig().LMS_BASE_URL}${certWebViewUrl}`; certURL = `${getConfig().LMS_BASE_URL}${certWebViewUrl}`;
} else if (downloadUrl) {
// PDF Certificate
certURL = downloadUrl;
} }
const hasAlertingCertStatus = verifyCertStatusType(certStatus); const hasAlertingCertStatus = verifyCertStatusType(certStatus);
@@ -87,7 +82,6 @@ function useCertificateStatusAlert(courseId) {
courseId, courseId,
courseEndDate: endBlock && endBlock.date, courseEndDate: endBlock && endBlock.date,
userTimezone, userTimezone,
isWebCert,
org, org,
notPassingCourseEnded, notPassingCourseEnded,
tabs, tabs,

View File

@@ -3,7 +3,7 @@ import PropTypes from 'prop-types';
import { import {
FormattedDate, FormattedDate,
FormattedMessage, FormattedMessage,
FormattedRelative, FormattedRelativeTime,
FormattedTime, FormattedTime,
} from '@edx/frontend-platform/i18n'; } from '@edx/frontend-platform/i18n';
import { Alert } from '@edx/paragon'; import { Alert } from '@edx/paragon';
@@ -21,7 +21,7 @@ function CourseEndAlert({ payload }) {
const timezoneFormatArgs = userTimezone ? { timeZone: userTimezone } : {}; const timezoneFormatArgs = userTimezone ? { timeZone: userTimezone } : {};
const timeRemaining = ( const timeRemaining = (
<FormattedRelative <FormattedRelativeTime
key="timeRemaining" key="timeRemaining"
value={endDate} value={endDate}
{...timezoneFormatArgs} {...timezoneFormatArgs}

View File

@@ -231,6 +231,11 @@ const messages = defineMessages({
defaultMessage: 'Expiring Soon', defaultMessage: 'Expiring Soon',
description: 'A label to indicate that proctortrack onboarding exam will expire soon', description: 'A label to indicate that proctortrack onboarding exam will expire soon',
}, },
expiredProctoringStatus: {
id: 'learning.proctoringPanel.status.expired',
defaultMessage: 'Expired',
description: 'A label to indicate that proctortrack onboarding exam has expired',
},
proctoringCurrentStatus: { proctoringCurrentStatus: {
id: 'learning.proctoringPanel.status', id: 'learning.proctoringPanel.status',
defaultMessage: 'Current Onboarding Status:', defaultMessage: 'Current Onboarding Status:',
@@ -278,9 +283,14 @@ const messages = defineMessages({
}, },
expiringSoonProctoringMessage: { expiringSoonProctoringMessage: {
id: 'learning.proctoringPanel.message.expiringSoon', id: 'learning.proctoringPanel.message.expiringSoon',
defaultMessage: 'Your onboarding profile has been approved in another course. However, your onboarding status is expiring soon. Please complete onboarding again to ensure that you will be able to continue taking proctored exams.', defaultMessage: 'Your onboarding profile has been approved. However, your onboarding status is expiring soon. Please complete onboarding again to ensure that you will be able to continue taking proctored exams.',
description: 'The text that recommend an action when the status of the proctortrack onboarding exam is (expiring soon)', description: 'The text that recommend an action when the status of the proctortrack onboarding exam is (expiring soon)',
}, },
expiredProctoringMessage: {
id: 'learning.proctoringPanel.message.expired',
defaultMessage: 'Your onboarding status has expired. Please complete onboarding again to continue taking proctored exams.',
description: 'The text that recommend an action when the status of the proctortrack onboarding exam is (expired)',
},
proctoringPanelGeneralInfo: { proctoringPanelGeneralInfo: {
id: 'learning.proctoringPanel.generalInfo', id: 'learning.proctoringPanel.generalInfo',
defaultMessage: 'You must complete the onboarding process prior to taking any proctored exam. ', defaultMessage: 'You must complete the onboarding process prior to taking any proctored exam. ',

View File

@@ -35,6 +35,7 @@ function ProctoringInfoPanel({ intl }) {
error: 'error', error: 'error',
otherCourseApproved: 'otherCourseApproved', otherCourseApproved: 'otherCourseApproved',
expiringSoon: 'expiringSoon', expiringSoon: 'expiringSoon',
expired: 'expired',
}; };
function getReadableStatusClass(examStatus) { function getReadableStatusClass(examStatus) {
@@ -54,9 +55,14 @@ function ProctoringInfoPanel({ intl }) {
return readableClass; return readableClass;
} }
function isNotYetSubmitted(examStatus) { function isCurrentlySubmitted(examStatus) {
const NO_SHOW_STATES = ['submitted', 'second_review_required', 'verified']; const SUBMITTED_STATES = ['submitted', 'second_review_required'];
return !NO_SHOW_STATES.includes(examStatus); return SUBMITTED_STATES.includes(examStatus);
}
function isSubmissionRequired(examStatus) {
const OK_STATES = [readableStatuses.submitted, readableStatuses.verified];
return !OK_STATES.includes(examStatus);
} }
function isNotYetReleased(examReleaseDate) { function isNotYetReleased(examReleaseDate) {
@@ -77,11 +83,19 @@ function ProctoringInfoPanel({ intl }) {
return borderClass; return borderClass;
} }
function isExpiringSoon(dateString) { function isExpired(dateString) {
// Returns true if the expiration date is within 28 days // Returns true if the expiration date has passed
const today = new Date(); const today = new Date();
const expirationDateObject = new Date(dateString); const expirationDateObject = new Date(dateString);
return today > expirationDateObject.getTime() - 2419200000; return today >= expirationDateObject.getTime();
}
function isExpiringSoon(dateString) {
// Returns true if the expiration date is within 28 days
const twentyeightDays = 28 * 24 * 60 * 60 * 1000;
const today = new Date();
const expirationDateObject = new Date(dateString);
return today > expirationDateObject.getTime() - twentyeightDays;
} }
useEffect(() => { useEffect(() => {
@@ -96,7 +110,9 @@ function ProctoringInfoPanel({ intl }) {
setStatus(response.onboarding_status); setStatus(response.onboarding_status);
setLink(response.onboarding_link); setLink(response.onboarding_link);
const expirationDate = response.expiration_date; const expirationDate = response.expiration_date;
if (expirationDate && isExpiringSoon(expirationDate)) { if (expirationDate && isExpired(expirationDate)) {
setReadableStatus(getReadableStatusClass('expired'));
} else if (expirationDate && isExpiringSoon(expirationDate)) {
setReadableStatus(getReadableStatusClass('expiringSoon')); setReadableStatus(getReadableStatusClass('expiringSoon'));
} else { } else {
setReadableStatus(getReadableStatusClass(response.onboarding_status)); setReadableStatus(getReadableStatusClass(response.onboarding_status));
@@ -175,17 +191,17 @@ function ProctoringInfoPanel({ intl }) {
{![readableStatuses.verified, readableStatuses.otherCourseApproved].includes(readableStatus) && ( {![readableStatuses.verified, readableStatuses.otherCourseApproved].includes(readableStatus) && (
<> <>
<p> <p>
{isNotYetSubmitted(status) && ( {!isCurrentlySubmitted(status) && (
intl.formatMessage(messages.proctoringPanelGeneralInfo) intl.formatMessage(messages.proctoringPanelGeneralInfo)
)} )}
{!isNotYetSubmitted(status) && ( {isCurrentlySubmitted(status) && (
intl.formatMessage(messages.proctoringPanelGeneralInfoSubmitted) intl.formatMessage(messages.proctoringPanelGeneralInfoSubmitted)
)} )}
</p> </p>
<p>{intl.formatMessage(messages.proctoringPanelGeneralTime)}</p> <p>{intl.formatMessage(messages.proctoringPanelGeneralTime)}</p>
</> </>
)} )}
{isNotYetSubmitted(status) && ( {isSubmissionRequired(readableStatus) && (
onboardingExamButton onboardingExamButton
)} )}
<Button variant="outline-primary" block href="https://support.edx.org/hc/en-us/sections/115004169247-Taking-Timed-and-Proctored-Exams"> <Button variant="outline-primary" block href="https://support.edx.org/hc/en-us/sections/115004169247-Taking-Timed-and-Proctored-Exams">

View File

@@ -1,7 +1,9 @@
import React, { useState } from 'react'; import React, { useEffect, useState } from 'react';
import { useLocation } from 'react-router-dom';
import PropTypes from 'prop-types'; import PropTypes from 'prop-types';
import { Form, Card, Icon } from '@edx/paragon'; import { Form, Card, Icon } from '@edx/paragon';
import { history } from '@edx/frontend-platform';
import { sendTrackEvent } from '@edx/frontend-platform/analytics'; import { sendTrackEvent } from '@edx/frontend-platform/analytics';
import { getAuthenticatedUser } from '@edx/frontend-platform/auth'; import { getAuthenticatedUser } from '@edx/frontend-platform/auth';
import { injectIntl, intlShape } from '@edx/frontend-platform/i18n'; import { injectIntl, intlShape } from '@edx/frontend-platform/i18n';
@@ -32,8 +34,9 @@ function WeeklyLearningGoalCard({
const [daysPerWeekGoal, setDaysPerWeekGoal] = useState(daysPerWeek); const [daysPerWeekGoal, setDaysPerWeekGoal] = useState(daysPerWeek);
// eslint-disable-next-line react/prop-types // eslint-disable-next-line react/prop-types
const [isGetReminderSelected, setGetReminderSelected] = useState(subscribedToReminders); const [isGetReminderSelected, setGetReminderSelected] = useState(subscribedToReminders);
const location = useLocation();
function handleSelect(days) { function handleSelect(days, triggeredFromEmail = false) {
// Set the subscription button if this is the first time selecting a goal // Set the subscription button if this is the first time selecting a goal
const selectReminders = daysPerWeekGoal === null ? true : isGetReminderSelected; const selectReminders = daysPerWeekGoal === null ? true : isGetReminderSelected;
setGetReminderSelected(selectReminders); setGetReminderSelected(selectReminders);
@@ -47,6 +50,9 @@ function WeeklyLearningGoalCard({
num_days: days, num_days: days,
reminder_selected: selectReminders, reminder_selected: selectReminders,
}); });
if (triggeredFromEmail) {
sendTrackEvent('welcome.email.clicked.setgoal', {});
}
} }
} }
@@ -65,6 +71,21 @@ function WeeklyLearningGoalCard({
} }
} }
useEffect(() => {
const currentParams = new URLSearchParams(location.search);
const weeklyGoal = Number(currentParams.get('weekly_goal'));
if ([1, 3, 5].includes(weeklyGoal)) {
handleSelect(weeklyGoal, true);
// Deleting the weekly_goal query param as it only needs to be set once
// whenever passed in query params.
currentParams.delete('weekly_goal');
history.replace({
search: currentParams.toString(),
});
}
}, [location.search]);
return ( return (
<Card <Card
id="courseHome-weeklyLearningGoal" id="courseHome-weeklyLearningGoal"

View File

@@ -31,6 +31,9 @@ describe('Progress Tab', () => {
courseMetadataUrl = appendBrowserTimezoneToUrl(courseMetadataUrl); courseMetadataUrl = appendBrowserTimezoneToUrl(courseMetadataUrl);
const progressUrl = new RegExp(`${getConfig().LMS_BASE_URL}/api/course_home/progress/*`); const progressUrl = new RegExp(`${getConfig().LMS_BASE_URL}/api/course_home/progress/*`);
const masqueradeUrl = `${getConfig().LMS_BASE_URL}/courses/${courseId}/masquerade`; const masqueradeUrl = `${getConfig().LMS_BASE_URL}/courses/${courseId}/masquerade`;
const now = new Date();
const tomorrow = new Date(now.getFullYear(), now.getMonth(), now.getDate() + 1);
const overmorrow = new Date(now.getFullYear(), now.getMonth(), now.getDate() + 2);
function setMetadata(attributes, options) { function setMetadata(attributes, options) {
const courseMetadata = Factory.build('courseHomeMetadata', attributes, options); const courseMetadata = Factory.build('courseHomeMetadata', attributes, options);
@@ -956,49 +959,6 @@ describe('Progress Tab', () => {
}); });
}); });
it('Displays download link', async () => {
setTabData({
certificate_data: {
cert_status: 'downloadable',
download_url: 'fake.download.url',
},
user_has_passing_grade: true,
});
await fetchAndRender();
expect(screen.getByRole('link', { name: 'Download my certificate' })).toBeInTheDocument();
});
it('sends events on view of progress tab and on click of downloadable certificate link', async () => {
setTabData({
certificate_data: {
cert_status: 'downloadable',
download_url: 'fake.download.url',
},
user_has_passing_grade: true,
});
await fetchAndRender();
expect(sendTrackEvent).toHaveBeenCalledTimes(1);
expect(sendTrackEvent).toHaveBeenCalledWith('edx.ui.lms.course_progress.visited', {
org_key: 'edX',
courserun_key: courseId,
is_staff: false,
track_variant: 'audit',
grade_variant: 'passing',
certificate_status_variant: 'earned_downloadable',
});
const downloadCertificateLink = screen.getByRole('link', { name: 'Download my certificate' });
fireEvent.click(downloadCertificateLink);
expect(sendTrackEvent).toHaveBeenCalledTimes(2);
expect(sendTrackEvent).toHaveBeenNthCalledWith(2, 'edx.ui.lms.course_progress.certificate_status.clicked', {
org_key: 'edX',
courserun_key: courseId,
is_staff: false,
certificate_status_variant: 'earned_downloadable',
});
});
it('Displays webview link', async () => { it('Displays webview link', async () => {
setTabData({ setTabData({
certificate_data: { certificate_data: {
@@ -1220,6 +1180,66 @@ describe('Progress Tab', () => {
await fetchAndRender(); await fetchAndRender();
expect(screen.queryByTestId('certificate-status-component')).not.toBeInTheDocument(); expect(screen.queryByTestId('certificate-status-component')).not.toBeInTheDocument();
}); });
it('Shows not available messaging before certificates are available to nonpassing learners when theres no certificate data', async () => {
setMetadata({
can_view_certificate: false,
is_enrolled: true,
});
setTabData({
end: tomorrow.toISOString(),
certificate_data: undefined,
});
await fetchAndRender();
expect(screen.getByText(`Final grades and any earned certificates are scheduled to be available after ${tomorrow.toLocaleDateString('en-us', {
year: 'numeric',
month: 'long',
day: 'numeric',
})}.`)).toBeInTheDocument();
});
it('Shows not available messaging before certificates are available to passing learners when theres no certificate data', async () => {
setMetadata({
can_view_certificate: false,
is_enrolled: true,
});
setTabData({
end: tomorrow.toISOString(),
user_has_passing_grade: true,
certificate_data: undefined,
});
await fetchAndRender();
expect(screen.getByText(`Final grades and any earned certificates are scheduled to be available after ${tomorrow.toLocaleDateString('en-us', {
year: 'numeric',
month: 'long',
day: 'numeric',
})}.`)).toBeInTheDocument();
});
it('Shows certificate_available_date if learner is passing', async () => {
setMetadata({
can_view_certificate: false,
is_enrolled: true,
});
setTabData({
end: tomorrow.toISOString(),
user_has_passing_grade: true,
certificate_data: {
cert_status: 'earned_but_not_available',
certificate_available_date: overmorrow.toISOString(),
},
});
await fetchAndRender();
expect(screen.getByText('Certificate status'));
expect(screen.getByText(
overmorrow.toLocaleDateString('en-us', {
year: 'numeric',
month: 'long',
day: 'numeric',
}),
{ exact: false },
)).toBeInTheDocument();
});
}); });
describe('Credit Information', () => { describe('Credit Information', () => {
@@ -1283,7 +1303,7 @@ describe('Progress Tab', () => {
await act(async () => render(<LoadedTabPage courseId={courseId} activeTabSlug="progress">...</LoadedTabPage>, { store })); await act(async () => render(<LoadedTabPage courseId={courseId} activeTabSlug="progress">...</LoadedTabPage>, { store }));
expect(screen.getByTestId('instructor-toolbar')).toBeInTheDocument(); expect(screen.getByTestId('instructor-toolbar')).toBeInTheDocument();
expect(screen.getByText('This learner no longer has access to this course. Their access expired on', { exact: false })).toBeInTheDocument(); expect(screen.getByText('This learner no longer has access to this course. Their access expired on', { exact: false })).toBeInTheDocument();
expect(screen.getByText('1/1/2020')).toBeInTheDocument(); expect(screen.getByText('1/1/2020', { exact: false })).toBeInTheDocument();
}); });
it('does not render banner when not masquerading', async () => { it('does not render banner when not masquerading', async () => {
setMetadata({ is_enrolled: true, original_user_is_staff: true }); setMetadata({ is_enrolled: true, original_user_is_staff: true });
@@ -1296,7 +1316,7 @@ describe('Progress Tab', () => {
await executeThunk(thunks.fetchProgressTab(courseId), store.dispatch); await executeThunk(thunks.fetchProgressTab(courseId), store.dispatch);
await act(async () => render(<LoadedTabPage courseId={courseId} activeTabSlug="progress">...</LoadedTabPage>, { store })); await act(async () => render(<LoadedTabPage courseId={courseId} activeTabSlug="progress">...</LoadedTabPage>, { store }));
expect(screen.queryByText('This learner no longer has access to this course. Their access expired on', { exact: false })).not.toBeInTheDocument(); expect(screen.queryByText('This learner no longer has access to this course. Their access expired on', { exact: false })).not.toBeInTheDocument();
expect(screen.queryByText('1/1/2020')).not.toBeInTheDocument(); expect(screen.queryByText('1/1/2020', { exact: false })).not.toBeInTheDocument();
}); });
}); });
@@ -1312,7 +1332,7 @@ describe('Progress Tab', () => {
await act(async () => render(<LoadedTabPage courseId={courseId} activeTabSlug="progress">...</LoadedTabPage>, { store })); await act(async () => render(<LoadedTabPage courseId={courseId} activeTabSlug="progress">...</LoadedTabPage>, { store }));
expect(screen.getByTestId('instructor-toolbar')).toBeInTheDocument(); expect(screen.getByTestId('instructor-toolbar')).toBeInTheDocument();
expect(screen.getByText('This learner does not yet have access to this course. The course starts on', { exact: false })).toBeInTheDocument(); expect(screen.getByText('This learner does not yet have access to this course. The course starts on', { exact: false })).toBeInTheDocument();
expect(screen.getByText('1/1/2999')).toBeInTheDocument(); expect(screen.getByText('1/1/2999', { exact: false })).toBeInTheDocument();
}); });
it('does not render banner when not masquerading', async () => { it('does not render banner when not masquerading', async () => {
setMetadata({ setMetadata({
@@ -1324,7 +1344,7 @@ describe('Progress Tab', () => {
await executeThunk(thunks.fetchProgressTab(courseId), store.dispatch); await executeThunk(thunks.fetchProgressTab(courseId), store.dispatch);
await act(async () => render(<LoadedTabPage courseId={courseId} activeTabSlug="progress">...</LoadedTabPage>, { store })); await act(async () => render(<LoadedTabPage courseId={courseId} activeTabSlug="progress">...</LoadedTabPage>, { store }));
expect(screen.queryByText('This learner does not yet have access to this course. The course starts on', { exact: false })).not.toBeInTheDocument(); expect(screen.queryByText('This learner does not yet have access to this course. The course starts on', { exact: false })).not.toBeInTheDocument();
expect(screen.queryByText('1/1/2999')).not.toBeInTheDocument(); expect(screen.queryByText('1/1/2999', { exact: false })).not.toBeInTheDocument();
}); });
}); });

View File

@@ -22,6 +22,8 @@ function CertificateStatus({ intl }) {
const { const {
isEnrolled, isEnrolled,
org, org,
canViewCertificate,
userTimezone,
} = useModel('courseHomeMeta', courseId); } = useModel('courseHomeMeta', courseId);
const { const {
@@ -45,6 +47,8 @@ function CertificateStatus({ intl }) {
hasScheduledContent, hasScheduledContent,
isEnrolled, isEnrolled,
userHasPassingGrade, userHasPassingGrade,
null, // CourseExitPageIsActive
canViewCertificate,
); );
const eventProperties = { const eventProperties = {
@@ -57,12 +61,11 @@ function CertificateStatus({ intl }) {
let certStatus; let certStatus;
let certWebViewUrl; let certWebViewUrl;
let downloadUrl; const timezoneFormatArgs = userTimezone ? { timeZone: userTimezone } : {};
if (certificateData) { if (certificateData) {
certStatus = certificateData.certStatus; certStatus = certificateData.certStatus;
certWebViewUrl = certificateData.certWebViewUrl; certWebViewUrl = certificateData.certWebViewUrl;
downloadUrl = certificateData.downloadUrl;
} }
let certCase; let certCase;
@@ -138,15 +141,10 @@ function CertificateStatus({ intl }) {
values={{ dashboardLink, profileLink }} values={{ dashboardLink, profileLink }}
/> />
); );
if (certWebViewUrl) { if (certWebViewUrl) {
certEventName = 'earned_viewable'; certEventName = 'earned_viewable';
buttonLocation = `${getConfig().LMS_BASE_URL}${certWebViewUrl}`; buttonLocation = `${getConfig().LMS_BASE_URL}${certWebViewUrl}`;
buttonText = intl.formatMessage(messages.viewableButton); buttonText = intl.formatMessage(messages.viewableButton);
} else if (downloadUrl) {
certEventName = 'earned_downloadable';
buttonLocation = downloadUrl;
buttonText = intl.formatMessage(messages.downloadableButton);
} }
break; break;
@@ -178,10 +176,22 @@ function CertificateStatus({ intl }) {
} }
break; break;
// This code shouldn't be hit but coding defensively since switch expects a default statement
default: default:
// if user completes a course before certificates are available, treat it as notAvailable
// regardless of passing or nonpassing status
if (!canViewCertificate) {
certCase = 'notAvailable';
endDate = intl.formatDate(end, {
year: 'numeric',
month: 'long',
day: 'numeric',
...timezoneFormatArgs,
});
body = intl.formatMessage(messages.notAvailableEndDateBody, { endDate });
} else {
certCase = null; certCase = null;
certEventName = 'no_certificate_status'; certEventName = 'no_certificate_status';
}
break; break;
} }
} }

View File

@@ -61,11 +61,6 @@ const messages = defineMessages({
defaultMessage: 'Showcase your accomplishment on LinkedIn or your resumé today. You can download your certificate now and access it any time from your Dashboard and Profile.', defaultMessage: 'Showcase your accomplishment on LinkedIn or your resumé today. You can download your certificate now and access it any time from your Dashboard and Profile.',
description: 'Recommending an action for learner when course certificate is available', description: 'Recommending an action for learner when course certificate is available',
}, },
downloadableButton: {
id: 'progress.certificateStatus.downloadableButton',
defaultMessage: 'Download my certificate',
description: 'Button text when learner certifcate status is downloadable',
},
viewableButton: { viewableButton: {
id: 'progress.certificateStatus.viewableButton', id: 'progress.certificateStatus.viewableButton',
defaultMessage: 'View my certificate', defaultMessage: 'View my certificate',
@@ -76,6 +71,11 @@ const messages = defineMessages({
defaultMessage: 'Certificate status', defaultMessage: 'Certificate status',
description: 'Header text when the certifcate is not available', description: 'Header text when the certifcate is not available',
}, },
notAvailableEndDateBody: {
id: 'progress.certificateBody.notAvailable.endDate',
defaultMessage: 'Final grades and any earned certificates are scheduled to be available after {endDate}.',
description: 'Shown for learners who have finished a course before grades and certificates are available.',
},
upgradeHeader: { upgradeHeader: {
id: 'progress.certificateStatus.upgradeHeader', id: 'progress.certificateStatus.upgradeHeader',
defaultMessage: 'Earn a certificate', defaultMessage: 'Earn a certificate',

View File

@@ -1,6 +1,8 @@
import React from 'react'; import React from 'react';
import { useSelector } from 'react-redux'; import { useSelector } from 'react-redux';
import { injectIntl, intlShape } from '@edx/frontend-platform/i18n'; import {
getLocale, injectIntl, intlShape, isRtl,
} from '@edx/frontend-platform/i18n';
import { useModel } from '../../../generic/model-store'; import { useModel } from '../../../generic/model-store';
import CompleteDonutSegment from './CompleteDonutSegment'; import CompleteDonutSegment from './CompleteDonutSegment';
@@ -26,6 +28,8 @@ function CompletionDonutChart({ intl }) {
const lockedPercentage = lockedCount ? Number(((lockedCount / numTotalUnits) * 100).toFixed(0)) : 0; const lockedPercentage = lockedCount ? Number(((lockedCount / numTotalUnits) * 100).toFixed(0)) : 0;
const incompletePercentage = 100 - completePercentage - lockedPercentage; const incompletePercentage = 100 - completePercentage - lockedPercentage;
const isLocaleRtl = isRtl(getLocale());
return ( return (
<> <>
<svg role="img" width="50%" height="100%" viewBox="0 0 42 42" className="donut" style={{ maxWidth: '178px' }} aria-hidden="true"> <svg role="img" width="50%" height="100%" viewBox="0 0 42 42" className="donut" style={{ maxWidth: '178px' }} aria-hidden="true">
@@ -35,7 +39,7 @@ function CompletionDonutChart({ intl }) {
<circle className="donut-hole" fill="#fff" cx="21" cy="21" r="15.91549430918954" /> <circle className="donut-hole" fill="#fff" cx="21" cy="21" r="15.91549430918954" />
<g className="donut-chart-text"> <g className="donut-chart-text">
<text x="50%" y="50%" className="donut-chart-number"> <text x="50%" y="50%" className="donut-chart-number">
{completePercentage}% {completePercentage}{isLocaleRtl && '\u200f'}%
</text> </text>
<text x="50%" y="50%" className="donut-chart-label"> <text x="50%" y="50%" className="donut-chart-label">
{intl.formatMessage(messages.donutLabel)} {intl.formatMessage(messages.donutLabel)}

View File

@@ -41,7 +41,7 @@ function CurrentGradeTooltip({ intl, tooltipClassName }) {
overlay={( overlay={(
<Popover id={`${isPassing ? 'passing' : 'non-passing'}-grade-tooltip`} aria-hidden="true" className={tooltipClassName}> <Popover id={`${isPassing ? 'passing' : 'non-passing'}-grade-tooltip`} aria-hidden="true" className={tooltipClassName}>
<Popover.Content data-testid="currentGradeTooltipContent" className={isPassing ? 'text-white' : 'text-dark-700'}> <Popover.Content data-testid="currentGradeTooltipContent" className={isPassing ? 'text-white' : 'text-dark-700'}>
{currentGrade.toFixed(0)}% {currentGrade.toFixed(0)}{isLocaleRtl ? '\u200f' : ''}%
</Popover.Content> </Popover.Content>
</Popover> </Popover>
)} )}

View File

@@ -2,7 +2,9 @@ import React from 'react';
import { useSelector } from 'react-redux'; import { useSelector } from 'react-redux';
import PropTypes from 'prop-types'; import PropTypes from 'prop-types';
import { injectIntl, intlShape } from '@edx/frontend-platform/i18n'; import {
getLocale, injectIntl, intlShape, isRtl,
} from '@edx/frontend-platform/i18n';
import { useModel } from '../../../../generic/model-store'; import { useModel } from '../../../../generic/model-store';
import CurrentGradeTooltip from './CurrentGradeTooltip'; import CurrentGradeTooltip from './CurrentGradeTooltip';
import PassingGradeTooltip from './PassingGradeTooltip'; import PassingGradeTooltip from './PassingGradeTooltip';
@@ -26,14 +28,16 @@ function GradeBar({ intl, passingGrade }) {
const lockedTooltipClassName = gradesFeatureIsFullyLocked ? 'locked-overlay' : ''; const lockedTooltipClassName = gradesFeatureIsFullyLocked ? 'locked-overlay' : '';
const adjustedRtlStyle = (percentOffest) => (isRtl(getLocale()) ? { transform: `translateX(${100 - percentOffest}%)` } : {});
return ( return (
<div className="col-12 col-sm-6 align-self-center p-0"> <div className="col-12 col-sm-6 align-self-center p-0">
<div className="sr-only">{intl.formatMessage(messages.courseGradeBarAltText, { currentGrade, passingGrade })}</div> <div className="sr-only">{intl.formatMessage(messages.courseGradeBarAltText, { currentGrade, passingGrade })}</div>
<svg width="100%" height="100px" className="grade-bar" aria-hidden="true"> <svg width="100%" height="100px" className="grade-bar" aria-hidden="true">
<g style={{ transform: 'translateY(2.61em)' }}> <g style={{ transform: 'translateY(2.61em)' }}>
<rect className="grade-bar__base" width="100%" /> <rect className="grade-bar__base" width="100%" />
<rect className="grade-bar--passing" width={`${passingGrade}%`} /> <rect className="grade-bar--passing" width={`${passingGrade}%`} style={adjustedRtlStyle(passingGrade)} />
<rect className={`grade-bar--current-${isPassing ? 'passing' : 'non-passing'}`} width={`${currentGrade}%`} /> <rect className={`grade-bar--current-${isPassing ? 'passing' : 'non-passing'}`} width={`${currentGrade}%`} style={adjustedRtlStyle(currentGrade)} />
{/* Start divider */} {/* Start divider */}
<rect className="grade-bar__divider" /> <rect className="grade-bar__divider" />

View File

@@ -25,7 +25,7 @@ function PassingGradeTooltip({ intl, passingGrade, tooltipClassName }) {
overlay={( overlay={(
<Popover id="minimum-grade-tooltip" className={`bg-primary-500 ${tooltipClassName}`} aria-hidden="true"> <Popover id="minimum-grade-tooltip" className={`bg-primary-500 ${tooltipClassName}`} aria-hidden="true">
<Popover.Content className="text-white"> <Popover.Content className="text-white">
{passingGrade}% {passingGrade}{isLocaleRtl && '\u200f'}%
</Popover.Content> </Popover.Content>
</Popover> </Popover>
)} )}

View File

@@ -1,7 +1,6 @@
import React from 'react'; import React from 'react';
import { useSelector } from 'react-redux'; import { useSelector } from 'react-redux';
import { getConfig } from '@edx/frontend-platform';
import { sendTrackEvent } from '@edx/frontend-platform/analytics'; import { sendTrackEvent } from '@edx/frontend-platform/analytics';
import { getAuthenticatedUser } from '@edx/frontend-platform/auth'; import { getAuthenticatedUser } from '@edx/frontend-platform/auth';
import { FormattedMessage, injectIntl, intlShape } from '@edx/frontend-platform/i18n'; import { FormattedMessage, injectIntl, intlShape } from '@edx/frontend-platform/i18n';
@@ -20,6 +19,7 @@ function DetailedGrades({ intl }) {
} = useSelector(state => state.courseHome); } = useSelector(state => state.courseHome);
const { const {
org, org,
tabs,
} = useModel('courseHomeMeta', courseId); } = useModel('courseHomeMeta', courseId);
const { const {
gradesFeatureIsFullyLocked, gradesFeatureIsFullyLocked,
@@ -37,11 +37,14 @@ function DetailedGrades({ intl }) {
}); });
}; };
const outlineLink = ( const overviewTab = tabs.find(tab => tab.slug === 'outline');
const overviewTabUrl = overviewTab && overviewTab.url;
const outlineLink = overviewTabUrl && (
<Hyperlink <Hyperlink
variant="muted" variant="muted"
isInline isInline
destination={`${getConfig().LMS_BASE_URL}/courses/${courseId}/course`} destination={overviewTabUrl}
onClick={logOutlineLinkClick} onClick={logOutlineLinkClick}
tabIndex={gradesFeatureIsFullyLocked ? '-1' : '0'} tabIndex={gradesFeatureIsFullyLocked ? '-1' : '0'}
> >
@@ -64,6 +67,7 @@ function DetailedGrades({ intl }) {
{!hasSectionScores && ( {!hasSectionScores && (
<p className="small">{intl.formatMessage(messages.detailedGradesEmpty)}</p> <p className="small">{intl.formatMessage(messages.detailedGradesEmpty)}</p>
)} )}
{overviewTabUrl && (
<p className="x-small m-0"> <p className="x-small m-0">
<FormattedMessage <FormattedMessage
id="progress.ungradedAlert" id="progress.ungradedAlert"
@@ -72,6 +76,7 @@ function DetailedGrades({ intl }) {
values={{ outlineLink }} values={{ outlineLink }}
/> />
</p> </p>
)}
</section> </section>
); );
} }

View File

@@ -1,7 +1,9 @@
import React from 'react'; import React from 'react';
import { useSelector } from 'react-redux'; import { useSelector } from 'react-redux';
import { injectIntl, intlShape } from '@edx/frontend-platform/i18n'; import {
getLocale, injectIntl, intlShape, isRtl,
} from '@edx/frontend-platform/i18n';
import { DataTable } from '@edx/paragon'; import { DataTable } from '@edx/paragon';
import { useModel } from '../../../../generic/model-store'; import { useModel } from '../../../../generic/model-store';
@@ -17,6 +19,7 @@ function DetailedGradesTable({ intl }) {
sectionScores, sectionScores,
} = useModel('progress', courseId); } = useModel('progress', courseId);
const isLocaleRtl = isRtl(getLocale());
return ( return (
sectionScores.map((chapter) => { sectionScores.map((chapter) => {
const subsectionScores = chapter.subsections.filter( const subsectionScores = chapter.subsections.filter(
@@ -32,7 +35,7 @@ function DetailedGradesTable({ intl }) {
const detailedGradesData = subsectionScores.map((subsection) => ({ const detailedGradesData = subsectionScores.map((subsection) => ({
subsectionTitle: <SubsectionTitleCell subsection={subsection} />, subsectionTitle: <SubsectionTitleCell subsection={subsection} />,
score: <span className={subsection.learnerHasAccess ? '' : 'greyed-out'}>{subsection.numPointsEarned}/{subsection.numPointsPossible}</span>, score: <span className={subsection.learnerHasAccess ? '' : 'greyed-out'}>{subsection.numPointsEarned}{isLocaleRtl ? '\\' : '/'}{subsection.numPointsPossible}</span>,
})); }));
return ( return (

View File

@@ -2,18 +2,21 @@ import React from 'react';
import PropTypes from 'prop-types'; import PropTypes from 'prop-types';
import classNames from 'classnames'; import classNames from 'classnames';
import { injectIntl, intlShape } from '@edx/frontend-platform/i18n'; import {
getLocale, injectIntl, intlShape, isRtl,
} from '@edx/frontend-platform/i18n';
import messages from '../messages'; import messages from '../messages';
function ProblemScoreDrawer({ intl, problemScores, subsection }) { function ProblemScoreDrawer({ intl, problemScores, subsection }) {
const isLocaleRtl = isRtl(getLocale());
return ( return (
<span className="row w-100 m-0 x-small ml-4 pt-2 pl-1 text-gray-700 flex-nowrap"> <span className="row w-100 m-0 x-small ml-4 pt-2 pl-1 text-gray-700 flex-nowrap">
<span id="problem-score-label" className="col-auto p-0">{intl.formatMessage(messages.problemScoreLabel)}</span> <span id="problem-score-label" className="col-auto p-0">{intl.formatMessage(messages.problemScoreLabel)}</span>
<div className={classNames('col', 'p-0', { 'greyed-out': !subsection.learnerHasAccess })}> <div className={classNames('col', 'p-0', { 'greyed-out': !subsection.learnerHasAccess })}>
<ul className="list-unstyled row w-100 m-0" aria-labelledby="problem-score-label"> <ul className="list-unstyled row w-100 m-0" aria-labelledby="problem-score-label">
{problemScores.map(problemScore => ( {problemScores.map(problemScore => (
<li className="ml-3">{problemScore.earned}/{problemScore.possible}</li> <li className="ml-3">{problemScore.earned}{isLocaleRtl ? '\\' : '/'}{problemScore.possible}</li>
))} ))}
</ul> </ul>
</div> </div>

View File

@@ -2,7 +2,9 @@ import React from 'react';
import PropTypes from 'prop-types'; import PropTypes from 'prop-types';
import { useSelector } from 'react-redux'; import { useSelector } from 'react-redux';
import { injectIntl, intlShape } from '@edx/frontend-platform/i18n'; import {
getLocale, injectIntl, intlShape, isRtl,
} from '@edx/frontend-platform/i18n';
import { DataTable } from '@edx/paragon'; import { DataTable } from '@edx/paragon';
import { useModel } from '../../../../generic/model-store'; import { useModel } from '../../../../generic/model-store';
@@ -66,13 +68,15 @@ function GradeSummaryTable({ intl, setAllOfSomeAssignmentTypeIsLocked }) {
const locked = !gradesFeatureIsFullyLocked && hasNoAccessToAssignmentsOfType(assignment.type); const locked = !gradesFeatureIsFullyLocked && hasNoAccessToAssignmentsOfType(assignment.type);
const isLocaleRtl = isRtl(getLocale());
return { return {
type: { type: {
footnoteId, footnoteMarker, type: assignment.type, locked, footnoteId, footnoteMarker, type: assignment.type, locked,
}, },
weight: { weight: `${(assignment.weight * 100).toFixed(0)}%`, locked }, weight: { weight: `${(assignment.weight * 100).toFixed(0)}${isLocaleRtl ? '\u200f' : ''}%`, locked },
grade: { grade: `${(assignment.averageGrade * 100).toFixed(0)}%`, locked }, grade: { grade: `${(assignment.averageGrade * 100).toFixed(0)}${isLocaleRtl ? '\u200f' : ''}%`, locked },
weightedGrade: { weightedGrade: `${(assignment.weightedGrade * 100).toFixed(0)}%`, locked }, weightedGrade: { weightedGrade: `${(assignment.weightedGrade * 100).toFixed(0)}${isLocaleRtl ? '\u200f' : ''}%`, locked },
}; };
}); });

View File

@@ -1,7 +1,9 @@
import React from 'react'; import React from 'react';
import { useSelector } from 'react-redux'; import { useSelector } from 'react-redux';
import { injectIntl, intlShape } from '@edx/frontend-platform/i18n'; import {
getLocale, injectIntl, intlShape, isRtl,
} from '@edx/frontend-platform/i18n';
import { DataTable } from '@edx/paragon'; import { DataTable } from '@edx/paragon';
import { useModel } from '../../../../generic/model-store'; import { useModel } from '../../../../generic/model-store';
@@ -22,11 +24,13 @@ function GradeSummaryTableFooter({ intl }) {
const bgColor = isPassing ? 'bg-success-100' : 'bg-warning-100'; const bgColor = isPassing ? 'bg-success-100' : 'bg-warning-100';
const totalGrade = (percent * 100).toFixed(0); const totalGrade = (percent * 100).toFixed(0);
const isLocaleRtl = isRtl(getLocale());
return ( return (
<DataTable.TableFooter className={`border-top border-primary ${bgColor}`}> <DataTable.TableFooter className={`border-top border-primary ${bgColor}`}>
<div className="row w-100 m-0"> <div className="row w-100 m-0">
<div id="weighted-grade-summary" className="col-8 p-0 small">{intl.formatMessage(messages.weightedGradeSummary)}</div> <div id="weighted-grade-summary" className="col-8 p-0 small">{intl.formatMessage(messages.weightedGradeSummary)}</div>
<div data-testid="gradeSummaryFooterTotalWeightedGrade" aria-labelledby="weighted-grade-summary" className="col-4 p-0 text-right font-weight-bold small">{totalGrade}%</div> <div data-testid="gradeSummaryFooterTotalWeightedGrade" aria-labelledby="weighted-grade-summary" className="col-4 p-0 text-right font-weight-bold small">{totalGrade}{isLocaleRtl && '\u200f'}%</div>
</div> </div>
</DataTable.TableFooter> </DataTable.TableFooter>
); );

View File

@@ -1,7 +1,6 @@
import React from 'react'; import React from 'react';
import { useSelector } from 'react-redux'; import { useSelector } from 'react-redux';
import { getConfig } from '@edx/frontend-platform';
import { sendTrackEvent } from '@edx/frontend-platform/analytics'; import { sendTrackEvent } from '@edx/frontend-platform/analytics';
import { getAuthenticatedUser } from '@edx/frontend-platform/auth'; import { getAuthenticatedUser } from '@edx/frontend-platform/auth';
import { injectIntl, intlShape } from '@edx/frontend-platform/i18n'; import { injectIntl, intlShape } from '@edx/frontend-platform/i18n';
@@ -16,6 +15,7 @@ function RelatedLinks({ intl }) {
} = useSelector(state => state.courseHome); } = useSelector(state => state.courseHome);
const { const {
org, org,
tabs,
} = useModel('courseHomeMeta', courseId); } = useModel('courseHomeMeta', courseId);
const { administrator } = getAuthenticatedUser(); const { administrator } = getAuthenticatedUser();
@@ -28,22 +28,31 @@ function RelatedLinks({ intl }) {
}); });
}; };
const overviewTab = tabs.find(tab => tab.slug === 'outline');
const overviewTabUrl = overviewTab && overviewTab.url;
const datesTab = tabs.find(tab => tab.slug === 'dates');
const datesTabUrl = datesTab && datesTab.url;
return ( return (
<section className="mb-4 x-small"> <section className="mb-4 x-small">
<h3 className="h4">{intl.formatMessage(messages.relatedLinks)}</h3> <h3 className="h4">{intl.formatMessage(messages.relatedLinks)}</h3>
<ul className="pl-4"> <ul className="pl-4">
{datesTabUrl && (
<li> <li>
<Hyperlink destination={`${getConfig().LMS_BASE_URL}/courses/${courseId}/dates`} onClick={() => logLinkClicked('dates')}> <Hyperlink destination={datesTabUrl} onClick={() => logLinkClicked('dates')}>
{intl.formatMessage(messages.datesCardLink)} {intl.formatMessage(messages.datesCardLink)}
</Hyperlink> </Hyperlink>
<p>{intl.formatMessage(messages.datesCardDescription)}</p> <p>{intl.formatMessage(messages.datesCardDescription)}</p>
</li> </li>
)}
{overviewTabUrl && (
<li> <li>
<Hyperlink destination={`${getConfig().LMS_BASE_URL}/courses/${courseId}/course`} onClick={() => logLinkClicked('course_outline')}> <Hyperlink destination={overviewTabUrl} onClick={() => logLinkClicked('course_outline')}>
{intl.formatMessage(messages.outlineCardLink)} {intl.formatMessage(messages.outlineCardLink)}
</Hyperlink> </Hyperlink>
<p>{intl.formatMessage(messages.outlineCardDescription)}</p> <p>{intl.formatMessage(messages.outlineCardDescription)}</p>
</li> </li>
)}
</ul> </ul>
</section> </section>
); );

View File

@@ -450,6 +450,7 @@ describe('CoursewareContainer', () => {
has_access: false, has_access: false,
error_code: errorCode, error_code: errorCode,
additional_context_user_message: 'uhoh oh no', // only used by audit_expired additional_context_user_message: 'uhoh oh no', // only used by audit_expired
developer_message: 'data_sharing_consent_url', // only used by data_sharing_access_required
}, },
}); });
@@ -465,7 +466,7 @@ describe('CoursewareContainer', () => {
const { courseMetadata } = setUpWithDeniedStatus('enrollment_required'); const { courseMetadata } = setUpWithDeniedStatus('enrollment_required');
await loadContainer(); await loadContainer();
expect(global.location.href).toEqual(`http://localhost/redirect/course-home/${courseMetadata.id}`); expect(global.location.href).toEqual(`http://localhost/course/${courseMetadata.id}/home`);
}); });
it('should go to course survey for a survey_required error code', async () => { it('should go to course survey for a survey_required error code', async () => {
@@ -475,11 +476,25 @@ describe('CoursewareContainer', () => {
expect(global.location.href).toEqual(`http://localhost/redirect/survey/${courseMetadata.id}`); expect(global.location.href).toEqual(`http://localhost/redirect/survey/${courseMetadata.id}`);
}); });
it('should go to consent page for a data_sharing_access_required error code', async () => {
setUpWithDeniedStatus('data_sharing_access_required');
await loadContainer();
expect(global.location.href).toEqual('http://localhost/redirect/consent?consentPath=data_sharing_consent_url');
});
it('should go to access denied page for a incorrect_active_enterprise error code', async () => {
const { courseMetadata } = setUpWithDeniedStatus('incorrect_active_enterprise');
await loadContainer();
expect(global.location.href).toEqual(`http://localhost/course/${courseMetadata.id}/access-denied`);
});
it('should go to course home for an authentication_required error code', async () => { it('should go to course home for an authentication_required error code', async () => {
const { courseMetadata } = setUpWithDeniedStatus('authentication_required'); const { courseMetadata } = setUpWithDeniedStatus('authentication_required');
await loadContainer(); await loadContainer();
expect(global.location.href).toEqual(`http://localhost/redirect/course-home/${courseMetadata.id}`); expect(global.location.href).toEqual(`http://localhost/course/${courseMetadata.id}/home`);
}); });
it('should go to dashboard for an unfulfilled_milestones error code', async () => { it('should go to dashboard for an unfulfilled_milestones error code', async () => {
@@ -504,21 +519,4 @@ describe('CoursewareContainer', () => {
expect(global.location.href).toEqual(`http://localhost/redirect/dashboard?notlive=${startDate}`); expect(global.location.href).toEqual(`http://localhost/redirect/dashboard?notlive=${startDate}`);
}); });
}); });
describe('redirects when canLoadCourseware is false', () => {
it('should go to legacy courseware for disabled frontend', async () => {
const courseMetadata = Factory.build('courseMetadata');
const courseHomeMetadata = Factory.build('courseHomeMetadata', {
can_load_courseware: false,
});
const courseId = courseMetadata.id;
const { courseBlocks, sequenceBlocks, unitBlocks } = buildSimpleCourseBlocks(courseId, courseMetadata.name);
setUpMockRequests({ courseBlocks, courseMetadata, courseHomeMetadata });
history.push(`/course/${courseId}/${sequenceBlocks[0].id}/${unitBlocks[0].id}`);
await loadContainer();
expect(global.location.href).toEqual(`http://localhost/redirect/courseware/${courseMetadata.id}/unit/${unitBlocks[0].id}`);
});
});
}); });

View File

@@ -4,6 +4,7 @@ import { getConfig } from '@edx/frontend-platform';
import { FormattedMessage } from '@edx/frontend-platform/i18n'; import { FormattedMessage } from '@edx/frontend-platform/i18n';
import { PageRoute } from '@edx/frontend-platform/react'; import { PageRoute } from '@edx/frontend-platform/react';
import queryString from 'query-string';
import PageLoading from '../generic/PageLoading'; import PageLoading from '../generic/PageLoading';
export default () => { export default () => {
@@ -20,18 +21,6 @@ export default () => {
/> />
<Switch> <Switch>
<PageRoute
path={`${path}/courseware/:courseId/unit/:unitId`}
render={({ match }) => {
global.location.assign(`${getConfig().LMS_BASE_URL}/courses/${match.params.courseId}/jump_to/${match.params.unitId}?experience=legacy`);
}}
/>
<PageRoute
path={`${path}/course-home/:courseId`}
render={({ match }) => {
global.location.assign(`${getConfig().LMS_BASE_URL}/courses/${match.params.courseId}/course/`);
}}
/>
<PageRoute <PageRoute
path={`${path}/survey/:courseId`} path={`${path}/survey/:courseId`}
render={({ match }) => { render={({ match }) => {
@@ -44,6 +33,19 @@ export default () => {
global.location.assign(`${getConfig().LMS_BASE_URL}/dashboard${location.search}`); global.location.assign(`${getConfig().LMS_BASE_URL}/dashboard${location.search}`);
}} }}
/> />
<PageRoute
path={`${path}/consent/`}
render={({ location }) => {
const { consentPath } = queryString.parse(location.search);
global.location.assign(`${getConfig().LMS_BASE_URL}${consentPath}`);
}}
/>
<PageRoute
path={`${path}/home/:courseId`}
render={({ match }) => {
global.location.assign(`/course/${match.params.courseId}/home`);
}}
/>
</Switch> </Switch>
</div> </div>
); );

View File

@@ -0,0 +1,52 @@
import React from 'react';
import { Router } from 'react-router';
import { createMemoryHistory } from 'history';
import { render, initializeMockApp } from '../setupTest';
import CoursewareRedirectLandingPage from './CoursewareRedirectLandingPage';
const redirectUrl = jest.fn();
jest.mock('@edx/frontend-platform/analytics');
jest.mock('react-router', () => ({
...jest.requireActual('react-router'),
useRouteMatch: () => ({
path: '/redirect',
}),
}));
describe('CoursewareRedirectLandingPage', () => {
beforeEach(async () => {
await initializeMockApp();
delete global.location;
global.location = { assign: redirectUrl };
});
it('Redirects to correct consent URL', () => {
const history = createMemoryHistory({
initialEntries: ['/redirect/consent/?consentPath=%2Fgrant_data_sharing_consent'],
});
render(
<Router history={history}>
<CoursewareRedirectLandingPage />
</Router>,
);
expect(redirectUrl).toHaveBeenCalledWith('http://localhost:18000/grant_data_sharing_consent');
});
it('Redirects to correct consent URL', () => {
const history = createMemoryHistory({
initialEntries: ['/redirect/home/course-v1:edX+DemoX+Demo_Course'],
});
render(
<Router history={history}>
<CoursewareRedirectLandingPage />
</Router>,
);
expect(redirectUrl).toHaveBeenCalledWith('/course/course-v1:edX+DemoX+Demo_Course/home');
});
});

View File

@@ -1,4 +1,4 @@
import React, { useState } from 'react'; import React, { useEffect, useState } from 'react';
import PropTypes from 'prop-types'; import PropTypes from 'prop-types';
import { Helmet } from 'react-helmet'; import { Helmet } from 'react-helmet';
import { useDispatch } from 'react-redux'; import { useDispatch } from 'react-redux';
@@ -46,10 +46,8 @@ function Course({
// Below the tabs, above the breadcrumbs alerts (appearing in the order listed here) // Below the tabs, above the breadcrumbs alerts (appearing in the order listed here)
const dispatch = useDispatch(); const dispatch = useDispatch();
const celebrateFirstSection = celebrations && celebrations.firstSection;
const [firstSectionCelebrationOpen, setFirstSectionCelebrationOpen] = useState(shouldCelebrateOnSectionLoad( const [firstSectionCelebrationOpen, setFirstSectionCelebrationOpen] = useState(false);
courseId, sequenceId, celebrateFirstSection, dispatch, celebrations,
));
// If streakLengthToCelebrate is populated, that modal takes precedence. Wait til the next load to display // If streakLengthToCelebrate is populated, that modal takes precedence. Wait til the next load to display
// the weekly goal celebration modal. // the weekly goal celebration modal.
const [weeklyGoalCelebrationOpen, setWeeklyGoalCelebrationOpen] = useState( const [weeklyGoalCelebrationOpen, setWeeklyGoalCelebrationOpen] = useState(
@@ -74,6 +72,17 @@ function Course({
/** [MM-P2P] Experiment */ /** [MM-P2P] Experiment */
const MMP2P = initCoursewareMMP2P(courseId, sequenceId, unitId); const MMP2P = initCoursewareMMP2P(courseId, sequenceId, unitId);
useEffect(() => {
const celebrateFirstSection = celebrations && celebrations.firstSection;
setFirstSectionCelebrationOpen(shouldCelebrateOnSectionLoad(
courseId,
sequenceId,
celebrateFirstSection,
dispatch,
celebrations,
));
}, [sequenceId]);
return ( return (
<SidebarProvider courseId={courseId} unitId={unitId}> <SidebarProvider courseId={courseId} unitId={unitId}>
<Helmet> <Helmet>

View File

@@ -3,6 +3,7 @@ import { screen, render } from '@testing-library/react';
import { getAuthenticatedUser } from '@edx/frontend-platform/auth'; import { getAuthenticatedUser } from '@edx/frontend-platform/auth';
import { getConfig } from '@edx/frontend-platform'; import { getConfig } from '@edx/frontend-platform';
import { BrowserRouter } from 'react-router-dom'; import { BrowserRouter } from 'react-router-dom';
import { IntlProvider } from '@edx/frontend-platform/i18n';
import { useModel, useModels } from '../../generic/model-store'; import { useModel, useModels } from '../../generic/model-store';
import CourseBreadcrumbs from './CourseBreadcrumbs'; import CourseBreadcrumbs from './CourseBreadcrumbs';
@@ -106,6 +107,7 @@ describe('CourseBreadcrumbs', () => {
], ],
]); ]);
render( render(
<IntlProvider>
<BrowserRouter> <BrowserRouter>
<CourseBreadcrumbs <CourseBreadcrumbs
courseId="course-v1:edX+DemoX+Demo_Course" courseId="course-v1:edX+DemoX+Demo_Course"
@@ -114,6 +116,7 @@ describe('CourseBreadcrumbs', () => {
isStaff isStaff
/> />
</BrowserRouter>, </BrowserRouter>,
</IntlProvider>,
); );
it('renders course breadcrumbs as expected', async () => { it('renders course breadcrumbs as expected', async () => {
expect(screen.queryAllByRole('link')).toHaveLength(1); expect(screen.queryAllByRole('link')).toHaveLength(1);

View File

@@ -1,12 +1,12 @@
import React from 'react'; import React from 'react';
import MockAdapter from 'axios-mock-adapter'; import MockAdapter from 'axios-mock-adapter';
import { getAuthenticatedHttpClient } from '@edx/frontend-platform/auth'; import { getAuthenticatedHttpClient } from '@edx/frontend-platform/auth';
import { getConfig } from '@edx/frontend-platform';
import { Factory } from 'rosie'; import { Factory } from 'rosie';
import { import {
render, screen, fireEvent, initializeTestStore, waitFor, authenticatedUser, logUnhandledRequests, render, screen, fireEvent, initializeTestStore, waitFor, authenticatedUser, logUnhandledRequests,
} from '../../../setupTest'; } from '../../../setupTest';
import { BookmarkButton } from './index'; import { BookmarkButton } from './index';
import { getBookmarksBaseUrl } from './data/api';
describe('Bookmark Button', () => { describe('Bookmark Button', () => {
let axiosMock; let axiosMock;
@@ -32,7 +32,8 @@ describe('Bookmark Button', () => {
mockData.unitId = nonBookmarkedUnitBlock.id; mockData.unitId = nonBookmarkedUnitBlock.id;
axiosMock = new MockAdapter(getAuthenticatedHttpClient()); axiosMock = new MockAdapter(getAuthenticatedHttpClient());
const bookmarkUrl = `${getConfig().LMS_BASE_URL}/api/bookmarks/v1/bookmarks/`; const bookmarkUrl = getBookmarksBaseUrl();
axiosMock.onPost(bookmarkUrl).reply(200, { }); axiosMock.onPost(bookmarkUrl).reply(200, { });
const bookmarkDeleteUrlRegExp = new RegExp(`${bookmarkUrl}*,*`); const bookmarkDeleteUrlRegExp = new RegExp(`${bookmarkUrl}*,*`);

View File

@@ -1,13 +1,13 @@
import { getConfig } from '@edx/frontend-platform'; import { getConfig } from '@edx/frontend-platform';
import { getAuthenticatedHttpClient, getAuthenticatedUser } from '@edx/frontend-platform/auth'; import { getAuthenticatedHttpClient, getAuthenticatedUser } from '@edx/frontend-platform/auth';
const bookmarksBaseUrl = `${getConfig().LMS_BASE_URL}/api/bookmarks/v1/bookmarks/`; export const getBookmarksBaseUrl = () => `${getConfig().LMS_BASE_URL}/api/bookmarks/v1/bookmarks/`;
export async function createBookmark(usageId) { export async function createBookmark(usageId) {
return getAuthenticatedHttpClient().post(bookmarksBaseUrl, { usage_id: usageId }); return getAuthenticatedHttpClient().post(getBookmarksBaseUrl(), { usage_id: usageId });
} }
export async function deleteBookmark(usageId) { export async function deleteBookmark(usageId) {
const { username } = getAuthenticatedUser(); const { username } = getAuthenticatedUser();
return getAuthenticatedHttpClient().delete(`${bookmarksBaseUrl}${username},${usageId}/`); return getAuthenticatedHttpClient().delete(`${getBookmarksBaseUrl()}${username},${usageId}/`);
} }

View File

@@ -9,6 +9,7 @@ import {
useWindowSize, useWindowSize,
} from '@edx/paragon'; } from '@edx/paragon';
import { useDispatch } from 'react-redux';
import ClapsMobile from './assets/claps_280x201.gif'; import ClapsMobile from './assets/claps_280x201.gif';
import ClapsTablet from './assets/claps_456x328.gif'; import ClapsTablet from './assets/claps_456x328.gif';
import messages from './messages'; import messages from './messages';
@@ -19,12 +20,13 @@ import { useModel } from '../../../generic/model-store';
function CelebrationModal({ function CelebrationModal({
courseId, intl, isOpen, onClose, ...rest courseId, intl, isOpen, onClose, ...rest
}) { }) {
const { org } = useModel('courseHomeMeta', courseId); const { org, celebrations } = useModel('courseHomeMeta', courseId);
const dispatch = useDispatch();
const wideScreen = useWindowSize().width >= breakpoints.small.minWidth; const wideScreen = useWindowSize().width >= breakpoints.small.minWidth;
useEffect(() => { useEffect(() => {
if (isOpen) { if (isOpen) {
recordFirstSectionCelebration(org, courseId); recordFirstSectionCelebration(org, courseId, celebrations, dispatch);
} }
}, [isOpen]); }, [isOpen]);

View File

@@ -57,6 +57,7 @@ function WeeklyGoalCelebrationModal({
className="mr-2" className="mr-2"
style={{ height: '21px', width: '22px' }} style={{ height: '21px', width: '22px' }}
/> />
<div>
<FormattedMessage <FormattedMessage
id="learning.celebration.setGoal" id="learning.celebration.setGoal"
defaultMessage="Setting a goal can help you {strongText} in your course." defaultMessage="Setting a goal can help you {strongText} in your course."
@@ -66,6 +67,7 @@ function WeeklyGoalCelebrationModal({
}} }}
/> />
</div> </div>
</div>
</> </>
</StandardModal> </StandardModal>
); );

View File

@@ -15,9 +15,20 @@ function handleNextSectionCelebration(sequenceId, nextSequenceId) {
}); });
} }
function recordFirstSectionCelebration(org, courseId) { function recordFirstSectionCelebration(org, courseId, celebrations, dispatch) {
// Tell the LMS // Tell the LMS
postCelebrationComplete(courseId, { first_section: false }); postCelebrationComplete(courseId, { first_section: false });
// Update our local copy of course data from LMS
dispatch(updateModel({
modelType: 'courseHomeMeta',
model: {
id: courseId,
celebrations: {
...celebrations,
firstSection: false,
},
},
}));
// Tell our analytics // Tell our analytics
const { administrator } = getAuthenticatedUser(); const { administrator } = getAuthenticatedUser();

View File

@@ -0,0 +1,15 @@
import { recordFirstSectionCelebration } from './utils';
jest.mock('@edx/frontend-platform/analytics');
jest.mock('./data/api');
jest.mock('@edx/frontend-platform/auth', () => ({
getAuthenticatedUser: jest.fn(() => ({ administrator: 'admin' })),
}));
describe('recordFirstSectionCelebration', () => {
it('updates the local copy of the course data from the LMS', async () => {
const dispatchMock = jest.fn();
recordFirstSectionCelebration('org', 'courseId', 'celebration', dispatchMock);
expect(dispatchMock).toHaveBeenCalled();
});
});

View File

@@ -1,6 +1,5 @@
.content-tools { .content-tools {
position: fixed; position: fixed;
left: 0;
right: 0; right: 0;
bottom: 0; bottom: 0;
z-index: 100; z-index: 100;

View File

@@ -55,12 +55,13 @@ function CourseCelebration({ intl }) {
const { const {
org, org,
verifiedMode, verifiedMode,
canViewCertificate,
userTimezone,
} = useModel('courseHomeMeta', courseId); } = useModel('courseHomeMeta', courseId);
const { const {
certStatus, certStatus,
certWebViewUrl, certWebViewUrl,
downloadUrl,
certificateAvailableDate, certificateAvailableDate,
} = certificateData || {}; } = certificateData || {};
@@ -69,6 +70,7 @@ function CourseCelebration({ intl }) {
const dashboardLink = <DashboardLink />; const dashboardLink = <DashboardLink />;
const idVerificationSupportLink = <IdVerificationSupportLink />; const idVerificationSupportLink = <IdVerificationSupportLink />;
const profileLink = <ProfileLink />; const profileLink = <ProfileLink />;
const timezoneFormatArgs = userTimezone ? { timeZone: userTimezone } : {};
let buttonPrefix = null; let buttonPrefix = null;
let buttonLocation; let buttonLocation;
@@ -101,9 +103,6 @@ function CourseCelebration({ intl }) {
if (certWebViewUrl) { if (certWebViewUrl) {
buttonLocation = `${getConfig().LMS_BASE_URL}${certWebViewUrl}`; buttonLocation = `${getConfig().LMS_BASE_URL}${certWebViewUrl}`;
buttonText = intl.formatMessage(messages.viewCertificateButton); buttonText = intl.formatMessage(messages.viewCertificateButton);
} else if (downloadUrl) {
buttonLocation = downloadUrl;
buttonText = intl.formatMessage(messages.downloadButton);
} }
if (linkedinAddToProfileUrl) { if (linkedinAddToProfileUrl) {
buttonPrefix = ( buttonPrefix = (
@@ -248,6 +247,29 @@ function CourseCelebration({ intl }) {
} }
break; break;
default: default:
if (!canViewCertificate) {
// We reuse the cert event here. Since this default state is so
// Similar to the earned_not_available state, this event name should be fine
// to cover the same cases.
visitEvent = 'celebration_with_unavailable_cert';
certHeader = intl.formatMessage(messages.certificateHeaderNotAvailable);
const endDate = intl.formatDate(end, {
year: 'numeric',
month: 'long',
day: 'numeric',
...timezoneFormatArgs,
});
message = (
<>
<p>
{intl.formatMessage(messages.certificateNotAvailableEndDateBody, { endDate })}
</p>
<p>
{intl.formatMessage(messages.certificateNotAvailableBodyAccessCert)}
</p>
</>
);
}
break; break;
} }

View File

@@ -27,7 +27,10 @@ function CourseExit({ intl }) {
userHasPassingGrade, userHasPassingGrade,
} = useModel('coursewareMeta', courseId); } = useModel('coursewareMeta', courseId);
const { isMasquerading } = useModel('courseHomeMeta', courseId); const {
isMasquerading,
canViewCertificate,
} = useModel('courseHomeMeta', courseId);
const mode = getCourseExitMode( const mode = getCourseExitMode(
certificateData, certificateData,
@@ -35,6 +38,7 @@ function CourseExit({ intl }) {
isEnrolled, isEnrolled,
userHasPassingGrade, userHasPassingGrade,
courseExitPageIsActive, courseExitPageIsActive,
canViewCertificate,
); );
// Audit users cannot fully complete a course, so we will // Audit users cannot fully complete a course, so we will

View File

@@ -38,6 +38,9 @@ describe('Course Exit Pages', () => {
const discoveryRecommendationsUrl = new RegExp(`${getConfig().DISCOVERY_API_BASE_URL}/api/v1/course_recommendations/*`); const discoveryRecommendationsUrl = new RegExp(`${getConfig().DISCOVERY_API_BASE_URL}/api/v1/course_recommendations/*`);
const enrollmentsUrl = new RegExp(`${getConfig().LMS_BASE_URL}/api/enrollment/v1/enrollment*`); const enrollmentsUrl = new RegExp(`${getConfig().LMS_BASE_URL}/api/enrollment/v1/enrollment*`);
const learningSequencesUrlRegExp = new RegExp(`${getConfig().LMS_BASE_URL}/api/learning_sequences/v1/course_outline/*`); const learningSequencesUrlRegExp = new RegExp(`${getConfig().LMS_BASE_URL}/api/learning_sequences/v1/course_outline/*`);
const now = new Date();
const tomorrow = new Date(now.getFullYear(), now.getMonth(), now.getDate() + 1);
const overmorrow = new Date(now.getFullYear(), now.getMonth(), now.getDate() + 2);
function setMetadata(coursewareAttributes, courseHomeAttributes = {}) { function setMetadata(coursewareAttributes, courseHomeAttributes = {}) {
const extendedCourseMetadata = { ...coursewareMetadata, ...coursewareAttributes }; const extendedCourseMetadata = { ...coursewareMetadata, ...coursewareAttributes };
@@ -105,17 +108,6 @@ describe('Course Exit Pages', () => {
}); });
describe('Course Celebration Experience', () => { describe('Course Celebration Experience', () => {
it('Displays download link', async () => {
setMetadata({
certificate_data: {
cert_status: 'downloadable',
download_url: 'fake.download.url',
},
});
await fetchAndRender(<CourseCelebration />);
expect(screen.getByRole('link', { name: 'Download my certificate' })).toBeInTheDocument();
});
it('Displays webview link', async () => { it('Displays webview link', async () => {
setMetadata({ setMetadata({
certificate_data: { certificate_data: {
@@ -363,6 +355,65 @@ describe('Course Exit Pages', () => {
expect(screen.queryByText('Same Course')).not.toBeInTheDocument(); expect(screen.queryByText('Same Course')).not.toBeInTheDocument();
}); });
}); });
it('Shows not available messaging before certificates are available to nonpassing learners when theres no certificate data', async () => {
setMetadata({
is_enrolled: true,
end: tomorrow.toISOString(),
user_has_passing_grade: false,
certificate_data: undefined,
}, {
can_view_certificate: false,
});
await fetchAndRender(<CourseCelebration />);
expect(screen.getByText(`Final grades and any earned certificates are scheduled to be available after ${tomorrow.toLocaleDateString('en-us', {
year: 'numeric',
month: 'long',
day: 'numeric',
})}.`)).toBeInTheDocument();
});
it('Shows not available messaging before certificates are available to passing learners when theres no certificate data', async () => {
setMetadata({
is_enrolled: true,
end: tomorrow.toISOString(),
user_has_passing_grade: true,
certificate_data: undefined,
}, {
can_view_certificate: false,
});
await fetchAndRender(<CourseCelebration />);
expect(screen.getByText(`Final grades and any earned certificates are scheduled to be available after ${tomorrow.toLocaleDateString('en-us', {
year: 'numeric',
month: 'long',
day: 'numeric',
})}.`)).toBeInTheDocument();
});
it('Shows certificate_available_date if learner is passing', async () => {
setMetadata({
is_enrolled: true,
end: tomorrow.toISOString(),
user_has_passing_grade: true,
certificate_data: {
cert_status: 'earned_but_not_available',
certificate_available_date: overmorrow.toISOString(),
},
}, {
can_view_certificate: false,
});
await fetchAndRender(<CourseCelebration />);
expect(screen.getByText('Your grade and certificate status will be available soon.'));
expect(screen.getByText(
overmorrow.toLocaleDateString('en-us', {
year: 'numeric',
month: 'long',
day: 'numeric',
}),
{ exact: false },
)).toBeInTheDocument();
});
}); });
describe('Course Non-passing Experience', () => { describe('Course Non-passing Experience', () => {

View File

@@ -21,6 +21,11 @@ const messages = defineMessages({
defaultMessage: 'If you have earned a passing grade, your certificate will be automatically issued.', defaultMessage: 'If you have earned a passing grade, your certificate will be automatically issued.',
description: 'Text displayed when course certificate is not yet available to be viewed', description: 'Text displayed when course certificate is not yet available to be viewed',
}, },
certificateNotAvailableEndDateBody: {
id: 'courseCelebration.certificateBody.notAvailable.endDate',
defaultMessage: 'Final grades and any earned certificates are scheduled to be available after {endDate}.',
description: 'Shown for learners who have finished a course before grades and certificates are available.',
},
certificateHeaderUnverified: { certificateHeaderUnverified: {
id: 'courseCelebration.certificateHeader.unverified', id: 'courseCelebration.certificateHeader.unverified',
defaultMessage: 'You must complete verification to receive your certificate.', defaultMessage: 'You must complete verification to receive your certificate.',
@@ -71,11 +76,6 @@ const messages = defineMessages({
defaultMessage: 'Dashboard', defaultMessage: 'Dashboard',
description: 'Link to users dashboard', description: 'Link to users dashboard',
}, },
downloadButton: {
id: 'courseCelebration.downloadButton',
defaultMessage: 'Download my certificate',
description: 'Button to download the course certificate',
},
endOfCourseDescription: { endOfCourseDescription: {
id: 'courseExit.endOfCourseDescription', id: 'courseExit.endOfCourseDescription',
defaultMessage: 'Unfortunately, you are not currently eligible for a certificate. You need to receive a passing grade to be eligible for a certificate.', defaultMessage: 'Unfortunately, you are not currently eligible for a certificate. You need to receive a passing grade to be eligible for a certificate.',

View File

@@ -31,6 +31,7 @@ function getCourseExitMode(
isEnrolled, isEnrolled,
userHasPassingGrade, userHasPassingGrade,
courseExitPageIsActive = null, courseExitPageIsActive = null,
canImmediatelyViewCertificate = false,
) { ) {
const authenticatedUser = getAuthenticatedUser(); const authenticatedUser = getAuthenticatedUser();
@@ -55,7 +56,7 @@ function getCourseExitMode(
if (hasScheduledContent && !userHasPassingGrade) { if (hasScheduledContent && !userHasPassingGrade) {
return COURSE_EXIT_MODES.inProgress; return COURSE_EXIT_MODES.inProgress;
} }
if (isEligibleForCertificate && !userHasPassingGrade) { if (isEligibleForCertificate && !userHasPassingGrade && canImmediatelyViewCertificate) {
return COURSE_EXIT_MODES.nonPassing; return COURSE_EXIT_MODES.nonPassing;
} }
if (isCelebratoryStatus) { if (isCelebratoryStatus) {
@@ -73,12 +74,14 @@ function getCourseExitNavigation(courseId, intl) {
userHasPassingGrade, userHasPassingGrade,
courseExitPageIsActive, courseExitPageIsActive,
} = useModel('coursewareMeta', courseId); } = useModel('coursewareMeta', courseId);
const { canViewCertificate } = useModel('courseHomeMeta', courseId);
const exitMode = getCourseExitMode( const exitMode = getCourseExitMode(
certificateData, certificateData,
hasScheduledContent, hasScheduledContent,
isEnrolled, isEnrolled,
userHasPassingGrade, userHasPassingGrade,
courseExitPageIsActive, courseExitPageIsActive,
canViewCertificate,
); );
const exitActive = exitMode !== COURSE_EXIT_MODES.disabled; const exitActive = exitMode !== COURSE_EXIT_MODES.disabled;

View File

@@ -14,7 +14,7 @@ describe('Sequence Content', () => {
courseId: courseware.courseId, courseId: courseware.courseId,
sequenceId: courseware.sequenceId, sequenceId: courseware.sequenceId,
unitId: models.sequences[courseware.sequenceId].unitIds[0], unitId: models.sequences[courseware.sequenceId].unitIds[0],
unitLoadedHandler: () => {}, unitLoadedHandler: () => { },
}; };
}); });

View File

@@ -96,6 +96,7 @@ function Unit({
const [showError, setShowError] = useState(false); const [showError, setShowError] = useState(false);
const [modalOptions, setModalOptions] = useState({ open: false }); const [modalOptions, setModalOptions] = useState({ open: false });
const [shouldDisplayHonorCode, setShouldDisplayHonorCode] = useState(false); const [shouldDisplayHonorCode, setShouldDisplayHonorCode] = useState(false);
const [windowTopOffset, setWindowTopOffset] = useState(null);
const unit = useModel('units', id); const unit = useModel('units', id);
const course = useModel('coursewareMeta', courseId); const course = useModel('coursewareMeta', courseId);
@@ -123,6 +124,13 @@ function Unit({
} = data; } = data;
if (type === 'plugin.resize') { if (type === 'plugin.resize') {
setIframeHeight(payload.height); setIframeHeight(payload.height);
// We observe exit from the video xblock full screen mode
// and do page scroll to the previously saved scroll position
if (windowTopOffset !== null) {
window.scrollTo(0, Number(windowTopOffset));
}
if (!hasLoaded && iframeHeight === 0 && payload.height > 0) { if (!hasLoaded && iframeHeight === 0 && payload.height > 0) {
setHasLoaded(true); setHasLoaded(true);
if (onLoaded) { if (onLoaded) {
@@ -132,12 +140,16 @@ function Unit({
} else if (type === 'plugin.modal') { } else if (type === 'plugin.modal') {
payload.open = true; payload.open = true;
setModalOptions(payload); setModalOptions(payload);
} else if (type === 'plugin.videoFullScreen') {
// We listen for this message from LMS to know when we need to
// save or reset scroll position on toggle video xblock full screen mode.
setWindowTopOffset(payload.open ? window.scrollY : null);
} else if (data.offset) { } else if (data.offset) {
// We listen for this message from LMS to know when the page needs to // We listen for this message from LMS to know when the page needs to
// be scrolled to another location on the page. // be scrolled to another location on the page.
window.scrollTo(0, data.offset + document.getElementById('unit-iframe').offsetTop); window.scrollTo(0, data.offset + document.getElementById('unit-iframe').offsetTop);
} }
}, [id, setIframeHeight, hasLoaded, iframeHeight, setHasLoaded, onLoaded]); }, [id, setIframeHeight, hasLoaded, iframeHeight, setHasLoaded, onLoaded, setWindowTopOffset, windowTopOffset]);
useEventListener('message', receiveMessage); useEventListener('message', receiveMessage);
useEffect(() => { useEffect(() => {
sendUrlHashToFrame(document.getElementById('unit-iframe')); sendUrlHashToFrame(document.getElementById('unit-iframe'));

View File

@@ -131,6 +131,21 @@ describe('Unit', () => {
expect(window.scrollY === testMessageWithOffset.offset); expect(window.scrollY === testMessageWithOffset.offset);
}); });
it('scrolls page on MessageEvent when receiving videoFullScreen state', async () => {
// Set message to contain video full screen data.
const defaultTopOffset = 800;
const testMessageWithOtherHeight = { ...messageEvent, payload: { height: 500 } };
const testMessageWithFullscreenState = (isOpen) => ({ type: 'plugin.videoFullScreen', payload: { open: isOpen } });
render(<Unit {...mockData} />);
Object.defineProperty(window, 'scrollY', { value: defaultTopOffset, writable: true });
window.postMessage(testMessageWithFullscreenState(true), '*');
window.postMessage(testMessageWithFullscreenState(false), '*');
window.postMessage(testMessageWithOtherHeight, '*');
await expect(waitFor(() => expect(window.scrollTo()).toHaveBeenCalledTimes(1)));
expect(window.scrollY === defaultTopOffset);
});
it('ignores MessageEvent with unhandled type', async () => { it('ignores MessageEvent with unhandled type', async () => {
// Clone message and set different type. // Clone message and set different type.
const testMessageWithUnhandledType = { ...messageEvent, type: 'wrong type' }; const testMessageWithUnhandledType = { ...messageEvent, type: 'wrong type' };

View File

@@ -8,7 +8,7 @@ const messages = defineMessages({
}, },
'learn.contentLock.complete.prerequisite': { 'learn.contentLock.complete.prerequisite': {
id: 'learn.contentLock.complete.prerequisite', id: 'learn.contentLock.complete.prerequisite',
defaultMessage: "You must complete the prerequisite: '{prereqSectionName}' to access this content.", defaultMessage: "You must complete the prerequisite: ''{prereqSectionName}'' to access this content.",
description: 'Message shown to indicate which prerequisite the student must complete prior to accessing the locked content. {prereqSectionName} is the name of the prerequisite.', description: 'Message shown to indicate which prerequisite the student must complete prior to accessing the locked content. {prereqSectionName} is the name of the prerequisite.',
}, },
'learn.contentLock.goToSection': { 'learn.contentLock.goToSection': {

View File

@@ -88,7 +88,7 @@ SidebarBase.propTypes = {
title: PropTypes.string.isRequired, title: PropTypes.string.isRequired,
ariaLabel: PropTypes.string.isRequired, ariaLabel: PropTypes.string.isRequired,
sidebarId: PropTypes.string.isRequired, sidebarId: PropTypes.string.isRequired,
className: PropTypes.string.isRequired, className: PropTypes.string,
children: PropTypes.element.isRequired, children: PropTypes.element.isRequired,
showTitleBar: PropTypes.bool, showTitleBar: PropTypes.bool,
width: PropTypes.string, width: PropTypes.string,
@@ -97,6 +97,7 @@ SidebarBase.propTypes = {
SidebarBase.defaultProps = { SidebarBase.defaultProps = {
width: '31rem', width: '31rem',
showTitleBar: true, showTitleBar: true,
className: '',
}; };
export default injectIntl(SidebarBase); export default injectIntl(SidebarBase);

View File

@@ -16,10 +16,10 @@ function DiscussionsSidebar({ intl }) {
courseId, courseId,
} = useContext(SidebarContext); } = useContext(SidebarContext);
const topic = useModel('discussionTopics', unitId); const topic = useModel('discussionTopics', unitId);
if (!topic) { if (!topic?.id) {
return null; return null;
} }
const discussionsUrl = `${getConfig().DISCUSSIONS_MFE_BASE_URL}/${courseId}/topics/${topic.id}`; const discussionsUrl = `${getConfig().DISCUSSIONS_MFE_BASE_URL}/${courseId}/category/${unitId}`;
return ( return (
<SidebarBase <SidebarBase
title={intl.formatMessage(messages.discussionsTitle)} title={intl.formatMessage(messages.discussionsTitle)}
@@ -31,8 +31,6 @@ function DiscussionsSidebar({ intl }) {
<iframe <iframe
src={`${discussionsUrl}?inContext`} src={`${discussionsUrl}?inContext`}
className="d-flex w-100 border-0" className="d-flex w-100 border-0"
// Need to set minHeight so there is enough space for the add post UI
// TODO: Use postMessage API to dynamically update iframe size.
style={{ minHeight: '60rem' }} style={{ minHeight: '60rem' }}
title={intl.formatMessage(messages.discussionsTitle)} title={intl.formatMessage(messages.discussionsTitle)}
/> />

View File

@@ -0,0 +1,69 @@
import { getConfig } from '@edx/frontend-platform';
import { getAuthenticatedHttpClient } from '@edx/frontend-platform/auth';
import MockAdapter from 'axios-mock-adapter';
import React from 'react';
import {
initializeMockApp, initializeTestStore, render, screen,
} from '../../../../../setupTest';
import { executeThunk } from '../../../../../utils';
import { buildTopicsFromUnits } from '../../../../data/__factories__/discussionTopics.factory';
import { getCourseDiscussionTopics } from '../../../../data/thunks';
import SidebarContext from '../../SidebarContext';
import DiscussionsSidebar from './DiscussionsSidebar';
initializeMockApp();
describe('Discussions Trigger', () => {
let axiosMock;
let mockData;
let courseId;
let unitId;
beforeEach(async () => {
const store = await initializeTestStore({
excludeFetchCourse: false,
excludeFetchSequence: false,
});
axiosMock = new MockAdapter(getAuthenticatedHttpClient());
const state = store.getState();
courseId = state.courseware.courseId;
[unitId] = Object.keys(state.models.units);
mockData = {
courseId,
unitId,
currentSidebar: 'DISCUSSIONS',
};
axiosMock.onGet(`${getConfig().LMS_BASE_URL}/api/discussion/v1/courses/${courseId}`).reply(
200,
{
provider: 'openedx',
},
);
axiosMock.onGet(`${getConfig().LMS_BASE_URL}/api/discussion/v2/course_topics/${courseId}`)
.reply(200, buildTopicsFromUnits(state.models.units));
await executeThunk(getCourseDiscussionTopics(courseId), store.dispatch);
});
function renderWithProvider(testData = {}) {
const { container } = render(
<SidebarContext.Provider value={{ ...mockData, ...testData }}>
<DiscussionsSidebar />
</SidebarContext.Provider>,
);
return container;
}
it('should show up if unit discussions associated with it', async () => {
renderWithProvider();
expect(screen.queryByTitle('Discussions')).toBeInTheDocument();
expect(screen.queryByTitle('Discussions'))
.toHaveAttribute('src', `http://localhost:2002/${courseId}/category/${unitId}?inContext`);
});
it('should show nothing if unit has no discussions associated with it', async () => {
renderWithProvider({ unitId: 'no-discussion' });
expect(screen.queryByTitle('Discussions')).not.toBeInTheDocument();
});
});

View File

@@ -26,7 +26,6 @@ export function normalizeLearningSequencesData(learningSequencesData) {
models.sequences[seqId] = { models.sequences[seqId] = {
id: seqId, id: seqId,
title: sequence.title, title: sequence.title,
legacyWebUrl: `${getConfig().LMS_BASE_URL}/courses/${learningSequencesData.course_key}/jump_to/${seqId}?experience=legacy`,
}; };
}); });
@@ -106,7 +105,6 @@ function normalizeMetadata(metadata) {
start: data.start, start: data.start,
enrollmentMode: data.enrollment.mode, enrollmentMode: data.enrollment.mode,
isEnrolled: data.enrollment.is_active, isEnrolled: data.enrollment.is_active,
canViewLegacyCourseware: data.can_view_legacy_courseware,
license: data.license, license: data.license,
userTimezone: data.user_timezone, userTimezone: data.user_timezone,
showCalculator: data.show_calculator, showCalculator: data.show_calculator,

View File

@@ -171,13 +171,11 @@ describe('Courseware Service', () => {
id: 'block-v1:edX+DemoX+Demo_Course+type@sequential+block@accessible', id: 'block-v1:edX+DemoX+Demo_Course+type@sequential+block@accessible',
title: 'Can access', title: 'Can access',
sectionId: 'block-v1:edX+DemoX+Demo_Course+type@chapter+block@partial', sectionId: 'block-v1:edX+DemoX+Demo_Course+type@chapter+block@partial',
legacyWebUrl: `${getConfig().LMS_BASE_URL}/courses/course-v1:edX+DemoX+Demo_Course/jump_to/block-v1:edX+DemoX+Demo_Course+type@sequential+block@accessible?experience=legacy`,
}, },
'block-v1:edX+DemoX+Demo_Course+type@sequential+block@released': { 'block-v1:edX+DemoX+Demo_Course+type@sequential+block@released': {
id: 'block-v1:edX+DemoX+Demo_Course+type@sequential+block@released', id: 'block-v1:edX+DemoX+Demo_Course+type@sequential+block@released',
title: 'Released and inaccessible', title: 'Released and inaccessible',
sectionId: 'block-v1:edX+DemoX+Demo_Course+type@chapter+block@partial', sectionId: 'block-v1:edX+DemoX+Demo_Course+type@chapter+block@partial',
legacyWebUrl: `${getConfig().LMS_BASE_URL}/courses/course-v1:edX+DemoX+Demo_Course/jump_to/block-v1:edX+DemoX+Demo_Course+type@sequential+block@released?experience=legacy`,
}, },
}, },
}; };
@@ -271,7 +269,6 @@ describe('Courseware Service', () => {
}), }),
show_calculator: boolean(false), show_calculator: boolean(false),
original_user_is_staff: boolean(true), original_user_is_staff: boolean(true),
can_view_legacy_courseware: boolean(true),
is_staff: boolean(true), is_staff: boolean(true),
course_access: like({ course_access: like({
has_access: true, has_access: true,
@@ -286,7 +283,7 @@ describe('Courseware Service', () => {
user_has_passing_grade: boolean(false), user_has_passing_grade: boolean(false),
course_exit_page_is_active: boolean(false), course_exit_page_is_active: boolean(false),
certificate_data: { certificate_data: {
cert_status: string('audit_passing'), cert_web_view_url: null, download_url: null, certificate_available_date: null, cert_status: string('audit_passing'), cert_web_view_url: null, certificate_available_date: null,
}, },
verify_identity_url: null, verify_identity_url: null,
verification_status: string('none'), verification_status: string('none'),
@@ -321,7 +318,6 @@ describe('Courseware Service', () => {
start: '2013-02-05T05:00:00Z', start: '2013-02-05T05:00:00Z',
enrollmentMode: 'audit', enrollmentMode: 'audit',
isEnrolled: true, isEnrolled: true,
canViewLegacyCourseware: true,
license: 'all-rights-reserved', license: 'all-rights-reserved',
userTimezone: null, userTimezone: null,
showCalculator: false, showCalculator: false,
@@ -332,7 +328,6 @@ describe('Courseware Service', () => {
certificateData: { certificateData: {
certStatus: 'audit_passing', certStatus: 'audit_passing',
certWebViewUrl: null, certWebViewUrl: null,
downloadUrl: null,
certificateAvailableDate: null, certificateAvailableDate: null,
}, },
timeOffsetMillis: 0, timeOffsetMillis: 0,

View File

@@ -95,9 +95,7 @@ export function fetchCourse(courseId) {
logError(courseHomeMetadataResult.reason); logError(courseHomeMetadataResult.reason);
} }
if (fetchedMetadata && fetchedCourseHomeMetadata) { if (fetchedMetadata && fetchedCourseHomeMetadata) {
if (courseHomeMetadataResult.value.courseAccess.hasAccess if (courseHomeMetadataResult.value.courseAccess.hasAccess && fetchedOutline) {
&& courseHomeMetadataResult.value.canLoadCourseware
&& fetchedOutline) {
// User has access // User has access
dispatch(fetchCourseSuccess({ courseId })); dispatch(fetchCourseSuccess({ courseId }));
return; return;

View File

@@ -0,0 +1,63 @@
import React, { useEffect } from 'react';
import { LearningHeader as Header } from '@edx/frontend-component-header';
import Footer from '@edx/frontend-component-footer';
import { useParams } from 'react-router-dom';
import { useDispatch, useSelector } from 'react-redux';
import { Redirect } from 'react-router';
import { injectIntl, intlShape } from '@edx/frontend-platform/i18n';
import useActiveEnterpriseAlert from '../alerts/active-enteprise-alert';
import { AlertList } from './user-messages';
import { fetchDiscussionTab } from '../course-home/data/thunks';
import { LOADED, LOADING } from '../course-home/data/slice';
import PageLoading from './PageLoading';
import messages from '../tab-page/messages';
function CourseAccessErrorPage({ intl }) {
const { courseId } = useParams();
const dispatch = useDispatch();
const activeEnterpriseAlert = useActiveEnterpriseAlert(courseId);
useEffect(() => {
dispatch(fetchDiscussionTab(courseId));
}, [courseId]);
const {
courseStatus,
} = useSelector(state => state.courseHome);
if (courseStatus === LOADING) {
return (
<>
<Header />
<PageLoading
srMessage={intl.formatMessage(messages.loading)}
/>
<Footer />
</>
);
}
if (courseStatus === LOADED) {
return (<Redirect to={`/redirect/home/${courseId}`} />);
}
return (
<>
<Header />
<main id="main-content" className="container my-5 text-center" data-testid="access-denied-main">
<AlertList
topic="outline"
className="mx-5 mt-3"
customAlerts={{
...activeEnterpriseAlert,
}}
/>
</main>
<Footer />
</>
);
}
CourseAccessErrorPage.propTypes = {
intl: intlShape.isRequired,
};
export default injectIntl(CourseAccessErrorPage);

View File

@@ -0,0 +1,58 @@
import React from 'react';
import { history } from '@edx/frontend-platform';
import { Route } from 'react-router';
import { initializeTestStore, render, screen } from '../setupTest';
import CourseAccessErrorPage from './CourseAccessErrorPage';
const mockDispatch = jest.fn();
let mockCourseStatus;
jest.mock('react-redux', () => ({
...jest.requireActual('react-redux'),
useDispatch: () => mockDispatch,
useSelector: () => ({ courseStatus: mockCourseStatus }),
}));
jest.mock('./PageLoading', () => () => <div data-testid="page-loading" />);
describe('CourseAccessErrorPage', () => {
let courseId;
let accessDeniedUrl;
beforeEach(async () => {
const store = await initializeTestStore({ excludeFetchSequence: true });
courseId = store.getState().courseware.courseId;
accessDeniedUrl = `/course/${courseId}/access-denied`;
history.push(accessDeniedUrl);
});
it('Displays loading in start on page rendering', () => {
mockCourseStatus = 'loading';
render(
<Route path="/course/:courseId/access-denied">
<CourseAccessErrorPage />
</Route>,
);
expect(screen.getByTestId('page-loading')).toBeInTheDocument();
expect(history.location.pathname).toBe(accessDeniedUrl);
});
it('Redirect user to homepage if user has access', () => {
mockCourseStatus = 'loaded';
render(
<Route path="/course/:courseId/access-denied">
<CourseAccessErrorPage />
</Route>,
);
expect(history.location.pathname).toBe('/redirect/home/course-v1:edX+DemoX+Demo_Course');
});
it('For access denied it should render access denied page', () => {
mockCourseStatus = 'denied';
render(
<Route path="/course/:courseId/access-denied">
<CourseAccessErrorPage />
</Route>,
);
expect(screen.getByTestId('access-denied-main')).toBeInTheDocument();
expect(history.location.pathname).toBe(accessDeniedUrl);
});
});

View File

@@ -1,6 +1,8 @@
/* eslint-disable import/prefer-default-export */ /* eslint-disable import/prefer-default-export */
import { useEffect, useRef } from 'react'; import {
useCallback, useEffect, useRef, useState,
} from 'react';
export function useEventListener(type, handler) { export function useEventListener(type, handler) {
// We use this ref so that we can hold a reference to the currently active event listener. // We use this ref so that we can hold a reference to the currently active event listener.
@@ -19,3 +21,41 @@ export function useEventListener(type, handler) {
return () => global.removeEventListener(type, eventListenerRef.current); return () => global.removeEventListener(type, eventListenerRef.current);
}, [type, handler]); }, [type, handler]);
} }
/**
* Hooks up post messages to callbacks
* @param {Object.<string, function>} events A mapping of message type to callback
*/
export function useIFramePluginEvents(events) {
const receiveMessage = useCallback(({ data }) => {
const {
type,
payload,
} = data;
if (events[type]) {
events[type](payload);
}
}, [events]);
useEventListener('message', receiveMessage);
}
/**
* A hook to monitor message about changes in iframe content height
* @param onIframeLoaded A callback for when the frame is loaded
* @returns {[boolean, number]}
*/
export function useIFrameHeight(onIframeLoaded = null) {
const [iframeHeight, setIframeHeight] = useState(null);
const [hasLoaded, setHasLoaded] = useState(false);
const receiveResizeMessage = useCallback(({ height }) => {
setIframeHeight(height);
if (!hasLoaded && !iframeHeight && height > 0) {
setHasLoaded(true);
if (onIframeLoaded) {
onIframeLoaded();
}
}
}, [setIframeHeight, hasLoaded, iframeHeight, setHasLoaded, onIframeLoaded]);
useIFramePluginEvents({ 'plugin.resize': receiveResizeMessage });
return [hasLoaded, iframeHeight];
}

View File

@@ -0,0 +1,45 @@
import { render, screen, waitFor } from '@testing-library/react';
import { useEventListener, useIFrameHeight } from './hooks';
describe('Hooks', () => {
test('useEventListener', async () => {
const handler = jest.fn();
const TestComponent = () => {
useEventListener('message', handler);
return (<div data-testid="testid" />);
};
render(<TestComponent />);
await screen.findByTestId('testid');
window.postMessage({ test: 'test' }, '*');
await waitFor(() => expect(handler).toHaveBeenCalled());
});
test('useIFrameHeight', async () => {
const onLoaded = jest.fn();
const TestComponent = () => {
const [hasLoaded, height] = useIFrameHeight(onLoaded);
return (
<div data-testid="testid">
<span data-testid="loaded">
{String(hasLoaded)}
</span>
<span data-testid="height">
{String(height)}
</span>
</div>
);
};
render(<TestComponent />);
await screen.findByTestId('testid');
expect(screen.getByTestId('loaded')).toHaveTextContent('false');
expect(screen.getByTestId('height')).toHaveTextContent('null');
window.postMessage({
type: 'plugin.resize',
payload: { height: 1234 },
}, '*');
await waitFor(() => expect(onLoaded).toHaveBeenCalled());
await waitFor(() => expect(screen.getByTestId('loaded')).toHaveTextContent('true'));
expect(screen.getByTestId('height')).toHaveTextContent('1234');
});
});

View File

@@ -98,9 +98,9 @@ export function SupportMissionBullet() {
<CheckmarkBullet /> <CheckmarkBullet />
<FormattedMessage <FormattedMessage
id="learning.generic.upsell.supportMissionBullet" id="learning.generic.upsell.supportMissionBullet"
defaultMessage="Support our {missionInBoldText} at edX" defaultMessage="Support our {missionInBoldText} at {siteName}"
description="Bullet encouraging user to support edX's goals." description="Bullet encouraging user to support edX's goals."
values={{ missionInBoldText }} values={{ missionInBoldText, siteName: getConfig().SITE_NAME }}
/> />
</li> </li>
); );

View File

@@ -1,444 +1,447 @@
{ {
"learning.accessExpiration.deadline": "قم بالترقية قبل {date} للحصول على صلاحية دخول غير محدود طالما أنه موجود على الموقع.", "learning.accessExpiration.deadline": "قم بالترقية قبل {date} للاستفادة من دخول غير محدود للمساق طالما هو موجود على الموقع.",
"learning.accessExpiration.header": "تنتهي صلاحية حضرور الدورة كمستمع في {date}", "learning.accessExpiration.header": "تنتهي صلاحية دخول المساق كمستمع في {date}",
"learning.accessExpiration.body": "ستفقد كل صلاحيات الدخول إلى هذا المساق، بما في ذلك مسار تقدمك في {date}.", "learning.accessExpiration.body": "ستفقد كل صلاجيات الدخول إلى هذا المساق مع أي تقدم حققته. و ذلك اعتبارًا من {date}.",
"instructorToolbar.pageBanner.courseHasExpired": ا يمتلك الطالب/ة صلاحية الدخول الى المقرر التعليمي, انتهت الصلاحية منذ {date}.", "instructorToolbar.pageBanner.courseHasExpired": م يعد هذا المتعلّم مخولاً بالدخول لهذا المساق. لقد انتهت صلاحية بتاريخ {date}.",
"learning.accessExpiration.upgradeNow": "ترقية الآن", "learning.accessExpiration.upgradeNow": "الترقية الآن",
"learning.outline.alert.start.short": "يبدأ المساق في غضون {timeRemaining} في {courseStartDate}.", "learning.activeEnterprise.alert": "{changeActiveEnterprise}.",
"learning.outline.alert.end.long": "سينتهي المساق خلال {timeRemaining} في {courseEndDate}.", "learning.activeEnterprise.change.alert": "تغيير المؤسسة الآن",
"learning.outline.alert.end.calendar": "لا تنسى إضافة تذكير في التقويم!", "learning.outline.alert.start.short": "يبدأ المساق في {timeRemaining} بتاريخ {courseStartDate}.",
"instructorToolbar.pageBanner.courseHasNotStarted": "لا يمكن لالطالب الوصول لالمساق حتى الان. يبدأ المساق في {date}.", "learning.outline.alert.end.long": "ينتهي المساق في {timeRemaining} في {courseEndDate}.",
"learning.enrollment.alert": "يجب أن تكون مسجلا في المساق لمشاهدة المحتوى.", "learning.outline.alert.end.calendar": "لا تنسَ إضافة تذكير في التقويم!",
"learning.staff.enrollment.alert": "أنت تستعرض هذا المساق كفرد من فريق طاقم المساق، ولم تلتحق بعد.", "instructorToolbar.pageBanner.courseHasNotStarted": "لا يمكن للطالب بعدُ الدخول للمساق. يبدأ المساق في {date}.",
"learning.enrollment.enrollNow.Inline": "التحق الآن", "learning.enrollment.alert": "يجب أن تكون مسجلا في المساق لمشاهدة محتواه.",
"learning.enrollment.enrollNow.Sentence": "التحق الآن", "learning.staff.enrollment.alert": "أنت تستعرض هذا المساق كعضو في الطاقم، و لست مسجلا فيه.",
"learning.enrollment.success": "تم التحاقك في هذا المساق بنجاح!", "learning.enrollment.enrollNow.Inline": "سجل الآن",
"account-activation.alert.button": "استمر الى {siteName}", "learning.enrollment.enrollNow.Sentence": "سجل الآن.",
"account-activation.alert.message": "تم ارسال بريد الكتروني الى {boldEmail} مع رابط لتفعيل الحساب. اذا لم تستطيع ان تجده يرجى فحص البريد في مجلد البريد المزعج او الغير مرغوب به ال (spam) \n {sendEmailTag}.", "learning.enrollment.success": "تم تسجيلك في هذا المساق بنجاح!",
"account-activation.resend.link": "ارسل البريد مرة اخرى ", "account-activation.alert.button": "المواصلة إلى {siteName}",
"learning.logistration.alert": "للاطلاع على محتوى المساق {signIn} أو {register}.", "account-activation.alert.message": "تم ارسال بريد الكتروني الى {boldEmail} به رابط لتفعيل حسابك. لم تجده؟ تفقد إذًا مجلد الرسائل غير المرغوب فيها وإلا \nف{sendEmailTag}.",
"account-activation.alert.title": "فعل حساب لكي تستطيع الدخول مرة أخرى", "account-activation.resend.link": "أرسل البريد مرة اخرى ",
"learn.sequence.entranceExamTextNotPassing": "للوصول إلى مواد الدورة التدريبية ، يجب أن تحصل على درجة {entranceExamMinimumScorePct}٪ أو أعلى في هذا الاختبار. نتيجتك الحالية هي {entranceExamCurrentScore}٪.", "learning.logistration.alert": "لرؤية محتوى المساق، {signIn} أو {register}.",
"learn.sequence.entranceExamTextPassed": "نتيجتك هي {entranceExamCurrentScore}٪. لقد اجتزت امتحان القبول.", "account-activation.alert.title": "فعّل حسابك كي تستطيع الدخول مجددًا",
"learn.sequence.entranceExamTextNotPassing": "للوصول إلى مواد المساق، يجب أن تحصل في هذا الاختبار على درجة {entranceExamMinimumScorePct}% أو أعلى. درجتك الحالية هي {entranceExamCurrentScore}%.",
"learn.sequence.entranceExamTextPassed": "درجتك هي {entranceExamCurrentScore}%. لقد اجتزت امتحان القبول.",
"learning.dates.badge.completed": "مُكتمل", "learning.dates.badge.completed": "مُكتمل",
"learning.dates.badge.dueNext": "موعد الاستحقاق التالي", "learning.dates.badge.dueNext": "الأجَل قادم",
"learning.dates.badge.pastDue": "مضى تاريخ الاستحقاق", "learning.dates.badge.pastDue": "انقضى الأجَل",
"learning.dates.title": "تواريخ مهمّة", "learning.dates.title": "التواريخ المهمة",
"learning.dates.badge.today": "اليوم", "learning.dates.badge.today": "اليوم",
"learning.dates.badge.unreleased": "لم يتم الإصدار بعد", "learning.dates.badge.unreleased": "لم يصدر بعد",
"learning.dates.badge.verifiedOnly": "موثق فقط", "learning.dates.badge.verifiedOnly": "حصري للمعتمدين",
"learning.goals.unsubscribe.contact": "الاتصال بفريق الدعم", "learning.goals.unsubscribe.contact": "اتصل بالدعم",
"learning.goals.unsubscribe.description": "لن تستقبل اشعارات على بريدك الالكتروني لتذكيرك باهدافك ل {courseTitle}.", "learning.goals.unsubscribe.description": "لن يصلك مستقبلا أي تذكير بهدفك لـ {courseTitle} عبر البريد الإلكتروني.",
"learning.goals.unsubscribe.errorHeader": "حصل خطأ ما", "learning.goals.unsubscribe.errorHeader": "حصل خطأ ما",
"learning.goals.unsubscribe.goToDashboard": "الذهاب إلى لوحة المعلومات", "learning.goals.unsubscribe.goToDashboard": "الذهاب إلى لوحة المعلومات",
"learning.goals.unsubscribe.header": "قمت بالغاء اشتراكك في شعارات التذكيرية لاهداف", "learning.goals.unsubscribe.header": "لقد ألغيت اشتراكك في رسائل التذكير بالهدف",
"learning.goals.unsubscribe.loading": "يجري الغاء الاشتراك...", "learning.goals.unsubscribe.loading": "إلغاء الاشتراك جارٍ...",
"learning.goals.unsubscribe.errorDescription": "لم نستطع الغاء اشتراكك في اشعارات التذكيرية باهدافك على بريدك الالكتروني. رجاءا حاول مرة اخرى او {contactSupport} للمساعدة.", "learning.goals.unsubscribe.errorDescription": "لم نستطع الغاء اشتراكك في رسائل التذكير بالهدف. رجاءً حاول مجددًا في وقت لاحق أو {contactSupport} للمساعدة.",
"learning.outline.alert.cert.earnedNotAvailable": "This course ends on {courseEndDateFormatted}. Final grades and any earned certificates are\n scheduled to be available after {certificateAvailableDate}.", "learning.outline.alert.cert.earnedNotAvailable": "ينتهي هذا المساق في {courseEndDateFormatted}. تمت جدولة ظهور الدرجات النهائية و أي شهادات مكتسبة ابتداءً من {certificateAvailableDate}.",
"cert.alert.earned.unavailable.header.v2": "Your grade and certificate status will be available soon.", "cert.alert.earned.unavailable.header.v2": "ستظهر درجتك و حالة شهادتك قريبًا.",
"cert.alert.earned.ready.header": "ألف مبروك! شهادتك جاهزة.", "cert.alert.earned.ready.header": "تهانينا! شهادتك جاهزة.",
"cert.alert.notPassing.header": "أنت غير مؤهل بعد للحصول على شهادة", "cert.alert.notPassing.header": "لست مؤهلا بعد للحصول على شهادة",
"cert.alert.notPassing.button": "عرض الدرجات", "cert.alert.notPassing.button": "عرض الدرجات",
"learning.outline.alert.end.short": "ينتهي هذا المساق في غضون {timeRemaining}في {courseEndTime}.", "learning.outline.alert.end.short": "ينتهي هذا المساق في غضون {timeRemaining} في {courseEndTime}.",
"alert.enroll": "للوصول إلى محتوى الدورة الكاملة.", "alert.enroll": "للوصول إلى كامل المساق.",
"learning.privateCourse.signInOrRegister": "{signIn} أو {register} ثم التحق بهذا المساق.", "learning.privateCourse.signInOrRegister": "{signIn} أو {register} أولاً، ثم سجل نفسك في المساق.",
"learning.outline.alert.scheduled-content.heading": "المزيد من المحتوى قريبا!", "learning.outline.alert.scheduled-content.heading": "مزيد من المحتوى قادم قريبا!",
"learning.outline.alert.scheduled-content.body": "سيتم إصدار المزيد من المحتوى في هذه الدورة التدريبية في تاريخ لاحق. ابقى على اطلاع بتحديثات البريد الإلكتروني أو تحقق في تحديثات هذه الدورة التدريبية لاحقاَ.", "learning.outline.alert.scheduled-content.body": "سيصدر لهذا المساق مزيد المحتوى في تاريخ لاحق. ترقب التحديثات عبر البريد الإلكتروني أو تفقّد هذا المساق لاحقًا من أجل التحديثات.",
"learning.outline.alert.scheduled-content.button": "عرض الجدول الزمني للدورة", "learning.outline.alert.scheduled-content.button": "عرض مواعيد المساق",
"learning.outline.dates.all": "عرض جميع تواريخ المساق", "learning.outline.dates.all": "عرض جميع تواريخ المساق",
"learning.outline.goalButton.casual.text": "يوم واحد في الأسبوع", "learning.outline.goalButton.casual.text": "يوم واحد في الأسبوع",
"learning.outline.goalButton.screenReader.text": "غير رسمي", "learning.outline.goalButton.screenReader.text": "غير رسمي",
"learning.outline.certificateAlt": "عينة الشهادة", "learning.outline.certificateAlt": "نموذج عن الشهادة",
"learning.outline.collapseAll": "اغلاق الكل", "learning.outline.collapseAll": "طي الكل",
"learning.outline.completedAssignment": "اكتمل", "learning.outline.completedAssignment": "مكتمل",
"learning.outline.completedSection": "قسم مكتمل", "learning.outline.completedSection": "قسم مكتمل",
"learning.outline.dates": "تواريخ مهمّة", "learning.outline.dates": "التواريخ المهمة",
"learning.outline.editGoal": حرير الهدف", "learning.outline.editGoal": عديل الهدف",
"learning.outline.expandAll": وسيع الكل", "learning.outline.expandAll": كبير الكل",
"learning.outline.goal": "الهدف", "learning.outline.goal": "الهدف",
"learning.outline.goalReminderDetail": ذا لاحظنا أنك لم تحقق هدفك تمامًا ، فسنرسل إليك تذكيرًا بالبريد الإلكتروني.", "learning.outline.goalReminderDetail": ن لاحظنا أنك لم تحقق هدفك تمامًا، فسنرسل إليك تذكيرًا بالبريد الإلكتروني.",
"learning.outline.goalUnsure": "لست متأكدا بعد", "learning.outline.goalUnsure": "لست متأكدا بعد",
"learning.outline.handouts": "نشرات المساق", "learning.outline.handouts": "مطبوعات المساق",
"learning.outline.incompleteAssignment": " بيانات غير مستكملة", "learning.outline.incompleteAssignment": "غير مكتمل",
"learning.outline.incompleteSection": "قسم غير مكتمل", "learning.outline.incompleteSection": "قسم غير مكتمل",
"learning.outline.goalButton.intense.text": "5 أيام في الأسبوع", "learning.outline.goalButton.intense.text": "5 أيام في الأسبوع",
"learning.outline.goalButton.intense.title": "شديد", "learning.outline.goalButton.intense.title": "شديد",
"learning.outline.learnMore": "اعرف المزيد", "learning.outline.learnMore": "معرفة المزيد",
"learning.outline.altText.openSection": "فتح", "learning.outline.altText.openSection": "فتح",
"learning.proctoringPanel.header": "تحتوي هذه الدورة على امتحانات مراقبة", "learning.proctoringPanel.header": "يحتوي هذا المساق امتحانات مراقبة",
"learning.outline.goalButton.regular.text": "3 أيام في الأسبوع", "learning.outline.goalButton.regular.text": "3 أيام في الأسبوع",
"learning.outline.goalButton.regular.title": "عادي", "learning.outline.goalButton.regular.title": "عادي",
"learning.outline.resumeBlurb": "تابع من حيث توقفت", "learning.outline.resumeBlurb": "استأنف من حيث توقفت",
"learning.outline.resume": "استئناف الدورة", "learning.outline.resume": "استئناف المساق",
"learning.outline.setGoal": "للبدء اضبط هدفًا للمساق عن طريق تحديد الخيار أدناه والذي يعطي الوصف الأمثل لخطتك التعليمية", "learning.outline.setGoal": "للبدء، اضبط هدفًا للمساق بتحديد أكثر خيار أدناه يصف خطتك التعلمية",
"learning.outline.setGoalReminder": "تفعيل خاصية تذكير الهدف", "learning.outline.setGoalReminder": "حدد ما يذكرك بالهدف",
"learning.outline.goalButton.casual.title": "حدد أسلوب هدف التعلم.", "learning.outline.goalButton.casual.title": "حدد أسلوبًا للهدف التعلمي.",
"learning.outline.setWeeklyGoal": "حدد هدف التعلم الأسبوعي", "learning.outline.setWeeklyGoal": "حدد هدفًا تعلميًا أسبوعيًا",
"learning.outline.setWeeklyGoalDetail": "إن تحديد هدف يحفزك على إنهاء الدورة. يمكنك دائما تغييره في وقت لاحق.", "learning.outline.setWeeklyGoalDetail": "إن تحديد هدف يحفزك على إنهاء المساق. سيبقى بإمكانك تغييره لاحقا.",
"learning.outline.start": "ابدأ المساق", "learning.outline.start": "بدأ المساق",
"learning.outline.startBlurb": "ابدأ المساق اليوم", "learning.outline.startBlurb": "ابدأ مساقك اليوم",
"learning.outline.tools": "أدوات المساق", "learning.outline.tools": "أدوات المساق",
"learning.outline.upgradeButton": "ترقية ({symbol}{price})", "learning.outline.upgradeButton": "ترقية ({symbol}{price})",
"learning.outline.upgradeTitle": "اسعَ للحصول على شهادة معتمدة", "learning.outline.upgradeTitle": "اسعَ للحصول على شهادة معتمدة",
"learning.outline.welcomeMessage": "رسالة ترحيب", "learning.outline.welcomeMessage": "الكلمة الترحيبية",
"learning.outline.welcomeMessageShowMoreButton": "إظهار المزيد", "learning.outline.welcomeMessageShowMoreButton": "إظهار المزيد",
"learning.outline.welcomeMessageShowLessButton": "إظهار أقل", "learning.outline.welcomeMessageShowLessButton": "إظهار القليل",
"learning.outline.goalWelcome": "مرحبًا بك في", "learning.outline.goalWelcome": "أهلاً بك في",
"learning.proctoringPanel.status.notStarted": "لم تبدأ بعد", "learning.proctoringPanel.status.notStarted": "لم يبدأ بعد",
"learning.proctoringPanel.status.started": "بدأ في تاريخ", "learning.proctoringPanel.status.started": "بدأ في",
"learning.proctoringPanel.status.submitted": "تم الإرسال ", "learning.proctoringPanel.status.submitted": "مُرسَل",
"learning.proctoringPanel.status.verified": "تمّ التحقّق", "learning.proctoringPanel.status.verified": "معتمد",
"learning.proctoringPanel.status.rejected": "مرفوض", "learning.proctoringPanel.status.rejected": "مرفوض",
"learning.proctoringPanel.status.error": "خطأ", "learning.proctoringPanel.status.error": "خطأ",
"learning.proctoringPanel.status.otherCourseApproved": "معتمد من مساق أخرى", "learning.proctoringPanel.status.otherCourseApproved": "معتمد من مساق آخر",
"learning.proctoringPanel.status.expiringSoon": "تنتهي قريبا", "learning.proctoringPanel.status.expiringSoon": "تنتهي صلاحيته قريبا",
"learning.proctoringPanel.status": "حالة التهيئة الحالية:", "learning.proctoringPanel.status.expired": "Expired",
"learning.proctoringPanel.message.notStarted": "لم تبدأ امتحان التهيئة الخاص بك.", "learning.proctoringPanel.status": "وضع الامتحان التحضيري حاليا:",
"learning.proctoringPanel.message.started": قد بدأت اختبار التهيئة الخاص بك.", "learning.proctoringPanel.message.notStarted": م تبدأ امتحانك التحضيري.",
"learning.proctoringPanel.message.submitted": "لقد قدمت امتحان التهيئة الخاص بك.", "learning.proctoringPanel.message.started": "لقد بدأت امتحانك التحضيري.",
"learning.proctoringPanel.message.verified": "تمت الموافقة على امتحان التهيئة الخاص بك في هذه الدورة التدريبية.", "learning.proctoringPanel.message.submitted": "لقد سلمت امتحانك التحضيري.",
"learning.proctoringPanel.message.rejected": "تم رفض امتحان التهيئة الخاص بك. يرجى إعادة المحاولة امتحان التهيئة.", "learning.proctoringPanel.message.verified": "تم اعتماد امتحانك التحضيري في هذا المساق.",
"learning.proctoringPanel.message.error": "حدث خطأ أثناء امتحان التهيئة الخاص بك. يرجى إعادة المحاولة امتحان التهيئة.", "learning.proctoringPanel.message.rejected": "تم رفض امتحانك التحضيري. رجاءً حاول إجراء الامتحان مجددًا.",
"learning.proctoringPanel.message.otherCourseApproved": "تمت الموافقة على امتحان التهيئة الخاص بك من دورة أخرى.", "learning.proctoringPanel.message.error": "حدث خطأ أثناء امتحان التهيئة الخاص بك. رجاءً حاول إجراء الامتحان مجددًا.",
"learning.proctoringPanel.detail.otherCourseApproved": "إذا تم تغيير جهازك ، نوصيك بإكمال امتحان التهيئة لهذه الدورة التدريبية للتأكد من أن إعدادتك لا تزال نفي بمتطلبات المراقبة.", "learning.proctoringPanel.message.otherCourseApproved": "تم اعتماد امتحانك التحضيري من مساق آخر.",
"learning.proctoringPanel.message.expiringSoon": "تمت الموافقة على ملف التعريف الخاص بك لامتحان التهيئة من دورة أخرى. ومع ذلك ، تنتهي صلاحية امتحان التهيئة الخاصة بك قريبًا. يرجى اجراء امتحان التهيئة مرة أخرى للتأكد من أنك ستتمكن من الاستمرار في إجراء الاختبارات المراقبة.", "learning.proctoringPanel.detail.otherCourseApproved": "إن تغير جهازك، فإننا نصحك بإكمال الامتحان التحضيري لهذا المساق، و ذلك للتأكد من أن إعداداتك لا تزال تستوفي متطلبات المراقبة.",
"learning.proctoringPanel.generalInfo": "يجب عليك إكمال امتحان التهيئة قبل إجراء أي اختبار مراقب.", "learning.proctoringPanel.message.expiringSoon": "Your onboarding profile has been approved. However, your onboarding status is expiring soon. Please complete onboarding again to ensure that you will be able to continue taking proctored exams.",
"learning.proctoringPanel.generalInfoSubmitted": "ملفك الشخصي المقدم قيد المراجعة.", "learning.proctoringPanel.message.expired": "Your onboarding status has expired. Please complete onboarding again to continue taking proctored exams.",
"learning.proctoringPanel.generalTime": "يمكن أن تستغرق مراجعة الملف الشخصي لامتحان التهيئة على الأقل يومين عمل.", "learning.proctoringPanel.generalInfo": "عليك إتمام إجراءات التحضير قبل إجراء أي امتحان مراقب.",
"learning.proctoringPanel.onboardingButton": "ابدأ امتحان التهيئة", "learning.proctoringPanel.generalInfoSubmitted": "ملفك الشخصي المرسَل قيد المراجعة.",
"learning.proctoringPanel.onboardingPracticeButton": "عرض امتحان التهيئة", "learning.proctoringPanel.generalTime": "قد تستغرق مراجعة الملف الشخصي للامتحان التحضيري يومي عمل على الأقل.",
"learning.proctoringPanel.onboardingButtonNotOpen": "يفتح امتحان التهيئة في : {releaseDate}", "learning.proctoringPanel.onboardingButton": "إكمال الامتحان التحضيري",
"learning.proctoringPanel.reviewRequirementsButton": "مراجعة التعليمات ومتطلبات النظام", "learning.proctoringPanel.onboardingPracticeButton": "عرض الامتحان التحضيري",
"learning.proctoringPanel.onboardingButtonPastDue": "امتحان التهيئة تجاوز تاريخ الاستحقاق", "learning.proctoringPanel.onboardingButtonNotOpen": "يفتح الامتحان التحضيري في: {releaseDate}",
"learning.outline.sequence-due": "{description} في{assignmentDue}", "learning.proctoringPanel.reviewRequirementsButton": "مراجعة التعليمات و متطلبات النظام",
"progress.certificateStatus.unverifiedBody": "لإنشاء شهادة ، يجب عليك إكمال عملية التحقق من الهوية. {idVerificationSupportLink}.", "learning.proctoringPanel.onboardingButtonPastDue": "انقضى أجَل الامتحان التحضيري",
"progress.certificateStatus.downloadableBody": "اعرض إنجازاتك على لينكد ان أو على سيرتك الذاتية اليوم. يمكنك تنزيل شهادتك الآن والوصول إليها في أي وقت من لوحة التحكم والملف الشخصي.", "learning.outline.sequence-due": "{description} للتسليم قبل {assignmentDue}",
"courseCelebration.certificateBody.notAvailable.endDate": "This course ends on {endDate}. Final grades and any earned certificates are\n scheduled to be available after {certAvailabilityDate}.", "progress.certificateStatus.unverifiedBody": "لتوليد شهادة، يجب عليك إكمال التحقق من هويتك. {idVerificationSupportLink}.",
"progress.certificateStatus.downloadableBody": "اعرض إنجازاتك اليوم على لينكد إن أو ضمن سيرتك الذاتية. يمكنك تحميل شهادتك الآن و الوصول إليها في أي وقت من لوحة معلوماتك و ملفك الشخصي.",
"courseCelebration.certificateBody.notAvailable.endDate": "تمت جدولة ظهور الدرجات النهائية و أي شهادات مكتسبة ابتداءً من {endDate}.",
"progress.certificateStatus.notPassingHeader": "حالة الشهادة", "progress.certificateStatus.notPassingHeader": "حالة الشهادة",
"progress.certificateStatus.notPassingBody": "من أجل التأهل للحصول على شهادة ، يجب أن تكون حاصلاً على درجة النجاح.", "progress.certificateStatus.notPassingBody": "كي تكون أهلا لنيل شهادة، يجب أن تحصل على درجة نجاح.",
"progress.certificateStatus.inProgressHeader": "المزيد من المحتوى قريبا!", "progress.certificateStatus.inProgressHeader": "مزيد من المحتوى قادم قريبا!",
"progress.certificateStatus.inProgressBody": "يبدو أن هناك المزيد من المحتوى في هذه الدورة والذي سيتم إصداره في المستقبل. ابقى على اطلاع بتحديثات عن طربق البريد الإلكتروني أو تحقق مرة أخرى من صفحة الدورة لمعرفة وقت توفر المحتوى الجديد.", "progress.certificateStatus.inProgressBody": "يبدو أن في هذا المساق مزيدا من المحتوى سيصدر مستقبلا. ستقبل. ترقب التحديثات عبر البريد الإلكتروني أو تفقّد هذا المساق لاحقًا لمعرفة موعد صدور هذا المحتوى.",
"progress.certificateStatus.requestableHeader": "حالة الشهادة", "progress.certificateStatus.requestableHeader": "حالة الشهادة",
"progress.certificateStatus.requestableBody": "تهانينا ، انت مؤهل للحصول على شهادة! من أجل الوصول إلى شهادتك ، اطلبها أدناه.", "progress.certificateStatus.requestableBody": "تهانينا، انت مؤهل للحصول على شهادة! للوصول إلى شهادتك، اطلبها أدناه.",
"progress.certificateStatus.requestableButton": "طلب شهادة", "progress.certificateStatus.requestableButton": "طلب الشهادة",
"progress.certificateStatus.unverifiedHeader": "حالة الشهادة", "progress.certificateStatus.unverifiedHeader": "حالة الشهادة",
"progress.certificateStatus.unverifiedButton": "التحقق من الهوية", "progress.certificateStatus.unverifiedButton": "التحقق من الهوية",
"progress.certificateStatus.courseCelebration.verificationPending": "عملية التحقق من المعرّف الخاص بك معلّقة وستتوفر شهادتك بمجرد الموافقة.", "progress.certificateStatus.courseCelebration.verificationPending": "طلب التحقق من هويتك قيد الانتظار، و ستتوفر شهادتك فور الموافقة علىه.",
"progress.certificateStatus.downloadableHeader": "!شهادتك جاهزة", "progress.certificateStatus.downloadableHeader": "شهادتك جاهزة!",
"progress.certificateStatus.downloadableButton": "تحميل شهاداتي", "progress.certificateStatus.viewableButton": "عرض شهادتي",
"progress.certificateStatus.viewableButton": "عرض شهاداتي",
"progress.certificateStatus.notAvailableHeader": "حالة الشهادة", "progress.certificateStatus.notAvailableHeader": "حالة الشهادة",
"progress.certificateBody.notAvailable.endDate": "تمت جدولة ظهور الدرجات النهائية و أي شهادات مكتسبة ابتداءً من {endDate}.",
"progress.certificateStatus.upgradeHeader": "احصل على شهادة.", "progress.certificateStatus.upgradeHeader": "احصل على شهادة.",
"progress.certificateStatus.upgradeBody": "أنت في مسجل في المساق كمستمع ولست مؤهلاً للحصول على شهادة. من أجل الحصول على شهادة ، قم بترقية تسجيلك في المسافق اليوم.", "progress.certificateStatus.upgradeBody": "أنت في مسجل في المساق كمستمع ولست مؤهلاً للحصول على شهادة. من أجل الحصول على شهادة، قم بترقية تسجيلك في المساق اليوم.",
"progress.certificateStatus.upgradeButton": "الترقية الآن", "progress.certificateStatus.upgradeButton": "الترقية الآن",
"progress.certificateStatus.unverifiedHomeHeader.v2": "Verify your identity to qualify for a certificate.", "progress.certificateStatus.unverifiedHomeHeader.v2": "تحقق من هويتك حتى تتأهل للحصول على شهادة.",
"progress.certificateStatus.unverifiedHomeButton": "تحقق من هويتي", "progress.certificateStatus.unverifiedHomeButton": "التحقق من هويتي",
"progress.certificateStatus.unverifiedHomeBody": "من أجل إنشاء شهادة لهذه الدورة ، يجب عليك إكمال عملية التحقق من الهوية.", "progress.certificateStatus.unverifiedHomeBody": "من أجل إنشاء شهادة لهذا المساق، يجب عليك إكمال عملية التحقق من الهوية.",
"progress.completion.donut.label": "مكتمل", "progress.completion.donut.label": "مكتمل",
"progress.completion.body": "يمثل هذا مقدار محتوى الدورة الذي أكملته. لاحظ أن بعض المحتوى قد لا يكون متوفر حتى الان.", "progress.completion.body": "يمثل هذا مقدار ما أكملته من محتوى المساق. لاحظ أن بعض المحتوى قد لا يكون صادرًا بعد.",
"progress.completion.tooltip.locked": "المحتوى الذي أكملته.", "progress.completion.tooltip.locked": "المحتوى الذي أكملته.",
"progress.completion.header": "اكتمال الدورة", "progress.completion.header": "إكمال المساق",
"progress.completion.tooltip": "المحتوى الذي يمكنك الوصول إليه ولم تكتمله بعد.", "progress.completion.tooltip": "المحتوى المتاح لك و الذي لم تكمله بعد.",
"progress.completion.tooltip.complete": "المحتوى المقفل والمتاح فقط لأولئك الذين قامو بترقية تسجلهم بالمساق.", "progress.completion.tooltip.complete": "المحتوى المقفل و المتاح فقط لمن يقوم بالترقية",
"progress.completion.donut.percentComplete": "لقد أكملت {percent}٪ من محتوى هذا المساق.", "progress.completion.donut.percentComplete": "لقد أكملت {percent}% من محتوى هذا المساق.",
"progress.completion.donut.percentIncomplete": "لم تكمل {percent}٪ من محتوى هذا المساق, بالنسبة للمحتوى الذي يمكنك الوصول إليه.", "progress.completion.donut.percentIncomplete": "لم تكمل {percent}% من محتوى هذا المساق، بالنسبة للمحتوى الذي يمكنك الوصول إليه.",
"progress.completion.donut.percentLocked": "{percent}٪ من المحتوى في هذه الدورة مقفول ومتاح فقط لمن قاموا بترقية تسجلهم بالمساق.", "progress.completion.donut.percentLocked": "{percent}% من محتوى هذا المساق مقفل و متاح فقط لمن يقوم بالترقية.",
"progress.creditInformation.creditNotEligible": "لم تعد مؤهلاً للحصول على اعتماد في هذه الدورة التدريبية. اعرف المزيد عن {creditLink}.", "progress.creditInformation.creditNotEligible": "لم تعد مؤهلاً للحصول على اعتماد في هذا المساق. اعرف المزيد عن {creditLink}.",
"progress.creditInformation.creditEligible": "لقد استوفيت متطلبات الحصول على اعتماد في هذه الدورة. انتقل إلى {dashboardLink} لشراء اعتماد الدورة. أو تعرف على المزيد حول {creditLink}.", "progress.creditInformation.creditEligible": "لقد استوفيت متطلبات الحصول على اعتماد في هذا المساق. اذهب إلى {dashboardLink} لشراء اعتماد المساق، أو اعرف المزيد عن {creditLink}.",
"progress.creditInformation.creditPartialEligible": "أنت لم تفي بمتطلبات اعتماد المساق. اعرف المزيد عن {creditLink}.", "progress.creditInformation.creditPartialEligible": "لم تستوف بعد شروط الحصول على اعتماد. اعرف المزيد عن {creditLink}.",
"progress.creditInformation.completed": "مُكتمل", "progress.creditInformation.completed": "مُكتمل",
"progress.creditInformation.courseCredit": "اعتماد المساق", "progress.creditInformation.courseCredit": "اعتماد المساق",
"progress.creditInformation.minimumGrade": "الحد الأدنى لدرجة الاعتماد ({minGrade}٪)", "progress.creditInformation.minimumGrade": "أدنى درجة مؤهلة للحصول على اعتماد ({minGrade}%)",
"progress.creditInformation.requirementsHeader": "متطلبات الحصول على اعتماد للمساق", "progress.creditInformation.requirementsHeader": "متطلبات الحصول على اعتماد المساق",
"progress.creditInformation.upcoming": "قريبًا", "progress.creditInformation.upcoming": "قريبًا",
"progress.creditInformation.verificationFailed": "فشل التحقق", "progress.creditInformation.verificationFailed": "فشل التحقق",
"progress.creditInformation.verificationSubmitted": "تم تقديم التحقق", "progress.creditInformation.verificationSubmitted": "تم إرسال طلب التحقق",
"progress.ungradedAlert": لتقدمك في المحتوى الذي لم يتم تقديره من المساق ، قم بعرض {outlineLink}.", "progress.ungradedAlert": معرفة تقدمك في محتويات المساق غير المنقّطة، اطلع على {outlineLink}ك.",
"progress.footnotes.droppableAssignments": "أدنى {numDroppable، plural, one {# {assignmentType} تجاهله} other {# {assignmentType} تجاهلهم} من النتائج.", "progress.footnotes.droppableAssignments": "لا تٌحتسب أدنى {numDroppable, plural,\n one {درجة}\n two {درجتين}\n few {# درجات}\n many {# درجة}\n other {# درحة}\n} {assignmentType}.",
"progress.assignmentType": "نوع الواجب", "progress.assignmentType": "نوع الواجب",
"progress.footnotes.backToContent": "العودة إلى المحتوى", "progress.footnotes.backToContent": "العودة إلى المحتوى",
"progress.courseGrade.body": "يمثل هذا مكان درجتك الحالية مقابل الدرجة المطلوبة لاجتياز هذه الدورة التدريبية.", "progress.courseGrade.body": "يمثل هذا درجتك الموزونة الحالية مقارنة بالدرجة المطلوبة للنجاح في هذا المساق.",
"progress.courseGrade.gradeBar.altText": "درجتك الحالية هي {currentGrade}٪. مطلوب الحصول على درجة {passingGrade}٪ للنجاح في هذه الدورة التدريبية.", "progress.courseGrade.gradeBar.altText": "درجتك الحالية هي {currentGrade}% مطلوب الحصول على درجة موزونة تبلغ {passingGrade}% للنجاح في هذا المساق.",
"progress.courseGrade.footer.generic.passing": "أنت ناجح حاليًا في هذه الدورة التدريبية", "progress.courseGrade.footer.generic.passing": "أنت ناجح حاليًا في هذا المساق.",
"progress.courseGrade.footer.nonPassing": "مطلوب درجة مرجحة تبلغ {passingGrade}٪ للنجاح في هذه الدورة", "progress.courseGrade.footer.nonPassing": "مطلوب الحصول على درجة موزونة تبلغ {passingGrade}% للنجاح في هذا المساق.",
"progress.courseGrade.footer.passing": "أنت تنجح حاليًا في اجتياز هذه الدورة بتقدير {letterGrade} ({minGrade} - {maxGrade}٪)", "progress.courseGrade.footer.passing": "أنت ناجح حاليًا في هذا المساق بتقدير {letterGrade} ({minGrade} - {maxGrade}%)",
"progress.courseGrade.preview.headerLocked": "ميزة مقفلة", "progress.courseGrade.preview.headerLocked": "ميزة مقفلة",
"progress.courseGrade.preview.headerLimited": "ميزة محدودة", "progress.courseGrade.preview.headerLimited": "ميزة محدودة",
"progress.courseGrade.preview.header.ariaHidden": "معاينة ال", "progress.courseGrade.preview.header.ariaHidden": "معاينة ",
"progress.courseGrade.preview.body.unlockCertificate": "قم بترقية تسجيلك لعرض الدرجات والعمل للحصول على الشهادة.", "progress.courseGrade.preview.body.unlockCertificate": "ارفع القيود لعرض الدرجات و العمل للحصول على شهادة.",
"progress.courseGrade.partialpreview.body.unlockCertificate": "قم بترقية تسجيلك للعمل من أجل الحصول على شهادة.", "progress.courseGrade.partialpreview.body.unlockCertificate": "ارفع القيود من أجل العمل للحصول على شهادة.",
"progress.courseGrade.preview.body.upgradeDeadlinePassed": "انقضى الموعد النهائي للترقية في هذه الدورة.", "progress.courseGrade.preview.body.upgradeDeadlinePassed": "انقضى أجَل الترقية في هذا المساق",
"progress.courseGrade.preview.button.upgrade": "الترقية الآن", "progress.courseGrade.preview.button.upgrade": "الترقية الآن",
"progress.courseGrade.gradeRange.tooltip": "نطاقات الدرجات لهذه الدورة:", "progress.courseGrade.gradeRange.tooltip": "مجالات الدرجات لهذا المساق:",
"progress.courseOutline": "مخطّط المساق", "progress.courseOutline": "مخطّط مساق",
"progress.courseGrade.label.currentGrade": "درجتك الحالية", "progress.courseGrade.label.currentGrade": "درجتك الحالية",
"progress.detailedGrades": "الدرجات التفصيلية", "progress.detailedGrades": "الدرجات التفصيلية",
"progress.detailedGrades.emptyTable": "ليس لديك أي مسائل بحاجة للتقييم.", "progress.detailedGrades.emptyTable": "ليست لديك حاليا أي درجات لمسائل منقّطة.",
"progress.footnotes.title": "ملاحظات لملخص الدرجات", "progress.footnotes.title": "ملاحظات بخصوص ملخص الدرجات",
"progress.gradeSummary.grade": "الدرجة", "progress.gradeSummary.grade": "الدرجة",
"progress.courseGrade.grades": "درجات", "progress.courseGrade.grades": "الدرجات",
"progress.courseGrade.gradesAndCredit": "الدرجات والساعات المعتمدة", "progress.courseGrade.gradesAndCredit": "الدرجات و الاعتماد",
"progress.courseGrade.gradeRange.Tooltip": "عن نطاق الدرجات", "progress.courseGrade.gradeRange.Tooltip": "تلميح عن مجال الدرجة",
"progress.gradeSummary": "ملخص الدرجة", "progress.gradeSummary": "ملخص الدرجات",
"progress.gradeSummary.limitedAccessExplanation": "لديك وصول محدود إلى الواجبات المقدرة كجزء من تسجيلك ( كمستمع ) في هذه الدورة التدريبية.", "progress.gradeSummary.limitedAccessExplanation": "وصولك للواجبات المنقّطة محدود، لكونك مسجلا صمن مسار الاستماع في هذا المساق.",
"progress.gradeSummary.tooltip.alt": "عن ملخص الدرجات", "progress.gradeSummary.tooltip.alt": "تلميح عن ملخص الدرجات",
"progress.gradeSummary.tooltip.body": "يتم تحديد وزن كل واجب من المقرر الدراسي الخاص, بك من قبل مدرسك. (حاصل الضرب ل تقيمك بواجب معين ب وزن الواجب نفسه) ينتج عن ذلك درجتك الموزنة. درجتك الموزنة هي ما يتم استخدامه لتحديد ما إذا كنت ستنجح في الدورة التدريبية.", "progress.gradeSummary.tooltip.body": "أوزان الواحبات في مساقك محددة من طرف الأستاذ. يتم حساب الدرجة الموزونة بضرب درجتك في وزن ذلك الواجب. درجتك الموزنة هي ما يُستخدم لتحديد ما إن كنت ناجحًا في المساق.",
"progress.noAcessToAssignmentType": "ليس لديك حق الوصول إلى الواجبات من النوع {assignmentType}", "progress.noAcessToAssignmentType": " لا يمكنك الوصول إلى الواجبات من نوع \"{assignmentType}\"",
"progress.noAcessToSubsection": يس لديك حق الوصول إلى جزء {displayName}", "progress.noAcessToSubsection": ا يمكنك الوصول إلى القسم الفرعي \"{displayName}\"",
"progress.courseGrade.label.passingGrade": "درجة النجاح", "progress.courseGrade.label.passingGrade": "درجة النجاح",
"progress.detailedGrades.problemScore.label": "درجات الواجبات:", "progress.detailedGrades.problemScore.label": "درجات المسائل:",
"progress.detailedGrades.problemScore.toggleButton": "اظهار التقييم لكل مسألة ب {subsectionTitle}", "progress.detailedGrades.problemScore.toggleButton": "إظهار / إخفاء درجة كل مسألة في {subsectionTitle}",
"progress.detailedGrades.overridden": "تم تجاوز درجة القسم.", "progress.detailedGrades.overridden": "تم استبدال درجة هذا القسم.",
"progress.score": "النتيجة", "progress.score": "الدرجة",
"progress.weight": "القيمة", "progress.weight": "الوزن",
"progress.weightedGrade": "درجتك الموزنة", "progress.weightedGrade": "الدرجة الموزونة",
"progress.weightedGradeSummary": "ملخص درجتك الموزنة الحالية", "progress.weightedGradeSummary": "ملخص الدرجات الموزونة الحالية",
"progress.header": "تقدمك", "progress.header": "تقدمك",
"progress.header.targetUser": "التقدم في المساق لـ {username}", "progress.header.targetUser": "تقدم {username} في المساق",
"progress.link.studio": "استعراض عملية التقييم في استوديو", "progress.link.studio": "استعراض التقييم في الاستوديو",
"progress.relatedLinks.datesCard.description": "عرض الجدول الزمني لتواريخ المستحقة والتعيينات القادمة ل واجبات الدورة.", "progress.relatedLinks.datesCard.description": "عرض لجدول زمني يتضمن الآجال و الواجبات القادمة لمساقك.",
"progress.relatedLinks.datesCard.link": "تواريخ الاستحقاق", "progress.relatedLinks.datesCard.link": "التواريخ",
"progress.relatedLinks.outlineCard.description": "نظرة عامة لمحتوى دورتك.", "progress.relatedLinks.outlineCard.description": "نظرة عامة لمحتوى مساقك.",
"progress.relatedLinks.outlineCard.link": "مخطّط المساق", "progress.relatedLinks.outlineCard.link": "مخطّط المساق",
"progress.relatedLinks": "روابط ذات صلة", "progress.relatedLinks": "روابط ذات صلة",
"datesBanner.suggestedSchedule": "لقد وضعنا جدولاً زمنيًا مقترحًا لمساعدتك على وضع خطة تعليم. لكن لا تقلق - فهي خطة مرنة حتى تتمكن من التعلم بالسرعة التي تناسبك.", "datesBanner.suggestedSchedule": "لقد بنينا جدولا زمنيا مقترحًا لمساعدتك في البقاء على السكة. لكن لا تقلق، فهو مرن و يمكّنك من التعلّم وفق وتيرتك الخاصة.",
"datesBanner.upgradeToCompleteGradedBanner.header": "قم بالترقية لالغاء القفل.", "datesBanner.upgradeToCompleteGradedBanner.header": "قم بالترقية لرفع القيود",
"datesBanner.upgradeToCompleteGradedBanner.body": "أنت ملتحق كمستمع في هذه الدورة التدريبية ، مما يعني أنك غير قادر على المشاركة في الواجبات المقدرة. لإكمال الواجبات المقدرة كجزء من هذه الدورة التدريبية ، يمكنك الترقية اليوم.", "datesBanner.upgradeToCompleteGradedBanner.body": "أنت مستمع في هذا المساق، ما يعني أنك غير قادر على المشاركة في الواجبات المنقّطة. لإكمال الواجبات المنقّطة كجزء من هذا المساق، يمكنك الترقية اليوم.",
"datesBanner.upgradeToCompleteGradedBanner.button": "ترقية الآن", "datesBanner.upgradeToCompleteGradedBanner.button": "الترقية الآن",
"datesBanner.upgradeToResetBanner.body": "للبقاء على المسار الصحيح، يمكن تحديث هذا الجدول وتحويل المهام السابقة المستحقة إلى مهام تاريخ استحقاقها في المستقبل. لا داعي للقلق - لن تفقد أي تقدم أحرزته عند تغيير تواريخ الاستحقاق.", "datesBanner.upgradeToResetBanner.body": "للبقاء على السكة، يمكنك تحديث هذا الجدول و تغيير آجال الواجبات المنقضية إلى آجال مستقبلية. لا داعي للقلق، لن تفقد أي تقدم أحرزته عند تغيير الآجال.",
"datesBanner.upgradeToResetBanner.button": "قم بالترقية لتغيير التواريخ المستحقة", "datesBanner.upgradeToResetBanner.button": "الترقية لتغيير الآجال",
"datesBanner.resetDatesBanner.header": "يبدو أنه قد فاتتك بعض المواعيد النهائية المهمة بناءً على جدولنا الزمني المقترح.", "datesBanner.resetDatesBanner.header": "يبدو أنك قد تخطيت بعض الآجال المهمة حسب جدولنا الزمني المقترح.",
"datesBanner.resetDatesBanner.body": "للبقاء على المسار الصحيح، يمكن تحديث هذا الجدول وتحويل المهام السابقة المستحقة إلى المستقبل. لا داعي للقلق - لن تفقد أي تقدم أحرزته عند تغيير تواريخ الاستحقاق.", "datesBanner.resetDatesBanner.body": "للبقاء على السكة، يمكنك تحديث هذا الجدول و تغيير آجال الواجبات المنقضية إلى آجال مستقبلية. لا داعي للقلق، لن تفقد أي تقدم أحرزته عند تغيير الآجال.",
"datesBanner.resetDatesBanner.button": "تغيير التواريخ المستحقة", "datesBanner.resetDatesBanner.button": "تغيير الآجال",
"learn.navigation.course.tabs.label": "مواد المساق", "learn.navigation.course.tabs.label": "مواد المساق",
"unit.bookmark.button.add.bookmark": "إضافة الصفحة للمفضلة", "unit.bookmark.button.add.bookmark": "التأشير على هذه الصفحة",
"unit.bookmark.button.remove.bookmark": "تم وضع علامة مرجعية", "unit.bookmark.button.remove.bookmark": "تم التأشير على الصفحة",
"learning.celebration.completed": "لقد أكملت للتو القسم الأول من مساقك.", "learning.celebration.completed": "لقد أكملت للتو القسم الأول من مساقك.",
"learning.celebration.congrats": "تهانينا!", "learning.celebration.congrats": "تهانينا!",
"learning.celebration.earned": "لقد استحققته.", "learning.celebration.earned": "لقد حققته!",
"learning.celebration.emailSubject": "أنا في طريقي لإتمام {title} عن بعد في {platform}!", "learning.celebration.emailSubject": "أنا في طريقي لإتمام {title} عن بعد في {platform}!",
"learning.celebration.forward": "استمر ", "learning.celebration.forward": "واصل",
"learning.celebration.goalMet": "لقد حققت هدفك!", "learning.celebration.goalMet": "لقد حققت هدفك!",
"learning.celebration.keepItUp": "أبقه على ما انت عليه", "learning.celebration.keepItUp": "واصل على هذا المنوال",
"learning.celebration.share": "لحظة من فضلك للاحتفاء بك ومشاركتك فرحة تقدمك", "learning.celebration.share": "خذ لحظة للاحتفاء بتقدّمك و مشاركته.",
"learning.celebration.social": "أنا في طريقي لإتمام {title} عن بعد في {platform}. ما الذي تقضي الوقت في تعلمه؟", "learning.celebration.social": "أنا في طريقي لإتمام مساق \"{title}\" عن بعد مع {platform}. ما الذي تقضي الوقت في تعلمه؟",
"learning.celebration.goalCongrats": "تهانينا ، لقد حققت هدفك التعليمي وهو {nTimes} في الأسبوع.", "learning.celebration.goalCongrats": "تهانينا، فقد حققت هدفك التعلّمي و هو {nTimes} أسبوعيا.",
"learning.celebration.setGoal": "يمكن أن يساعدك تحديد هدف {strongText} في مقررك الدراسي.", "learning.celebration.setGoal": "إن تحديد هدف {strongText} قد يساعدك في مساقك.",
"calculator.instructions.button.label": "إرشادات الحاسبة", "calculator.instructions.button.label": "إرشادات الآلة الحاسبة",
"calculator.instructions": "لمزيد من التفاصيل، تفضل بالاطلاع على {expressions_link}.", "calculator.instructions": "لمزيد من المعلومات، اطّلع على {expressions_link}.",
"calculator.instructions.support.title": "مركز المساعدة", "calculator.instructions.support.title": "مركز المساعدة",
"calculator.instructions.useful.tips": "تلميحات مفيدة:", "calculator.instructions.useful.tips": "نصائح مفيدة:",
"calculator.hint1": "يُرجى استخدام القوسين () لإيضاح التعابير. ويمكنك استخدام قوسين داخل قوسين آخرين. ", "calculator.hint1": "استخدم القوسين () لجعل التعابير واضحة. يمكنك استخدام الأقواس بعضها داخل بعض. ",
"calculator.hint2": "لا تستخدم مسافات في التعابير.", "calculator.hint2": "لا تستخدم مسافات في التعابير.",
"calculator.hint3": "بالنسبة للثوابت، حدِّد عملية الضرب بوضوح (مثلًا: 5*c). ", "calculator.hint3": "بالنسبة للثوابت، أبرز عملية الضرب بوضوح (مثلًا: 5*c).",
"calculator.hint4": "بالنسبة لللاحقات، أَدخِل الرقم والملحق من دون مسافة بينهما (مثلًا: 5c). ", "calculator.hint4": "بالنسبة للواحق، اكتب الرقم فاللاحقة دون مسافة (مثلًا: 5c).",
"calculator.hint5": "بالنسبة للوظائف، أدخِل اسم الوظيفة، يتبعه التعبير بين قوسين. ", "calculator.hint5": "بالنسبة للدوال، اكتب اسم الدالة، ثم التعبير بين قوسين.",
"calculator.instruction.table.to.use.heading": لاستخدام", "calculator.instruction.table.to.use.heading": "لاستخدام",
"calculator.instruction.table.type.heading": "النوع", "calculator.instruction.table.type.heading": "النوع",
"calculator.instruction.table.examples.heading": "أمثلة", "calculator.instruction.table.examples.heading": "أمثلة",
"calculator.instruction.table.to.use.numbers": "أرقام", "calculator.instruction.table.to.use.numbers": "الأرقام",
"calculator.instruction.table.to.use.numbers.type1": "أعداد صحيحة", "calculator.instruction.table.to.use.numbers.type1": "أعداد الصحيحة",
"calculator.instruction.table.to.use.numbers.type2": "كسور", "calculator.instruction.table.to.use.numbers.type2": "كسور",
"calculator.instruction.table.to.use.numbers.type3": "أعداد عشرية", "calculator.instruction.table.to.use.numbers.type3": "أعداد العشرية",
"calculator.instruction.table.to.use.operators": "المعامِلات الحسابية", "calculator.instruction.table.to.use.operators": "العمليات الحسابية",
"calculator.instruction.table.to.use.operators.type1": "(زائد، ناقص، ضرب، قسمة)", "calculator.instruction.table.to.use.operators.type1": "(جمع، طرح، ضرب و قسمة)",
"calculator.instruction.table.to.use.operators.type2": "(الرفع إلى قوة)", "calculator.instruction.table.to.use.operators.type2": "(الرفع إلى قوة)",
"calculator.instruction.table.to.use.operators.type3": "(مقاومات متوازية)", "calculator.instruction.table.to.use.operators.type3": "(مقاومات متوازية)",
"calculator.instruction.table.to.use.constants": "الثوابت ", "calculator.instruction.table.to.use.constants": "الثوابت ",
"calculator.instruction.table.to.use.affixes": "اللاحقات", "calculator.instruction.table.to.use.affixes": "اللواحق",
"calculator.instruction.table.to.use.affixes.type": "علامة النسبة المئوية (٪)", "calculator.instruction.table.to.use.affixes.type": "علامة النسبة المئوية (%)",
"calculator.instruction.table.to.use.basic.functions": "الاقترانات الأساسية", "calculator.instruction.table.to.use.basic.functions": "الدوال الأساسية",
"calculator.instruction.table.to.use.trig.functions": "الاقترانات المثلثية", "calculator.instruction.table.to.use.trig.functions": "الدوال المثلثية",
"calculator.instruction.table.to.use.scientific.notation": "الكتابة العلميّة للأعداد", "calculator.instruction.table.to.use.scientific.notation": "الكتابة العلميّة للأعداد",
"calculator.instruction.table.to.use.scientific.notation.type1": "{exponentSyntax} والأس", "calculator.instruction.table.to.use.scientific.notation.type1": "{exponentSyntax} و الأس",
"calculator.instruction.table.to.use.scientific.notation.type2": "{notationSyntax} الترميز", "calculator.instruction.table.to.use.scientific.notation.type2": "كتابة {notationSyntax}",
"calculator.instruction.table.to.use.scientific.notation.type3": "{notationSyntax}والأس", "calculator.instruction.table.to.use.scientific.notation.type3": "{notationSyntax} و الأس",
"calculator.button.label": "الآلة الحاسبة ", "calculator.button.label": "الآلة الحاسبة ",
"calculator.input.field.label": "الإدخال في الآلة الحاسبة", "calculator.input.field.label": "أدخل في الآلة الحاسبة",
"calculator.submit.button.label": "احسب", "calculator.submit.button.label": "حساب",
"calculator.result.field.label": "نتيجة الحاسبة", "calculator.result.field.label": "نتيجة الآلة الحاسبة",
"calculator.result.field.placeholder": "النتيجة", "calculator.result.field.placeholder": "النتيجة",
"notes.button.show": "إظهار الملاحظات", "notes.button.show": "إظهار الملاحظات",
"notes.button.hide": "إخفاء الملاحظات", "notes.button.hide": "إخفاء الملاحظات",
"courseExit.catalogSearchSuggestion": "هل تطمح إلى تعلّم المزيد؟{searchOurCatalogLink} لاستكشاف المزيد من المساقات والبرامج.", "courseExit.catalogSearchSuggestion": "هل انت متطلع للتعلّم أكثر؟ {searchOurCatalogLink} عن مزيد من المساقات والبرامج.",
"courseCelebration.certificateBody.available": "اعرض إنجازاتك على لينكد إن أو سيرتك الذاتية اليوم.\nيمكنك تنزيل الشهادة الآن والوصول إليها في أي وقت من\n{dashboardLink} و{profileLink}.", "courseCelebration.certificateBody.available": "اعرض إنجازاتك على لينكد إن أو سيرتك الذاتية اليوم.\nيمكنك تنزيل الشهادة الآن والوصول إليها في أي وقت من\n{dashboardLink} و {profileLink}.",
"courseCelebration.certificateBody.notAvailable.endDate.v2": "This course ends on {endDate}. Final grades and any earned certificates are\n scheduled to be available after {certAvailableDate}.", "courseCelebration.certificateBody.notAvailable.endDate.v2": "ينتهي هذا المساق في {endDate}. تمت جدولة ظهور الدرجات النهائية و أي شهادات مكتسبة ابتداءً من {certAvailableDate}.",
"courseCelebration.certificateBody.unverified": إنشاء شهادة يجب عليك إتمام عملية التحقق من الهوية.\n{idVerificationSupportLink} الآن.", "courseCelebration.certificateBody.unverified": توليد شهادة، يجب عليك إتمام التحقق من الهوية.\n{idVerificationSupportLink} الآن.",
"courseCelebration.certificateBody.upgradable": "لم يفت الأوان للترقية. بالنسبة لـ {price} ستقوم بإلغاء تأمين الوصول إلى كافة أنواع \nالواجبات في هذا المساق. عند الانتهاء، ستحصل على شهادة تم التحقق منها وهي إحدى\nالوثائق القيّمة لتحسين فرصك الوظيفية وتطويرك المهني، أو لتسليط الضوء على\nشهادة في التطبيقات التعليمية.", "courseCelebration.certificateBody.upgradable": "لم يفت الأوان بعد للترقية. مقابل {price}، سترفع القيود عن كل الواجبات المنقّطة في هذا المساق. عند الانتهاء، ستحصل على شهادة معتمدة، وهي من الوثائق القيّمة التي تنفع لتوسيع آفاقك في التوظيف و تقدمك في حياتك المهنية، أو لإبراز شهادتك في طلبات الالتحاق بالمدارس.",
"courseCelebration.upgradeDiscountCodePrompt": "استخدم الرمز {code} عند إتمام الطلب لخصم {percent}%!", "courseCelebration.upgradeDiscountCodePrompt": "استخدم الرمز {code} عند الشراء للاستفادة من خصم {percent}%!",
"courseCelebration.recommendations.heading": "استمر في بناء مهاراتك مع هذه الدورات!", "courseCelebration.recommendations.heading": "واصل بناء مهاراتك مع هذه المساقات!",
"courseCelebration.recommendations.label": "المساق", "courseCelebration.recommendations.label": "المساق",
"courseCelebration.recommendations.formatting.list_join": "{style, select, punctuation {, } conjunction { {sp}and } other { }}", "courseCelebration.recommendations.formatting.list_join": "{style, select, punctuation {، } conjunction { {sp}و } other { }}",
"courseCelebration.recommendations.browse_catalog": "اكتشف المزيد من الدورات", "courseCelebration.recommendations.browse_catalog": "اكتشف المزيد من المساقات",
"courseCelebration.recommendations.loading_recommendations": "جاري تحميل التوصيات", "courseCelebration.recommendations.loading_recommendations": "تحميل المقترحات جارٍ",
"courseCelebration.recommendations.card.schools.label": "المؤسسات التعليمية والشركاء", "courseCelebration.recommendations.card.schools.label": "المدارس و الشركاء",
"courseCelebration.dashboardInfo": "يمكنك الوصول إلى هذا المساق ومواده على لوحة معلوماتك {dashboardLink}.", "courseCelebration.dashboardInfo": "يمكنك الوصول إلى هذا المساق و مواده من {dashboardLink}.",
"courseExit.programs.applyForCredit": "تقدم بطلب للحصول على ائتمان", "courseExit.programs.applyForCredit": "تقدّم بطلب للحصول على اعتماد",
"courseCelebration.certificateHeader.downloadable": "!شهادتك جاهزة", "courseCelebration.certificateHeader.downloadable": "!شهادتك جاهزة",
"courseCelebration.certificateHeader.notAvailable": "Your grade and certificate status will be available soon.", "courseCelebration.certificateHeader.notAvailable": "ستتوفر حالة درجتك وشهادتك قريبًا.",
"courseCelebration.certificateBody.notAvailable.accessCertificate": ذا كنت قد حصلت على درجة النجاح ، فسيتم إصدار شهادتك تلقائيًا.", "courseCelebration.certificateBody.notAvailable.accessCertificate": ن كنت قد حصلت على درجة نجاح، فإن شهادتك ستصدر تلقائيا.",
"courseCelebration.certificateHeader.unverified": "يجب إكمال عملية التحقق للحصول على شهادتك", "courseCelebration.certificateHeader.unverified": "يجب إكمال عملية التحقق للحصول على شهادتك",
"courseCelebration.certificateHeader.requestable": "تهانينا، لقد تأهلت للحصول على شهادة!", "courseCelebration.certificateHeader.requestable": "تهانينا، لقد تأهلت للحصول على شهادة!",
"courseCelebration.certificateHeader.upgradable": "قم بالترقية للحصول على شهادة معتمدة", "courseCelebration.certificateHeader.upgradable": "الترقية الآن للحصول على شهادة معتمدة",
"courseCelebration.certificateImage": "عينة الشهادة", "courseCelebration.certificateImage": "عينة الشهادة",
"courseCelebration.completedCourseHeader": "لقد أكملت دورتك.", "courseCelebration.completedCourseHeader": "لقد أكملت مساقك.",
"courseCelebration.congratulationsHeader": "تهانينا!", "courseCelebration.congratulationsHeader": "تهانينا!",
"courseCelebration.congratulationsImage": "يرفع أربعة أشخاص أيديهم احتفاءً", "courseCelebration.congratulationsImage": "يرفع أربعة أشخاص أيديهم احتفاءً",
"courseExit.courseInProgressDescription": "يبدو أن هناك المزيد من المحتوى في هذه الدورة والذي سيتم إصداره في المستقبل. ابقى على اطلاع بتحديثات البريد الإلكتروني أو تحقق مرة أخرى من الدورة التدريبية الخاصة بك لمعرفة وقت توفر هذا المحتوى.", "courseExit.courseInProgressDescription": "يبدو أن في هذا المساق مزيدا من المحتوى سيصدر مستقبلا. ستقبل. ترقب التحديثات عبر البريد الإلكتروني أو تفقّد هذا المساق لاحقًا لمعرفة موعد صدور هذا المحتوى.",
"courseExit.courseInProgressHeader": "المزيد من المحتوى قريبا!", "courseExit.courseInProgressHeader": "مزيد من المحتوى قادم قريبا!",
"courseExit.dashboardLink": "لوحة المعلومات", "courseExit.dashboardLink": "لوحة المعلومات",
"courseCelebration.downloadButton": "تحميل شهاداتي", "courseExit.endOfCourseDescription": "مع الأسف، لست مؤهلًا حاليا للحصول على شهادة. أنت بحاجة لتحصيل درجة نجاح كي تتأهل للحصول على شهادة.",
"courseExit.endOfCourseDescription": "لسوء الحظ، لست مؤهلًا الآن للحصول على شهادة. تحتاج إلى تحقيق درجة الاجتياز تؤهلك للحصول على شهادة.",
"courseExit.endOfCourseHeader": "لقد أتممت المساق!", "courseExit.endOfCourseHeader": "لقد أتممت المساق!",
"courseExit.endOfCourseTitle": "نهاية المساق", "courseExit.endOfCourseTitle": "نهاية المساق",
"courseExit.idVerificationSupportLink": "اعرف المزيد عن عملية التحقق من الهوية", "courseExit.idVerificationSupportLink": "اعرف المزيد عن عملية التحقق من الهوية",
"courseCelebration.linkedinAddToProfileButton": "إضافة إلى ملف شخصي على لينكد إن", "courseCelebration.linkedinAddToProfileButton": "إضافة إلى ملف شخصي على لينكد إن",
"courseExit.programs.microBachelors.learnMore": "تعرف على المزيد حول كيفية تقديم بيانات اعتماد البكالوريوس المصغر للحصول على ائتمان.", "courseExit.programs.microBachelors.learnMore": "اعرف المزيد عن كيفية طلب اعتماد مقابل مؤهل MicroBachelors الذي حصلت عليه.",
"courseExit.programs.microMasters.learnMore": "تعرف على المزيد حول عملية تطبيق شهادات MicroMasters على درجة الماجستير.", "courseExit.programs.microMasters.learnMore": "اعرف المزيد عن عملية تطبيق شهادات MicroMasters على شهادة الماستر / الماجستير.",
"courseExit.programs.microMasters.mastersMessage": ذا كنت مهتماً باستخدام شهادة MicroMasters الخاصة بك في برنامج Master، فيمكنك البدء اليوم!", "courseExit.programs.microMasters.mastersMessage": ن كنت مهتماً باستخدام شهادة MicroMasters التي حصلت عليها للتسجيل في برنامج Master، فيمكنك البدء في ذلك اليوم!",
"learn.sequence.navigation.complete.button": "أكمل المساق", "learn.sequence.navigation.complete.button": "أكمل المساق",
"courseExit.nextButton.endOfCourse": "التالي (نهاية المساق)", "courseExit.nextButton.endOfCourse": "التالي (نهاية المساق)",
"courseExit.profileLink": "الملف الشخصي", "courseExit.profileLink": "الملف الشخصي",
"courseExit.programs.lastCourse": "لقد أتممت المساق الأخير في {title}!", "courseExit.programs.lastCourse": "لقد أتممت المساق الأخير في {title}!",
"courseCelebration.requestCertificateBodyText": تصفح شهادتك ارفع الطلب أدناه.", "courseCelebration.requestCertificateBodyText": لوصول إلى شهادتك، اطلبها أدناه.",
"courseCelebration.requestCertificateButton": "طلب شهادة", "courseCelebration.requestCertificateButton": "طلب الشهادة",
"courseExit.searchOurCatalogLink": "ابحث في الدليل", "courseExit.searchOurCatalogLink": "ابحث في دليلنا",
"courseCelebration.shareMessage": "شارك نجاحك على وسائل التواصل الاجتماعي أو البريد الإلكتروني.", "courseCelebration.shareMessage": "شارك نجاحك على وسائل التواصل الاجتماعي أو البريد الإلكتروني.",
"courseExit.social.shareCompletionMessage": "أتممت للتو {title} في {platform}!", "courseExit.social.shareCompletionMessage": "لقد أتممت للتو \"{title}\" مع {platform}!",
"courseExit.upgradeButton": "ترقية الآن", "courseExit.upgradeButton": "الترقية الآن",
"courseExit.upgradeLink": "الترقية الآن", "courseExit.upgradeLink": "الترقية الآن",
"courseCelebration.verificationPending": "عملية التحقق من المعرّف الخاص بك معلّقة وستتوفر شهادتك بمجرد الموافقة.", "courseCelebration.verificationPending": "عملية التحقق من هويتك في انتظار المراجعة وستتوفر شهادتك بمجرد الموافقة.",
"courseExit.verifiedCertificateSupportLink": "تعرف أكثر على الشهادات المعتمدة", "courseExit.verifiedCertificateSupportLink": "معرفة المزيد عن الشهادات المعتمدة",
"courseCelebration.verifyIdentityButton": "تحقق من الهوية الآن", "courseCelebration.verifyIdentityButton": "التحقق من الهوية الآن",
"courseCelebration.viewCertificateButton": "عرض شهاداتي", "courseCelebration.viewCertificateButton": "عرض شهاداتي",
"courseExit.viewCourseScheduleButton": "عرض الجدول الزمني للدورة", "courseExit.viewCourseScheduleButton": "عرض الجدول الزمني للمساق",
"courseExit.viewCoursesButton": "عرض مساقاتي الملتحق بها", "courseExit.viewCoursesButton": "عرض مساقاتي",
"courseExit.viewGradesButton": "عرض الدرجات", "courseExit.viewGradesButton": "عرض الدرجات",
"courseExit.programCompletion.dashboardMessage": "لعرض حالة الشهادة، تحقق من قسم البرامج في {programLink}.", "courseExit.programCompletion.dashboardMessage": "لعرض حالة الشهادة، تحقق من قسم البرامج في {programLink}.",
"courseExit.upgradeFootnote": "يتوفر الوصول إلى هذا المساق ومواده على لوحة المعلومات حتى {expirationDate}. لزيادة فترة إتاحة الوصول، {ترقيتها Link}.", "courseExit.upgradeFootnote": "سيبقى الوصول إلى هذا المساق و مواده على لوحة المعلومات ممكنا إلى غاية {expirationDate}. لتمديد فترة الوصول، قم ب{upgradeLink}.",
"learn.course.license.allRightsReserved.text": "جميع الحقوق محفوظة", "learn.course.license.allRightsReserved.text": "جميع الحقوق محفوظة",
"learn.course.license.creativeCommons.terms.preamble": "محتوى مرخّص كمشاع إبداعي وفق الشروط التالية:", "learn.course.license.creativeCommons.terms.preamble": "محتوى مرخّص كمشاع إبداعي وفق الشروط التالية:",
"learn.course.license.creativeCommons.terms.by": "الإسناد", "learn.course.license.creativeCommons.terms.by": "الإسناد (Attribution)",
"learn.course.license.creativeCommons.terms.nc": "استخدام غير تجاري", "learn.course.license.creativeCommons.terms.nc": "غير تجاري (Non-Commercial)",
"learn.course.license.creativeCommons.terms.nd": "عدم الاقتباس", "learn.course.license.creativeCommons.terms.nd": "دون اشتقاقات (No Derivatives)",
"learn.course.license.creativeCommons.terms.sa": "مشاركة بالتساوي", "learn.course.license.creativeCommons.terms.sa": " المشاركة بالمثل (Share Alike)",
"learn.course.license.creativeCommons.terms.zero": "لا توجد عناصر", "learn.course.license.creativeCommons.terms.zero": "دون شروط",
"learn.course.license.creativeCommons.text": "بعض الحقوق محفوظة", "learn.course.license.creativeCommons.text": "بعض الحقوق محفوظة",
"learn.breadcrumb.navigation.course.home": "المساق ", "learn.breadcrumb.navigation.course.home": "المساق ",
"notification.tray.container": "Notification tray", "notification.tray.container": "دُرج الإشعارات",
"notification.open.button": "Show notification tray", "notification.open.button": "إظهار دٌرج الإشعارات",
"notification.close.button": "Close notification tray", "notification.close.button": "إغلاق دٌرج الإشعارات",
"responsive.close.notification": "العودة إلى الدورة", "responsive.close.notification": "العودة إلى المساق",
"notification.tray.title": "إشعارات", "notification.tray.title": "الإشعارات",
"notification.tray.no.message": "ليس لديك إشعارات جديدة في هذا الوقت.", "notification.tray.no.message": "ليست لديك إشعارات جديدة في الوقت الراهن.",
"learn.contentLock.content.locked": "محتوى مغلق", "learn.contentLock.content.locked": "المحتوى مقفل",
"learn.contentLock.complete.prerequisite": "يجب استيفاء المتطلبات الأساسية: '{priceqSectionName}' للوصول إلى هذا المحتوى.", "learn.contentLock.complete.prerequisite": "You must complete the prerequisite: ''{prereqSectionName}'' to access this content.",
"learn.contentLock.goToSection": "انتقل إلى قسم المتطلبات الأساسية", "learn.contentLock.goToSection": "انتقل إلى القسم المتطلّب.",
"learn.hiddenAfterDue.gradeAvailable": ذا كنت قد أكملت هذا الواجب ، فسيكون تقديرك متاحًا على {progressPage}.", "learn.hiddenAfterDue.gradeAvailable": ن كنت قد أكملت هذا الواجب، فإن درجتك ستظهر في {progressPage}.",
"learn.hiddenAfterDue.header": "انقضى الموعد النهائي لهذا الواجب.", "learn.hiddenAfterDue.header": "انقضى أجَل هذا الواجب.",
"learn.hiddenAfterDue.description": "Because the due date has passed, this assignment is no longer available.", "learn.hiddenAfterDue.description": "نظرًا لانقضاء الأجَل، فإن هذا الواجب لم يعد متاحًا.",
"learn.hiddenAfterDue.progressPage": "صفحة تقدمك", "learn.hiddenAfterDue.progressPage": "صفحة التقدم",
"learn.honorCode.content": "Honesty and academic integrity are important to {siteName} and the institutions providing courses and programs on the {siteName} site. By clicking “I agree” below, I confirm that I have read, understand, and will abide by the {link} for the {siteName} Site.", "learn.honorCode.content": "الصدق و النزاهة الأكاديمية أمران مهمان بالنسبة إلى {siteName} و المؤسسات التي تقدم المساقات و البرامج على موقع {siteName}. بالنقر على “أنا موافق“ أدناه، أؤكد أني قد قرأت و فهمت و سألتزم ب{link} في موقع {siteName}.",
"learn.honorCode.name": "ميثاق الشرف الأكاديمي", "learn.honorCode.name": "ميثاق الشرف",
"learn.honorCode.cancel": "إلغاء", "learn.honorCode.cancel": "إلغاء",
"learn.honorCode.agree": "أنا موافق", "learn.honorCode.agree": "أنا موافق",
"learn.lockPaywall.title": "الواجبات المقدرة غير متاحة لك", "learn.lockPaywall.title": "الواجبات المنقّطة مقفلة",
"learn.lockPaywall.content": "قم بالترقية للوصول إلى الميزات المقفلة مثل هذه الميزة وتحقيق أقصى استفادة من الدورة التدريبية الخاصة بك.", "learn.lockPaywall.content": "قم بالترقية للوصول إلى الميزات المقفلة كهذه الميزة و تحقيق أقصى استفادة من مساقك.",
"learn.lockPaywall.content.pastExpiration": "انقضى الموعد النهائي للترقية لهذه الدورة. للترقية ، قم بالتسجيل في الجلسة التالية المتاحة.", "learn.lockPaywall.content.pastExpiration": "لقد انقضى أجَل الترقية لهذا المساق.، سجل نفسك في الدورة التالية المتاحة.",
"learn.lockPaywall.courseDetails": "عرض تفاصيل الدورة", "learn.lockPaywall.courseDetails": "عرض تفاصيل المساق",
"learn.lockPaywall.example.alt": "عينة الشهادة", "learn.lockPaywall.example.alt": "عينة الشهادة",
"learn.lockPaywall.list.intro": "عندما تقوم بالترقية ، فإنك:", "learn.lockPaywall.list.intro": "عندما تقوم بالترقية، فإنك:",
"learn.header.h2.placeholder": "Level 2 headings may be created by course providers in the future.", "learn.header.h2.placeholder": "قد يتم إنشاء عناوين المستوى 2 من طرف مزودي المساق مستقبلا.",
"learn.course.load.failure": "حدث خطأ أثناء تحميل هذا المساق.", "learn.course.load.failure": "حدث خطأ أثناء تحميل هذا المساق.",
"learn.loading.honor.codk": "جارٍ تحميل ميثاق الشرف ...", "learn.loading.honor.codk": "جارٍ تحميل نص ميثاق الشرف...",
"learn.loading.content.lock": "جارٍ تحميل رسائل المحتوى المغلق...", "learn.loading.content.lock": "جارٍ تحميل رسالة المحتوى المغلق...",
"learn.loading.learning.sequence": "جارِ تحميل سلسلة التعلّم...", "learn.loading.learning.sequence": "جارٍ تحميل السلسلة التعليمية...",
"learn.sequence.no.content": "لا يوجد محتوى هنا.", "learn.sequence.no.content": "لا يوجد محتوى هنا.",
"learn.sequence.navigation.next.button": "التالي", "learn.sequence.navigation.next.button": "التالي",
"learn.sequence.navigation.next.up.button": "التالي: {title}", "learn.sequence.navigation.next.up.button": "التالي: {title}",
"learn.sequence.navigation.previous.button": "السابق", "learn.sequence.navigation.previous.button": "السابق",
"learn.course.sequence.navigation.mobile.menu": "{current} من أصل {total}", "learn.course.sequence.navigation.mobile.menu": "{current} من {total}",
"discussions.sidebar.title": "نقاشات", "discussions.sidebar.title": "المناقشات",
"discussions.sidebar.open.button": "Show discussions tray", "discussions.sidebar.open.button": "إظهار دٌرج المناقشات",
"learn.redirect.interstitial.message": "إعادة توجيه...", "learn.redirect.interstitial.message": "إعادة التوجيه جارية...",
"learn.loading.error": "خطأ: {error}", "learn.loading.error": "خطأ: {error}",
"learning.celebration.emailBody": "ما الذي تقضي وقتك في تعلّمه؟", "learning.celebration.emailBody": "ما الذي تقضي وقتك في تعلّمه؟",
"learning.social.shareEmail": "شارك تقدمك عبر البريد الإلكتروني.", "learning.social.shareEmail": "شارك تقدمك عبر البريد الإلكتروني.",
"learning.social.shareService": "شارك تقدمك على {service}.", "learning.social.shareService": "شارك تقدمك على {service}.",
"general.altText.close": "إغلاق ", "general.altText.close": "إغلاق ",
"learning.logistration.register": "تسجيل", "learning.logistration.register": "افتح حسابًا جديدًا",
"learning.logistration.login": "تسجيل الدخول", "learning.logistration.login": "سجل دخولك",
"general.signIn.sentenceCase": "تسجيل الدخول", "general.signIn.sentenceCase": "تسجيل الدخول",
"learn.course.tabs.navigation.overflow.menu": "المزيد...", "learn.course.tabs.navigation.overflow.menu": "المزيد...",
"learning.offer.screenReaderPrices": "السعر الأصلي: {originalPrice}, سعر الخصم: {discountedPrice}", "learning.offer.screenReaderPrices": "السعر الأصلي: {originalPrice}، السعر المخفَّض: {discountedPrice}",
"learning.upgradeButton.screenReaderInlinePrices": "السعر الأصلي: {originalPrice}", "learning.upgradeButton.screenReaderInlinePrices": "السعر الأصلي: {originalPrice}",
"learning.upgradeButton.buttonText": "Upgrade for {pricing}", "learning.upgradeButton.buttonText": "الترقية مقابل {pricing}",
"learning.upgradeNowButton.buttonText": "Upgrade now for {pricing}", "learning.upgradeNowButton.buttonText": "الترقية الآن مقابل {pricing}",
"learning.generic.upgradeNotification.expirationAccessLoss.progress": "including any progress", "learning.generic.upgradeNotification.expirationAccessLoss.progress": "بما في ذلك أي تقدم",
"learning.generic.upgradeNotification.expirationVerifiedCert.benefits": "benefits of upgrading", "learning.generic.upgradeNotification.expirationVerifiedCert.benefits": "فوائد الترقية",
"learning.generic.upgradeNotification.expirationAccessLoss": "ستفقد كل إمكانية الوصول إلى هذه الدورة التدريبية ، {includingAnyProgress} ، في {date}.", "learning.generic.upgradeNotification.expirationAccessLoss": "ستفقد كل إمكانية الوصول إلى هذا المساق، {includingAnyProgress}، في {date}.",
"learning.generic.upgradeNotification.expirationVerifiedCert": "Upgrading your course enables you to pursue a verified certificate and unlocks numerous features. Learn more about the {benefitsOfUpgrading}.", "learning.generic.upgradeNotification.expirationVerifiedCert": "ترقية مساقك تتيح لك السعي للحصول شهادة معتمدة، و ترفع القيود عن العديد من الميزات. اعرف المزيد عن {benefitsOfUpgrading}.",
"learning.generic.upgradeNotification.pastExpiration.content": "The upgrade deadline for this course passed. To upgrade, enroll in the next available session.", "learning.generic.upgradeNotification.pastExpiration.content": "لقد انقضى أجَل الترقية لهذا المساق. للترقية، سجل نفسك في الدورة التالية المتاحة.",
"learning.generic.upgradeNotification.expirationDays": "{dayCount, number} {dayCount, plural, \n one {day}\n other {days}} left", "learning.generic.upgradeNotification.expirationDays": "{dayCount, plural, \n one {بقي يوم واحد}\n two {بقي يومان}\n few {بقيت # أيام}\n many {بقي # يومًا}\n other {بقي # يوم}\n}",
"learning.generic.upgradeNotification.expirationHours": "{hourCount, number} {hourCount, plural,\n one {hour}\n other {hours}} left", "learning.generic.upgradeNotification.expirationHours": "بقيت {hourCount, plural, \n one {ساعة و احدة}\n two {ساعتان}\n few {# ساعات}\n many {# ساعة}\n}",
"learning.generic.upgradeNotification.expirationMinutes": "أقل من 1 ساعة متبقية", "learning.generic.upgradeNotification.expirationMinutes": "بقيت أقل من ساعة",
"learning.generic.upgradeNotification.expiration": "ستنتهي صلاحية الوصول إلى الدورة التدريبية في {date}", "learning.generic.upgradeNotification.expiration": "ستنتهي صلاحية الوصول إلى المساق في {date}",
"learning.generic.upgradeNotification.pastExpiration.banner": "Upgrade deadline passed on {date}", "learning.generic.upgradeNotification.pastExpiration.banner": "انقضى أجَل الترقية في {date}",
"learning.generic.upgradeNotification.firstTimeLearnerDiscount": "{percentage}% First-Time Learner Discount", "learning.generic.upgradeNotification.firstTimeLearnerDiscount": "خصم {percentage}% للمتعلم أول مرة",
"learning.generic.upgradeNotification.accessExpiration": "قم بترقية التحاقك بالدورة اليوم", "learning.generic.upgradeNotification.accessExpiration": "قم بترقية مساقك اليوم",
"learning.generic.upgradeNotification.accessExpirationUrgent": "Course Access Expiration", "learning.generic.upgradeNotification.accessExpirationUrgent": " نهاية صلاحية الوصول إلى المساق",
"learning.generic.upgradeNotification.accessExpirationPast": "Course Access Expiration", "learning.generic.upgradeNotification.accessExpirationPast": "نهاية صلاحية الوصول إلى المساق",
"learning.generic.upgradeNotification.pursueAverifiedCertificate": "اسعَ للحصول على شهادة معتمدة", "learning.generic.upgradeNotification.pursueAverifiedCertificate": "اسعَ للحصول على شهادة معتمدة",
"learning.generic.upgradeNotification.code": "استخدم الكود {code} عند الدفع", "learning.generic.upgradeNotification.code": "استخدم الكود {code} عند الشراء",
"learning.generic.upsell.verifiedCertBullet.verifiedCert": "verified certificate", "learning.generic.upsell.verifiedCertBullet.verifiedCert": "شهادة معتمدة",
"learning.generic.upsell.verifiedCertBullet": "Earn a {verifiedCertLink} of completion to showcase on your resumé", "learning.generic.upsell.verifiedCertBullet": "تحصل على {verifiedCertLink} لإتمام المساق لتعرضها في سيرتك الذاتية.",
"learning.generic.upsell.unlockGradedBullet.gradedAssignments": "graded assignments", "learning.generic.upsell.unlockGradedBullet.gradedAssignments": "الواجبات المنقّطة",
"learning.generic.upsell.unlockGradedBullet": "Unlock your access to all course activities, including {gradedAssignmentsInBoldText}", "learning.generic.upsell.unlockGradedBullet": "ترفع قيودك عن جميع أنشطة المساق، بما في ذلك {gradedAssignmentsInBoldText}",
"learning.generic.upsell.fullAccessBullet.fullAccess": "Full access", "learning.generic.upsell.fullAccessBullet.fullAccess": "تستفيد من كل",
"learning.generic.upsell.fullAccessBullet": "{fullAccessInBoldText} to course content and materials, even after the course ends", "learning.generic.upsell.fullAccessBullet": "{fullAccessInBoldText} محتوى و مواد المساق، حتى بعد انتهائه",
"learning.generic.upsell.supportMissionBullet.mission": "mission", "learning.generic.upsell.supportMissionBullet.mission": "رسالتنا",
"learning.generic.upsell.supportMissionBullet": "ادعم {missionInBoldText} في edX", "learning.generic.upsell.supportMissionBullet": "تدعم {missionInBoldText} في {siteName}",
"masquerade-widget.userName.error.generic": "حدث خطأ؛ يرجى المحاولة مرة أخرى.", "masquerade-widget.userName.error.generic": "حدث خطأ ما. رجاءً حاول مجددًا",
"masquerade-widget.userName.input.placeholder": "اسم المستخدم أو البريد الإلكتروني", "masquerade-widget.userName.input.placeholder": "اسم المستخدم أو البريد الإلكتروني",
"masquerade-widget.userName.input.label": "عرف كهذا المستخدم", "masquerade-widget.userName.input.label": "تقمص دور هذا المستخدم",
"tours.abandonTour.launchTourCheckpoint.body": "Feeling lost? Launch the tour any time for some quick tips to get the most out of the experience.", "tours.abandonTour.launchTourCheckpoint.body": "تشعر بالضياع؟ ابدأ الجولة في أي وقت للحصول على بعض النصائح السريعة لتحقيق أقصى استفادة من التجربة.",
"tours.sequenceNavigationCheckpoint.body": "The top bar within your course allows you to easily jump to different sections and shows you whats coming up.", "tours.sequenceNavigationCheckpoint.body": "يتيح لك الشريط العلوي في مساقك الانتقال بسهولة إلى أقسام مختلفة ويظهر لك ما هو قادم.",
"tours.existingUserTour.launchTourCheckpoint.body": "Weve recently added a few new features to the course experience. Want some help looking around? Take a tour to learn more.", "tours.existingUserTour.launchTourCheckpoint.body": "لقد أضفنا مؤخرًا بعض الميزات الجديدة إلى تجربة المساق. هل أتريد بعض المساعدة في البحث حولك؟ قم بجولة لمعرفة المزيد.",
"tours.button.dismiss": "تجاهل", "tours.button.dismiss": "تجاهل",
"tours.button.next": "التالي", "tours.button.next": "التالي",
"tours.button.okay": "تمام", "tours.button.okay": "حسنًا",
"tours.button.beginTour": "ابدأ الجولة", "tours.button.beginTour": "بدأ الجولة",
"tours.button.launchTour": "انطلاق الجولة", "tours.button.launchTour": "إطلاق الجولة",
"tours.newUserModal.body": "Lets take a quick tour of edX so you can get the most out of your course.", "tours.newUserModal.body": "لنذهب في جولة سريعة على {siteName} تمكنك من تحقيق أقصى استفادة من مساقك.",
"tours.newUserModal.title.welcome": "مرحبًا بك في", "tours.newUserModal.title.welcome": "مرحبًا بك في",
"tours.button.skipForNow": "تخطي في الوقت الراهن", "tours.button.skipForNow": "التخطي مؤقتا",
"tours.datesCheckpoint.body": "Important dates can help you stay on track.", "tours.datesCheckpoint.body": "قد تكون التواريخ المهمة عونا لك للبقاء على السكة.",
"tours.datesCheckpoint.title": "Keep on top of key dates", "tours.datesCheckpoint.title": "ابق مٌطّلعًا على أهم التواريخ",
"tours.outlineCheckpoint.body": "You can explore sections of the course using the outline below.", "tours.outlineCheckpoint.body": "يمكنك استكشاف أقسام المساق من خلال المخطط أدناه.",
"tours.outlineCheckpoint.title": "التحق في الدورة!", "tours.outlineCheckpoint.title": "إبدأ المساق!",
"tours.tabNavigationCheckpoint.body": "يمكن استخدام علامات التبويب هذه للوصول إلى مواد الدورة التدريبية الأخرى ، مثل تقدمك والمنهج الدراسي وما إلى ذلك.", "tours.tabNavigationCheckpoint.body": "يمكن استخدام هذه التبويبات للوصول إلى بقية مواد المساق الأخرى كتقدمك، و المنهاج، و غير ذلك.",
"tours.tabNavigationCheckpoint.title": "موارد الدورة الإضافية", "tours.tabNavigationCheckpoint.title": "موارد المساق الإضافية",
"tours.upgradeCheckpoint.body": "Work towards a certificate and gain full access to course materials. Upgrade now!", "tours.upgradeCheckpoint.body": "اعمل للحصول على شهادة و استفد من كافة مواد المساق. قم بالترقية الآن!",
"tours.upgradeCheckpoint.title": "قم بترفية دورتك", "tours.upgradeCheckpoint.title": "رفع القيود عن مساقك",
"tours.weeklyGoalsCheckpoint.body": "يؤدي تحديد خظة التعلم إلى زيادة احتمالية إتمام دورتك التدريبية.", "tours.weeklyGoalsCheckpoint.body": "إن تحديد هدف يجعلك أقرب ﻹتمام المساق.",
"tours.weeklyGoalsCheckpoint.title": "حدد خظة التعلم", "tours.weeklyGoalsCheckpoint.title": "حدد هدفًا للمساق",
"tours.newUserModal.title": "{welcome} دورة {siteName}!", "tours.newUserModal.title": "{welcome} مساق {siteName}!",
"learning.effortEstimation.combinedEstimate": "{minutes} + {activities}", "learning.effortEstimation.combinedEstimate": "{minutes} + {activities}",
"learning.effortEstimation.activities": "{activityCount, plural, zero {# أنشطة} one {# نشاط} two {# أنشطة} few {# أنشطة} many {# أنشطة} other {# أنشطة}}", "learning.effortEstimation.activities": "{activityCount, plural, zero {لا أنشطة} one {نشاط واحد} two {نشاطان} few {# أنشطة} many {# نشاطًا} other {# نشاط}}",
"learning.effortEstimation.minutesAbbreviated": "{minuteCount, plural, zero {# دقيقة} one {# دقيقة} two {# دقيقة} few {# دقيقة} many {# دقيقة} other {# دقائق}}", "learning.effortEstimation.minutesAbbreviated": "{minuteCount, plural, zero {#د} one {#د} two {#د} few {#د} many {#د} other {#د}}",
"learning.effortEstimation.minutesFull": "{minuteCount, plural, zero {# الدقائق} one {# اللحظة} two {# الدقائق} few {# الدقائق} many {# الدقائق} other {# دقائق}}", "learning.effortEstimation.minutesFull": "{minuteCount, plural, zero {# دقيقة} one {# دقيقة واحدة} two {# دقيقتان} few {# دقائق} many {# دقيقة} other {# دقيقة}}",
"learning.streakCelebration.congratulations": "تهانينا!", "learning.streakCelebration.congratulations": "تهانينا!",
"learning.streakCelebration.body": "استمر في التقدم ، أنت بوضع ممتاز!", "learning.streakCelebration.body": "واصل التقدم، حافظ على حماسك!",
"learning.streakCelebration.button": "أبق على ما انت عليه !", "learning.streakCelebration.button": "واصل على هذا المنوال",
"learning.streakCelebration.buttonSrOnly": "إغلاق الوسائط والمتابعة", "learning.streakCelebration.buttonSrOnly": "إغلاق النافذة و المتابعة",
"learning.streakCelebration.buttonAA759": "أكمل المساق", "learning.streakCelebration.buttonAA759": "المواصلة مع المساق",
"learning.streakCelebration.header": "ايام متتالية", "learning.streakCelebration.header": "أيام متتالية",
"learning.streakCelebration.factoidABoldedSection": "تزيد احتمالية اجتيازهم الدورة التدريبية بمقدار 20 ضعفًا", "learning.streakCelebration.factoidABoldedSection": "يزيد احتمال نجاحهم في مساقهم بـ 20 ضعفًا",
"learning.streakCelebration.factoidBBoldedSection": "أكمل بمعدل 5 أضعاف محتوى الدورة التدريبية", "learning.streakCelebration.factoidBBoldedSection": "يكملون في المتوسط أكثر من 5 أضعاف محتوى المساق الذي يكمله",
"learning.streakCelebration.streakDiscountMessage": "لقد قمت بتفعيل خصم {percent}٪ بمكنك استخدامه عند ترقية هذه الدورة التدريبية لفترة محدودة فقط.", "learning.streakCelebration.streakDiscountMessage": "لقد حصلت على خصم {percent}% لترقية هذا المساق، و هو صالح لفترة محدودة فقط.",
"learning.streakcelebration.factoida": "المستخدمون الذين يتعلمون {streak_length} يومًا متتاليًا {bolded_section} أكثر من أولئك الذين لا يتعلمون.", "learning.streakcelebration.factoida": "إن المستخدمين الذين يتعلمون ل{streak_length, plural,\n two {يومين}\n few {# أيام}\n many {# يومًا}\n other {# يوم}\n} على التوالي {bolded_section} عن الذين لا يفعلون مثلهم.",
"learning.streakcelebration.factoidb": "المستخدمون الذين يتعلمون {streak_length} يومًا متتاليًا {bolded_section} مقابل أولئك الذين لا يفعلون ذلك.", "learning.streakcelebration.factoidb": "إن المستخدمين الذين يتعلمون ل{streak_length, plural,\n two {يومين}\n few {# أيام}\n many {# يومًا}\n other {# يوم}\n} على التوالي {bolded_section} من لا يفعلون مثلهم.",
"learning.streakCelebration.streakCelebrationCouponEndDateMessage": "ينتهي في {date}.", "learning.streakCelebration.streakCelebrationCouponEndDateMessage": "ينتهي في {date}.",
"learning.loading.failure": "حدث خطأ أثناء تحميل هذا المساق.", "learning.loading.failure": "حدث خطأ ما أثناء تحميل هذا المساق.",
"learning.loading": "يتم الآن تحميل صفحة المساق..." "learning.loading": "جارٍ تحميل صفحة المساق..."
} }

View File

@@ -4,6 +4,8 @@
"learning.accessExpiration.body": "Pierdes todo acceso a este curso, incluyendo tu progreso, el {fecha}.", "learning.accessExpiration.body": "Pierdes todo acceso a este curso, incluyendo tu progreso, el {fecha}.",
"instructorToolbar.pageBanner.courseHasExpired": "El estudiante ya no cuenta con acceso para este curso. Su acceso expiró en {fecha}.", "instructorToolbar.pageBanner.courseHasExpired": "El estudiante ya no cuenta con acceso para este curso. Su acceso expiró en {fecha}.",
"learning.accessExpiration.upgradeNow": "Actualizar Ahora", "learning.accessExpiration.upgradeNow": "Actualizar Ahora",
"learning.activeEnterprise.alert": "{changeActiveEnterprise}.",
"learning.activeEnterprise.change.alert": "cambiar de empresa ahora",
"learning.outline.alert.start.short": "El curso comienza en {timeRemaining} a la/s {courseStartTime}.", "learning.outline.alert.start.short": "El curso comienza en {timeRemaining} a la/s {courseStartTime}.",
"learning.outline.alert.end.long": "Este curso terminará en {timeRemaining} el {courseEndDate}.", "learning.outline.alert.end.long": "Este curso terminará en {timeRemaining} el {courseEndDate}.",
"learning.outline.alert.end.calendar": "No olvides añadir un recordatorio en el calendario.", "learning.outline.alert.end.calendar": "No olvides añadir un recordatorio en el calendario.",
@@ -18,8 +20,8 @@
"account-activation.resend.link": "reenviar el email", "account-activation.resend.link": "reenviar el email",
"learning.logistration.alert": "Para ver el contenido del curso, {signIn} o {register}.", "learning.logistration.alert": "Para ver el contenido del curso, {signIn} o {register}.",
"account-activation.alert.title": "Activa tu cuenta para poder volver a conectarte", "account-activation.alert.title": "Activa tu cuenta para poder volver a conectarte",
"learn.sequence.entranceExamTextNotPassing": "To access course materials, you must score {entranceExamMinimumScorePct}% or higher on this exam. Your current score is {entranceExamCurrentScore}%.", "learn.sequence.entranceExamTextNotPassing": "Para acceder a los materiales del curso, debe obtener un {entranceExamMinimumScorePct}% o más en este examen. Su puntaje actual es {entranceExamCurrentScore}%.",
"learn.sequence.entranceExamTextPassed": "Your score is {entranceExamCurrentScore}%. You have passed the entrance exam.", "learn.sequence.entranceExamTextPassed": "Tu puntuación es {entranceExamCurrentScore}%. Has aprobado el examen de ingreso.",
"learning.dates.badge.completed": "Completado", "learning.dates.badge.completed": "Completado",
"learning.dates.badge.dueNext": "Próximo vencimiento", "learning.dates.badge.dueNext": "Próximo vencimiento",
"learning.dates.badge.pastDue": "Vencido", "learning.dates.badge.pastDue": "Vencido",
@@ -34,8 +36,8 @@
"learning.goals.unsubscribe.header": "Te has desinscrito de los recordatorios de objetivos.", "learning.goals.unsubscribe.header": "Te has desinscrito de los recordatorios de objetivos.",
"learning.goals.unsubscribe.loading": "Desinscribiendo...", "learning.goals.unsubscribe.loading": "Desinscribiendo...",
"learning.goals.unsubscribe.errorDescription": "No fue posible desinscribirte de tus correos de recordatorios de objetivos. Por favor inténtalo más tarde o ponte en contacto con el equipo de soporte para solicitar ayuda {contactSupport}", "learning.goals.unsubscribe.errorDescription": "No fue posible desinscribirte de tus correos de recordatorios de objetivos. Por favor inténtalo más tarde o ponte en contacto con el equipo de soporte para solicitar ayuda {contactSupport}",
"learning.outline.alert.cert.earnedNotAvailable": "This course ends on {courseEndDateFormatted}. Final grades and any earned certificates are\n scheduled to be available after {certificateAvailableDate}.", "learning.outline.alert.cert.earnedNotAvailable": "Este curso finaliza el {courseEndDateFormatted}. Las calificaciones finales y los certificados obtenidos estarán disponibles después de esta fecha: {certificateAvailableDate}.",
"cert.alert.earned.unavailable.header.v2": "Your grade and certificate status will be available soon.", "cert.alert.earned.unavailable.header.v2": "El estado de su calificación y certificado estarán disponible pronto.",
"cert.alert.earned.ready.header": "¡Felicitaciones! Tu certificado está listo.", "cert.alert.earned.ready.header": "¡Felicitaciones! Tu certificado está listo.",
"cert.alert.notPassing.header": "Aún no eres elegible para obtener un certificado", "cert.alert.notPassing.header": "Aún no eres elegible para obtener un certificado",
"cert.alert.notPassing.button": "Ver calificaciones", "cert.alert.notPassing.button": "Ver calificaciones",
@@ -92,6 +94,7 @@
"learning.proctoringPanel.status.error": "Error", "learning.proctoringPanel.status.error": "Error",
"learning.proctoringPanel.status.otherCourseApproved": "Aprobado en otro curso", "learning.proctoringPanel.status.otherCourseApproved": "Aprobado en otro curso",
"learning.proctoringPanel.status.expiringSoon": "Expira pronto", "learning.proctoringPanel.status.expiringSoon": "Expira pronto",
"learning.proctoringPanel.status.expired": "Expired",
"learning.proctoringPanel.status": "Estado actual de la integración:", "learning.proctoringPanel.status": "Estado actual de la integración:",
"learning.proctoringPanel.message.notStarted": "No has comenzado tu examen de integración.", "learning.proctoringPanel.message.notStarted": "No has comenzado tu examen de integración.",
"learning.proctoringPanel.message.started": "Has comenzado tu examen de integración.", "learning.proctoringPanel.message.started": "Has comenzado tu examen de integración.",
@@ -101,7 +104,8 @@
"learning.proctoringPanel.message.error": "Se ha producido un error durante tu examen de integración. Vuelve a intentar la integración.", "learning.proctoringPanel.message.error": "Se ha producido un error durante tu examen de integración. Vuelve a intentar la integración.",
"learning.proctoringPanel.message.otherCourseApproved": "Tu examen de ingreso ha sido aprobado en otro curso.", "learning.proctoringPanel.message.otherCourseApproved": "Tu examen de ingreso ha sido aprobado en otro curso.",
"learning.proctoringPanel.detail.otherCourseApproved": "Si tu dispositivo ha cambiado, te recomendamos que realices el examen de ingreso de este curso para asegurarte de que tu configuración sigue cumpliendo los requisitos para el examen supervisado.", "learning.proctoringPanel.detail.otherCourseApproved": "Si tu dispositivo ha cambiado, te recomendamos que realices el examen de ingreso de este curso para asegurarte de que tu configuración sigue cumpliendo los requisitos para el examen supervisado.",
"learning.proctoringPanel.message.expiringSoon": "Tu perfil de ingreso ha sido aprobado en otro curso. Sin embargo, tu estado de ingreso expirará pronto. Vuelve a completar el proceso de ingreso para asegurarte de que puedas seguir realizando los exámenes supervisados.", "learning.proctoringPanel.message.expiringSoon": "Your onboarding profile has been approved. However, your onboarding status is expiring soon. Please complete onboarding again to ensure that you will be able to continue taking proctored exams.",
"learning.proctoringPanel.message.expired": "Your onboarding status has expired. Please complete onboarding again to continue taking proctored exams.",
"learning.proctoringPanel.generalInfo": "Debes completar el proceso de integración antes de realizar cualquier examen supervisado. ", "learning.proctoringPanel.generalInfo": "Debes completar el proceso de integración antes de realizar cualquier examen supervisado. ",
"learning.proctoringPanel.generalInfoSubmitted": "Tu perfil enviado está en revisión.", "learning.proctoringPanel.generalInfoSubmitted": "Tu perfil enviado está en revisión.",
"learning.proctoringPanel.generalTime": "La revisión del perfil de ingreso puede tardar más de 2 días laborables.", "learning.proctoringPanel.generalTime": "La revisión del perfil de ingreso puede tardar más de 2 días laborables.",
@@ -113,7 +117,7 @@
"learning.outline.sequence-due": "Fecha límite para {description}: {assignmentDue}", "learning.outline.sequence-due": "Fecha límite para {description}: {assignmentDue}",
"progress.certificateStatus.unverifiedBody": "Para generar un certificado, debes completar la verificación de identidad. {idVerificationSupportLink}.", "progress.certificateStatus.unverifiedBody": "Para generar un certificado, debes completar la verificación de identidad. {idVerificationSupportLink}.",
"progress.certificateStatus.downloadableBody": "Muestra tu logro en LinkedIn o en tu currículum. Puedes descargar tu certificado ahora y acceder a él en cualquier momento desde tu panel de estudiante y tu perfil.", "progress.certificateStatus.downloadableBody": "Muestra tu logro en LinkedIn o en tu currículum. Puedes descargar tu certificado ahora y acceder a él en cualquier momento desde tu panel de estudiante y tu perfil.",
"courseCelebration.certificateBody.notAvailable.endDate": "This course ends on {endDate}. Final grades and any earned certificates are\n scheduled to be available after {certAvailabilityDate}.", "courseCelebration.certificateBody.notAvailable.endDate": "Las calificaciones finales y los certificados obtenidos están programados para estar disponibles después de {endDate}.",
"progress.certificateStatus.notPassingHeader": "Estado del certificado", "progress.certificateStatus.notPassingHeader": "Estado del certificado",
"progress.certificateStatus.notPassingBody": "Para poder obtener un certificado, es necesario tener una calificación de aprobado.", "progress.certificateStatus.notPassingBody": "Para poder obtener un certificado, es necesario tener una calificación de aprobado.",
"progress.certificateStatus.inProgressHeader": "¡Pronto habrá más contenido!", "progress.certificateStatus.inProgressHeader": "¡Pronto habrá más contenido!",
@@ -125,13 +129,13 @@
"progress.certificateStatus.unverifiedButton": "Verificar tu identidad", "progress.certificateStatus.unverifiedButton": "Verificar tu identidad",
"progress.certificateStatus.courseCelebration.verificationPending": "Su verificación de ID está pendiente y su certificado estará disponible una vez que se haya aprobado.", "progress.certificateStatus.courseCelebration.verificationPending": "Su verificación de ID está pendiente y su certificado estará disponible una vez que se haya aprobado.",
"progress.certificateStatus.downloadableHeader": "¡Tu certificado está disponible!", "progress.certificateStatus.downloadableHeader": "¡Tu certificado está disponible!",
"progress.certificateStatus.downloadableButton": "Descargar mi certificado",
"progress.certificateStatus.viewableButton": "Ver mi certificado", "progress.certificateStatus.viewableButton": "Ver mi certificado",
"progress.certificateStatus.notAvailableHeader": "Estado del certificado", "progress.certificateStatus.notAvailableHeader": "Estado del certificado",
"progress.certificateBody.notAvailable.endDate": "Las calificaciones finales y los certificados obtenidos estarán disponibles después de {endDate}.",
"progress.certificateStatus.upgradeHeader": "Obtén un certificado", "progress.certificateStatus.upgradeHeader": "Obtén un certificado",
"progress.certificateStatus.upgradeBody": "Estás en la opción auditada y no calificas para un certificado. Para poder obtener un certificado, cambiate a la opción verificada del curso hoy mismo.", "progress.certificateStatus.upgradeBody": "Estás en la opción auditada y no calificas para un certificado. Para poder obtener un certificado, cambiate a la opción verificada del curso hoy mismo.",
"progress.certificateStatus.upgradeButton": "Actualizar Ahora", "progress.certificateStatus.upgradeButton": "Actualizar Ahora",
"progress.certificateStatus.unverifiedHomeHeader.v2": "Verify your identity to qualify for a certificate.", "progress.certificateStatus.unverifiedHomeHeader.v2": "Verifique su identidad a fin de calificar para un certificado.",
"progress.certificateStatus.unverifiedHomeButton": "Verificar mi identidad", "progress.certificateStatus.unverifiedHomeButton": "Verificar mi identidad",
"progress.certificateStatus.unverifiedHomeBody": "Para generar un certificado para este curso, debes completar el proceso de verificación de identidad.", "progress.certificateStatus.unverifiedHomeBody": "Para generar un certificado para este curso, debes completar el proceso de verificación de identidad.",
"progress.completion.donut.label": "Completado", "progress.completion.donut.label": "Completado",
@@ -246,7 +250,7 @@
"calculator.instruction.table.to.use.operators.type3": "(resistencias en paralelo)", "calculator.instruction.table.to.use.operators.type3": "(resistencias en paralelo)",
"calculator.instruction.table.to.use.constants": "Constantes", "calculator.instruction.table.to.use.constants": "Constantes",
"calculator.instruction.table.to.use.affixes": "Añadidos", "calculator.instruction.table.to.use.affixes": "Añadidos",
"calculator.instruction.table.to.use.affixes.type": "Percent sign (%)", "calculator.instruction.table.to.use.affixes.type": "Signo de porcentaje (%)",
"calculator.instruction.table.to.use.basic.functions": "Funciones basicas", "calculator.instruction.table.to.use.basic.functions": "Funciones basicas",
"calculator.instruction.table.to.use.trig.functions": "Funciones trigonometricas", "calculator.instruction.table.to.use.trig.functions": "Funciones trigonometricas",
"calculator.instruction.table.to.use.scientific.notation": "Notación Científica", "calculator.instruction.table.to.use.scientific.notation": "Notación Científica",
@@ -262,7 +266,7 @@
"notes.button.hide": "Ocultar Notas", "notes.button.hide": "Ocultar Notas",
"courseExit.catalogSearchSuggestion": "¿Quieres saber más? {searchOurCatalogLink} para buscar más cursos y programas por explorar.", "courseExit.catalogSearchSuggestion": "¿Quieres saber más? {searchOurCatalogLink} para buscar más cursos y programas por explorar.",
"courseCelebration.certificateBody.available": "\n Muestra tu logro en LinkedIn o en tu currículum hoy mismo.\n Puedes descargar tu certificado ahora y acceder a él en cualquier momento desde tu\n {dashboardLink} y {profileLink}.", "courseCelebration.certificateBody.available": "\n Muestra tu logro en LinkedIn o en tu currículum hoy mismo.\n Puedes descargar tu certificado ahora y acceder a él en cualquier momento desde tu\n {dashboardLink} y {profileLink}.",
"courseCelebration.certificateBody.notAvailable.endDate.v2": "This course ends on {endDate}. Final grades and any earned certificates are\n scheduled to be available after {certAvailableDate}.", "courseCelebration.certificateBody.notAvailable.endDate.v2": "Este curso finaliza el {endDate}. Las calificaciones finales y los certificados obtenidos estarán disponibles después de la siguiente fecha {certAvailableDate}.",
"courseCelebration.certificateBody.unverified": "Para generar un certificado, debes completar la verificación de ID.\n {idVerificationSupportLink} ahora.", "courseCelebration.certificateBody.unverified": "Para generar un certificado, debes completar la verificación de ID.\n {idVerificationSupportLink} ahora.",
"courseCelebration.certificateBody.upgradable": "No es demasiado tarde para mejorar de categoría. Por {price}, obtendrás acceso a todas las asignaciones\n calificadas de este curso. Al terminar, recibirás un certificado verificado que es una\n valiosa credencial para mejorar tus perspectivas de trabajo y avanzar en tu carrera, o puedes usar dicho\n certificado para destacarlo en solicitudes universitarias.", "courseCelebration.certificateBody.upgradable": "No es demasiado tarde para mejorar de categoría. Por {price}, obtendrás acceso a todas las asignaciones\n calificadas de este curso. Al terminar, recibirás un certificado verificado que es una\n valiosa credencial para mejorar tus perspectivas de trabajo y avanzar en tu carrera, o puedes usar dicho\n certificado para destacarlo en solicitudes universitarias.",
"courseCelebration.upgradeDiscountCodePrompt": "Utiliza el código {code} en el momento de la compra para obtener un {percent} % de descuento.", "courseCelebration.upgradeDiscountCodePrompt": "Utiliza el código {code} en el momento de la compra para obtener un {percent} % de descuento.",
@@ -275,7 +279,7 @@
"courseCelebration.dashboardInfo": "Puedes acceder a este curso y a sus materiales en tu {dashboardLink}.", "courseCelebration.dashboardInfo": "Puedes acceder a este curso y a sus materiales en tu {dashboardLink}.",
"courseExit.programs.applyForCredit": "Solicitar crédito", "courseExit.programs.applyForCredit": "Solicitar crédito",
"courseCelebration.certificateHeader.downloadable": "¡Tu certificado está disponible!", "courseCelebration.certificateHeader.downloadable": "¡Tu certificado está disponible!",
"courseCelebration.certificateHeader.notAvailable": "Your grade and certificate status will be available soon.", "courseCelebration.certificateHeader.notAvailable": "El estado de su calificación y certificado estarán disponible pronto.",
"courseCelebration.certificateBody.notAvailable.accessCertificate": "Si has obtenido una calificación de aprobado, tu certificado se emitirá automáticamente.", "courseCelebration.certificateBody.notAvailable.accessCertificate": "Si has obtenido una calificación de aprobado, tu certificado se emitirá automáticamente.",
"courseCelebration.certificateHeader.unverified": "Debes completar la verificación para recibir tu certificado.", "courseCelebration.certificateHeader.unverified": "Debes completar la verificación para recibir tu certificado.",
"courseCelebration.certificateHeader.requestable": "¡Felicitaciones, usted califica para recibir un certificado!", "courseCelebration.certificateHeader.requestable": "¡Felicitaciones, usted califica para recibir un certificado!",
@@ -287,7 +291,6 @@
"courseExit.courseInProgressDescription": "Parece que hay más contenido en este curso que se publicará en el futuro. Presta atención a las novedades por correo electrónico o consulta tu curso para saber cuándo estará disponible este contenido.", "courseExit.courseInProgressDescription": "Parece que hay más contenido en este curso que se publicará en el futuro. Presta atención a las novedades por correo electrónico o consulta tu curso para saber cuándo estará disponible este contenido.",
"courseExit.courseInProgressHeader": "¡Pronto habrá más contenido!", "courseExit.courseInProgressHeader": "¡Pronto habrá más contenido!",
"courseExit.dashboardLink": "Panel de Control", "courseExit.dashboardLink": "Panel de Control",
"courseCelebration.downloadButton": "Descargar mi certificado",
"courseExit.endOfCourseDescription": "Lamentablemente, no puedes obtener un certificado en este momento. Debes recibir una calificación aprobatoria para poder obtener un certificado.", "courseExit.endOfCourseDescription": "Lamentablemente, no puedes obtener un certificado en este momento. Debes recibir una calificación aprobatoria para poder obtener un certificado.",
"courseExit.endOfCourseHeader": "¡Has llegado al fin del curso!", "courseExit.endOfCourseHeader": "¡Has llegado al fin del curso!",
"courseExit.endOfCourseTitle": "Fin del curso", "courseExit.endOfCourseTitle": "Fin del curso",
@@ -332,7 +335,7 @@
"notification.tray.title": "Notificaciones", "notification.tray.title": "Notificaciones",
"notification.tray.no.message": "No tienes notificaciones nuevas en este momento.", "notification.tray.no.message": "No tienes notificaciones nuevas en este momento.",
"learn.contentLock.content.locked": "Contenido Bloqueado", "learn.contentLock.content.locked": "Contenido Bloqueado",
"learn.contentLock.complete.prerequisite": "Debe completar el prerrequisito: '{prereqSectionName}'para acceder a este contenido.", "learn.contentLock.complete.prerequisite": "You must complete the prerequisite: ''{prereqSectionName}'' to access this content.",
"learn.contentLock.goToSection": "Ir a la Sección de Prerrequisitos", "learn.contentLock.goToSection": "Ir a la Sección de Prerrequisitos",
"learn.hiddenAfterDue.gradeAvailable": "Si has completado esta tarea, tu calificación estará disponible en {progressPage}.", "learn.hiddenAfterDue.gradeAvailable": "Si has completado esta tarea, tu calificación estará disponible en {progressPage}.",
"learn.hiddenAfterDue.header": "La fecha límite para esta tarea ha pasado.", "learn.hiddenAfterDue.header": "La fecha límite para esta tarea ha pasado.",
@@ -344,8 +347,8 @@
"learn.honorCode.agree": "Estoy de acuerdo ", "learn.honorCode.agree": "Estoy de acuerdo ",
"learn.lockPaywall.title": "Las tareas calificadas están bloqueadas", "learn.lockPaywall.title": "Las tareas calificadas están bloqueadas",
"learn.lockPaywall.content": "Cámbiate a la opción verificada para obtener acceso a funciones bloqueadas como esta y aprovechar al máximo tu curso.", "learn.lockPaywall.content": "Cámbiate a la opción verificada para obtener acceso a funciones bloqueadas como esta y aprovechar al máximo tu curso.",
"learn.lockPaywall.content.pastExpiration": "The upgrade deadline for this course passed. To upgrade, enroll in the next available session. ", "learn.lockPaywall.content.pastExpiration": "La fecha límite de actualización para este curso expiró. Para actualizarlo, inscríbase en la siguiente sesión disponible.",
"learn.lockPaywall.courseDetails": "View Course Details", "learn.lockPaywall.courseDetails": "Ver detalles del curso",
"learn.lockPaywall.example.alt": "Certificado de ejemplo", "learn.lockPaywall.example.alt": "Certificado de ejemplo",
"learn.lockPaywall.list.intro": "Cuando te cambias a la opción verificada, tú:", "learn.lockPaywall.list.intro": "Cuando te cambias a la opción verificada, tú:",
"learn.header.h2.placeholder": "Enunciados de nivel 2 podrían ser creados por proveedores del curso en el futuro. ", "learn.header.h2.placeholder": "Enunciados de nivel 2 podrían ser creados por proveedores del curso en el futuro. ",
@@ -358,8 +361,8 @@
"learn.sequence.navigation.next.up.button": "Siguiente: {title}", "learn.sequence.navigation.next.up.button": "Siguiente: {title}",
"learn.sequence.navigation.previous.button": "Anterior", "learn.sequence.navigation.previous.button": "Anterior",
"learn.course.sequence.navigation.mobile.menu": "{current} de {total}", "learn.course.sequence.navigation.mobile.menu": "{current} de {total}",
"discussions.sidebar.title": "Discussions", "discussions.sidebar.title": "Debates",
"discussions.sidebar.open.button": "Show discussions tray", "discussions.sidebar.open.button": "Mostrar bandeja de discusiones",
"learn.redirect.interstitial.message": "Redirigiendo...", "learn.redirect.interstitial.message": "Redirigiendo...",
"learn.loading.error": "Error: {error}", "learn.loading.error": "Error: {error}",
"learning.celebration.emailBody": "¿A qué dedicas tu tiempo para aprender?", "learning.celebration.emailBody": "¿A qué dedicas tu tiempo para aprender?",
@@ -378,16 +381,16 @@
"learning.generic.upgradeNotification.expirationVerifiedCert.benefits": "beneficios del cambio", "learning.generic.upgradeNotification.expirationVerifiedCert.benefits": "beneficios del cambio",
"learning.generic.upgradeNotification.expirationAccessLoss": "Perderás todo el acceso a este curso, {includingAnyProgress}, el {date}.", "learning.generic.upgradeNotification.expirationAccessLoss": "Perderás todo el acceso a este curso, {includingAnyProgress}, el {date}.",
"learning.generic.upgradeNotification.expirationVerifiedCert": "Cambiarte a la opción verificada permite obtener un certificado verificado y obtener acceso a numerosas funciones. Obtén más información sobre los {benefitsOfUpgrading}.", "learning.generic.upgradeNotification.expirationVerifiedCert": "Cambiarte a la opción verificada permite obtener un certificado verificado y obtener acceso a numerosas funciones. Obtén más información sobre los {benefitsOfUpgrading}.",
"learning.generic.upgradeNotification.pastExpiration.content": "The upgrade deadline for this course passed. To upgrade, enroll in the next available session.", "learning.generic.upgradeNotification.pastExpiration.content": "La fecha límite de actualización para este curso expiró. Para actualizarlo, inscríbase en la siguiente sesión disponible.",
"learning.generic.upgradeNotification.expirationDays": "Quedan {dayCount, number} {dayCount, plural, \none {day}\nother {days}}", "learning.generic.upgradeNotification.expirationDays": "Quedan {dayCount, number} {dayCount, plural, \none {day}\nother {days}}",
"learning.generic.upgradeNotification.expirationHours": "Quedan {hourCount, number} {hourCount, plural,\none {hour}\nother {hours}}", "learning.generic.upgradeNotification.expirationHours": "Quedan {hourCount, number} {hourCount, plural,\none {hour}\nother {hours}}",
"learning.generic.upgradeNotification.expirationMinutes": "Queda menos de 1 hora", "learning.generic.upgradeNotification.expirationMinutes": "Queda menos de 1 hora",
"learning.generic.upgradeNotification.expiration": "El acceso al curso expirará el {date}", "learning.generic.upgradeNotification.expiration": "El acceso al curso expirará el {date}",
"learning.generic.upgradeNotification.pastExpiration.banner": "Upgrade deadline passed on {date}", "learning.generic.upgradeNotification.pastExpiration.banner": "La fecha límite de actualización expiró el {date}",
"learning.generic.upgradeNotification.firstTimeLearnerDiscount": "{percentage}% de descuento de bienvenida para estudiantes nuevos", "learning.generic.upgradeNotification.firstTimeLearnerDiscount": "{percentage}% de descuento de bienvenida para estudiantes nuevos",
"learning.generic.upgradeNotification.accessExpiration": "Cámbiate a la opción verificada", "learning.generic.upgradeNotification.accessExpiration": "Cámbiate a la opción verificada",
"learning.generic.upgradeNotification.accessExpirationUrgent": "Vencimiento del acceso al curso", "learning.generic.upgradeNotification.accessExpirationUrgent": "Vencimiento del acceso al curso",
"learning.generic.upgradeNotification.accessExpirationPast": "Course Access Expiration", "learning.generic.upgradeNotification.accessExpirationPast": "Vencimiento del acceso al curso",
"learning.generic.upgradeNotification.pursueAverifiedCertificate": "Obtenga un certificado verificado", "learning.generic.upgradeNotification.pursueAverifiedCertificate": "Obtenga un certificado verificado",
"learning.generic.upgradeNotification.code": "Usa el código {code} al finalizar la compra", "learning.generic.upgradeNotification.code": "Usa el código {code} al finalizar la compra",
"learning.generic.upsell.verifiedCertBullet.verifiedCert": "certificado verificado", "learning.generic.upsell.verifiedCertBullet.verifiedCert": "certificado verificado",
@@ -397,7 +400,7 @@
"learning.generic.upsell.fullAccessBullet.fullAccess": "Acceso completo", "learning.generic.upsell.fullAccessBullet.fullAccess": "Acceso completo",
"learning.generic.upsell.fullAccessBullet": "{fullAccessInBoldText} al contenido y los materiales del curso, incluso después de que finalice el curso", "learning.generic.upsell.fullAccessBullet": "{fullAccessInBoldText} al contenido y los materiales del curso, incluso después de que finalice el curso",
"learning.generic.upsell.supportMissionBullet.mission": "misión", "learning.generic.upsell.supportMissionBullet.mission": "misión",
"learning.generic.upsell.supportMissionBullet": "Apoya nuestra {missionInBoldText} en edX", "learning.generic.upsell.supportMissionBullet": "Apoye a nuestro {missionInBoldText} en {siteName}",
"masquerade-widget.userName.error.generic": "Se ha producido un error. Inténtalo de nuevo.", "masquerade-widget.userName.error.generic": "Se ha producido un error. Inténtalo de nuevo.",
"masquerade-widget.userName.input.placeholder": "Nombre de usuario o correo electrónico", "masquerade-widget.userName.input.placeholder": "Nombre de usuario o correo electrónico",
"masquerade-widget.userName.input.label": "Hazte pasar por este usuario", "masquerade-widget.userName.input.label": "Hazte pasar por este usuario",
@@ -409,8 +412,8 @@
"tours.button.okay": "Okey", "tours.button.okay": "Okey",
"tours.button.beginTour": "Comenzar recorrido", "tours.button.beginTour": "Comenzar recorrido",
"tours.button.launchTour": "gira de lanzamiento", "tours.button.launchTour": "gira de lanzamiento",
"tours.newUserModal.body": "Hagamos un recorrido rápido por edX para que pueda aprovechar al máximo su curso.", "tours.newUserModal.body": "Hagamos un recorrido rápido por {siteName} para que pueda aprovechar al máximo su curso.",
"tours.newUserModal.title.welcome": "Bienvenido a tu", "tours.newUserModal.title.welcome": "Te damos la bienvenida a tu",
"tours.button.skipForNow": "Saltar por ahora ", "tours.button.skipForNow": "Saltar por ahora ",
"tours.datesCheckpoint.body": "Las fechas importantes pueden ayudarlo a mantenerse encaminado.", "tours.datesCheckpoint.body": "Las fechas importantes pueden ayudarlo a mantenerse encaminado.",
"tours.datesCheckpoint.title": "Manténgase al tanto de las fechas clave", "tours.datesCheckpoint.title": "Manténgase al tanto de las fechas clave",
@@ -422,11 +425,11 @@
"tours.upgradeCheckpoint.title": "Desbloquea tu curso", "tours.upgradeCheckpoint.title": "Desbloquea tu curso",
"tours.weeklyGoalsCheckpoint.body": "Establecer una meta aumenta las probabilidades de completar el curso.", "tours.weeklyGoalsCheckpoint.body": "Establecer una meta aumenta las probabilidades de completar el curso.",
"tours.weeklyGoalsCheckpoint.title": "Establecer un objetivo del curso", "tours.weeklyGoalsCheckpoint.title": "Establecer un objetivo del curso",
"tours.newUserModal.title": "{welcome} {siteName} ¡Claro!", "tours.newUserModal.title": "¡{welcome} curso en {siteName}!",
"learning.effortEstimation.combinedEstimate": "{minutes} + {activities}", "learning.effortEstimation.combinedEstimate": "{minutes} + {activities}",
"learning.effortEstimation.activities": "{activityCount, plural, one {# de actividades} other {# actividades}}", "learning.effortEstimation.activities": "{activityCount, plural, one {# de actividades} many {# actividades} other {# actividades}}",
"learning.effortEstimation.minutesAbbreviated": "{minuteCount, plural, one {# minutos} other {# min}}", "learning.effortEstimation.minutesAbbreviated": "{minuteCount, plural, one {# minutos} many {# min} other {# min}}",
"learning.effortEstimation.minutesFull": "{minuteCount, plural, one {# minutos} other {# minutos}}", "learning.effortEstimation.minutesFull": "{minuteCount, plural, one {# minutos} many {# minutos} other {# minutos}}",
"learning.streakCelebration.congratulations": "¡Felicitaciones!", "learning.streakCelebration.congratulations": "¡Felicitaciones!",
"learning.streakCelebration.body": "Sigue así, ¡estás de buena racha!", "learning.streakCelebration.body": "Sigue así, ¡estás de buena racha!",
"learning.streakCelebration.button": "Sigue así", "learning.streakCelebration.button": "Sigue así",

View File

@@ -4,6 +4,8 @@
"learning.accessExpiration.body": "Vous perdez tout accès à ce cours, y compris votre progression, le {date}.", "learning.accessExpiration.body": "Vous perdez tout accès à ce cours, y compris votre progression, le {date}.",
"instructorToolbar.pageBanner.courseHasExpired": "Cet apprenant n'a plus accès à ce cours. Leur accès a expiré le {date}.", "instructorToolbar.pageBanner.courseHasExpired": "Cet apprenant n'a plus accès à ce cours. Leur accès a expiré le {date}.",
"learning.accessExpiration.upgradeNow": "Mettre à jour dès maintenant", "learning.accessExpiration.upgradeNow": "Mettre à jour dès maintenant",
"learning.activeEnterprise.alert": " {changeActiveEnterprise}.",
"learning.activeEnterprise.change.alert": "change enterprise now",
"learning.outline.alert.start.short": "Le cours commence dans {timeRemaining} à {courseStartTime}.", "learning.outline.alert.start.short": "Le cours commence dans {timeRemaining} à {courseStartTime}.",
"learning.outline.alert.end.long": "Ce cours se termine dans {timeRemaining}, le {courseEndDate}.", "learning.outline.alert.end.long": "Ce cours se termine dans {timeRemaining}, le {courseEndDate}.",
"learning.outline.alert.end.calendar": "N'oubliez pas d'ajouter un rappel dans le calendrier!", "learning.outline.alert.end.calendar": "N'oubliez pas d'ajouter un rappel dans le calendrier!",
@@ -34,8 +36,8 @@
"learning.goals.unsubscribe.header": "Vous vous êtes désabonné des rappels d'objectifs", "learning.goals.unsubscribe.header": "Vous vous êtes désabonné des rappels d'objectifs",
"learning.goals.unsubscribe.loading": "Désinscription...", "learning.goals.unsubscribe.loading": "Désinscription...",
"learning.goals.unsubscribe.errorDescription": "Nous n'avons pas pu vous désinscrire des courriels de rappel d'objectif. Veuillez réessayer plus tard ou {contactSupport} pour obtenir de l'aide.", "learning.goals.unsubscribe.errorDescription": "Nous n'avons pas pu vous désinscrire des courriels de rappel d'objectif. Veuillez réessayer plus tard ou {contactSupport} pour obtenir de l'aide.",
"learning.outline.alert.cert.earnedNotAvailable": "This course ends on {courseEndDateFormatted}. Final grades and any earned certificates are\n scheduled to be available after {certificateAvailableDate}.", "learning.outline.alert.cert.earnedNotAvailable": "Ce cours se termine le {courseEndDateFormatted}. Les notes finales et les attestations obtenues\n devraient être disponibles après le {certificateAvailableDate}.",
"cert.alert.earned.unavailable.header.v2": "Your grade and certificate status will be available soon.", "cert.alert.earned.unavailable.header.v2": "Le statut de votre note et de votre certificat seront bientôt disponibles.",
"cert.alert.earned.ready.header": "Félicitations ! Votre attestation est prête.", "cert.alert.earned.ready.header": "Félicitations ! Votre attestation est prête.",
"cert.alert.notPassing.header": "Vous n'êtes pas encore éligible pour une attestation", "cert.alert.notPassing.header": "Vous n'êtes pas encore éligible pour une attestation",
"cert.alert.notPassing.button": "Voir les notes", "cert.alert.notPassing.button": "Voir les notes",
@@ -92,6 +94,7 @@
"learning.proctoringPanel.status.error": "Erreur", "learning.proctoringPanel.status.error": "Erreur",
"learning.proctoringPanel.status.otherCourseApproved": "Approuvé dans un autre cours", "learning.proctoringPanel.status.otherCourseApproved": "Approuvé dans un autre cours",
"learning.proctoringPanel.status.expiringSoon": "Expire bientôt", "learning.proctoringPanel.status.expiringSoon": "Expire bientôt",
"learning.proctoringPanel.status.expired": "Expired",
"learning.proctoringPanel.status": "Statut actuel d'intégration :", "learning.proctoringPanel.status": "Statut actuel d'intégration :",
"learning.proctoringPanel.message.notStarted": "Vous n'avez pas commencé votre examen d'intégration.", "learning.proctoringPanel.message.notStarted": "Vous n'avez pas commencé votre examen d'intégration.",
"learning.proctoringPanel.message.started": "Vous avez commencé votre examen d'intégration.", "learning.proctoringPanel.message.started": "Vous avez commencé votre examen d'intégration.",
@@ -101,7 +104,8 @@
"learning.proctoringPanel.message.error": "Une erreur s'est produite lors de votre examen d'intégration. Veuillez réessayer l'intégration.", "learning.proctoringPanel.message.error": "Une erreur s'est produite lors de votre examen d'intégration. Veuillez réessayer l'intégration.",
"learning.proctoringPanel.message.otherCourseApproved": "Votre examen d'intégration a été approuvé dans un autre cours.", "learning.proctoringPanel.message.otherCourseApproved": "Votre examen d'intégration a été approuvé dans un autre cours.",
"learning.proctoringPanel.detail.otherCourseApproved": "Si votre appareil a changé, nous vous recommandons de passer l'examen d'intégration de ce cours afin de vous assurer que votre configuration répond toujours aux exigences de surveillance.", "learning.proctoringPanel.detail.otherCourseApproved": "Si votre appareil a changé, nous vous recommandons de passer l'examen d'intégration de ce cours afin de vous assurer que votre configuration répond toujours aux exigences de surveillance.",
"learning.proctoringPanel.message.expiringSoon": "Votre profil d'intégration a été approuvé dans un autre cours. Cependant, votre statut d'intégration expire bientôt. Veuillez compléter à nouveau l'intégration afin que vous soyez en mesure de continuer à passer des examens surveillés.", "learning.proctoringPanel.message.expiringSoon": "Your onboarding profile has been approved. However, your onboarding status is expiring soon. Please complete onboarding again to ensure that you will be able to continue taking proctored exams.",
"learning.proctoringPanel.message.expired": "Your onboarding status has expired. Please complete onboarding again to continue taking proctored exams.",
"learning.proctoringPanel.generalInfo": "Vous devez terminer le processus d'intégration avant de passer un examen surveillé.", "learning.proctoringPanel.generalInfo": "Vous devez terminer le processus d'intégration avant de passer un examen surveillé.",
"learning.proctoringPanel.generalInfoSubmitted": "Votre profil soumis est en cours de révision.", "learning.proctoringPanel.generalInfoSubmitted": "Votre profil soumis est en cours de révision.",
"learning.proctoringPanel.generalTime": "L'examen du profil d'intégration peut prendre plus de 2 jours ouvrables.", "learning.proctoringPanel.generalTime": "L'examen du profil d'intégration peut prendre plus de 2 jours ouvrables.",
@@ -113,7 +117,7 @@
"learning.outline.sequence-due": "{description} échéance {assignmentDue}", "learning.outline.sequence-due": "{description} échéance {assignmentDue}",
"progress.certificateStatus.unverifiedBody": "Afin de générer une attestation, vous devez effectuer une vérification d'identité. {idVerificationSupportLink}.", "progress.certificateStatus.unverifiedBody": "Afin de générer une attestation, vous devez effectuer une vérification d'identité. {idVerificationSupportLink}.",
"progress.certificateStatus.downloadableBody": "Présentez vos réalisations sur LinkedIn ou votre curriculum vitae aujourd'hui. Vous pouvez télécharger votre certificat maintenant et y accéder à tout moment depuis votre tableau de bord et votre profil.", "progress.certificateStatus.downloadableBody": "Présentez vos réalisations sur LinkedIn ou votre curriculum vitae aujourd'hui. Vous pouvez télécharger votre certificat maintenant et y accéder à tout moment depuis votre tableau de bord et votre profil.",
"courseCelebration.certificateBody.notAvailable.endDate": "This course ends on {endDate}. Final grades and any earned certificates are\n scheduled to be available after {certAvailabilityDate}.", "courseCelebration.certificateBody.notAvailable.endDate": "Les notes finales et toutes les attestations obtenues devraient être disponibles après le {endDate}.",
"progress.certificateStatus.notPassingHeader": "État de l'attestation", "progress.certificateStatus.notPassingHeader": "État de l'attestation",
"progress.certificateStatus.notPassingBody": "Pour être admissible à une attestation, vous devez avoir la note de passage.", "progress.certificateStatus.notPassingBody": "Pour être admissible à une attestation, vous devez avoir la note de passage.",
"progress.certificateStatus.inProgressHeader": "Plus de contenu sera bientôt disponible!", "progress.certificateStatus.inProgressHeader": "Plus de contenu sera bientôt disponible!",
@@ -125,13 +129,13 @@
"progress.certificateStatus.unverifiedButton": "Vérifiez votre identité", "progress.certificateStatus.unverifiedButton": "Vérifiez votre identité",
"progress.certificateStatus.courseCelebration.verificationPending": "La vérification de votre identité est en attente et votre attestation sera disponible une fois approuvé.", "progress.certificateStatus.courseCelebration.verificationPending": "La vérification de votre identité est en attente et votre attestation sera disponible une fois approuvé.",
"progress.certificateStatus.downloadableHeader": "Votre attestation est disponible!", "progress.certificateStatus.downloadableHeader": "Votre attestation est disponible!",
"progress.certificateStatus.downloadableButton": "Téléchargez mon attestation",
"progress.certificateStatus.viewableButton": "Voir mon attestation", "progress.certificateStatus.viewableButton": "Voir mon attestation",
"progress.certificateStatus.notAvailableHeader": "État de l'attestation", "progress.certificateStatus.notAvailableHeader": "État de l'attestation",
"progress.certificateBody.notAvailable.endDate": "Les notes finales et toutes les attestations obtenues devraient être disponibles après le {endDate}.",
"progress.certificateStatus.upgradeHeader": "Obtenir un certificat", "progress.certificateStatus.upgradeHeader": "Obtenir un certificat",
"progress.certificateStatus.upgradeBody": "Vous êtes dans une piste d'audit et n'êtes pas admissible à une attestation. Afin d'obtenir vers une attestation, mettez à niveau votre cours dès aujourd'hui.", "progress.certificateStatus.upgradeBody": "Vous êtes dans une piste d'audit et n'êtes pas admissible à une attestation. Afin d'obtenir vers une attestation, mettez à niveau votre cours dès aujourd'hui.",
"progress.certificateStatus.upgradeButton": "Mettre à jour dès maintenant", "progress.certificateStatus.upgradeButton": "Mettre à jour dès maintenant",
"progress.certificateStatus.unverifiedHomeHeader.v2": "Verify your identity to qualify for a certificate.", "progress.certificateStatus.unverifiedHomeHeader.v2": "Vérifiez votre identité pour bénéficier d'un certificat.",
"progress.certificateStatus.unverifiedHomeButton": "Vérifiez mon identité", "progress.certificateStatus.unverifiedHomeButton": "Vérifiez mon identité",
"progress.certificateStatus.unverifiedHomeBody": "Afin de générer une attestation pour ce cours, vous devez compléter le processus de vérification d'identité.", "progress.certificateStatus.unverifiedHomeBody": "Afin de générer une attestation pour ce cours, vous devez compléter le processus de vérification d'identité.",
"progress.completion.donut.label": "achevée", "progress.completion.donut.label": "achevée",
@@ -262,7 +266,7 @@
"notes.button.hide": "Masquer les notes", "notes.button.hide": "Masquer les notes",
"courseExit.catalogSearchSuggestion": "Vous souhaitez en apprendre plus? {searchOurCatalogLink} pour trouver plus de cours et de programmes à explorer.", "courseExit.catalogSearchSuggestion": "Vous souhaitez en apprendre plus? {searchOurCatalogLink} pour trouver plus de cours et de programmes à explorer.",
"courseCelebration.certificateBody.available": "\n Affichez vos accomplissements sur LinkedIn ou votre CV dès aujourd'hui.\n Vous pouvez télécharger votre attestation maintenant et y accéder à tout moment depuis vos\n {dashboardLink} et {profileLink}.", "courseCelebration.certificateBody.available": "\n Affichez vos accomplissements sur LinkedIn ou votre CV dès aujourd'hui.\n Vous pouvez télécharger votre attestation maintenant et y accéder à tout moment depuis vos\n {dashboardLink} et {profileLink}.",
"courseCelebration.certificateBody.notAvailable.endDate.v2": "This course ends on {endDate}. Final grades and any earned certificates are\n scheduled to be available after {certAvailableDate}.", "courseCelebration.certificateBody.notAvailable.endDate.v2": "Ce cours se termine le {endDate}. Les notes finales et les certificats obtenus\n devraient être disponibles après le {certAvailableDate}.",
"courseCelebration.certificateBody.unverified": "Afin de générer une attestation, vous devez effectuer une vérification d'identité.\n {idVerificationSupportLink} maintenant.", "courseCelebration.certificateBody.unverified": "Afin de générer une attestation, vous devez effectuer une vérification d'identité.\n {idVerificationSupportLink} maintenant.",
"courseCelebration.certificateBody.upgradable": "Il nest pas trop tard pour effectuer une mise à niveau. Pour {price}, vous débloquerez l'accès à tous les\n devoirs dans ce cours. À la fin, vous recevrez une attestation qui est une source\n d'informations précieuses pour améliorer vos perspectives d'emploi et faire progresser votre carrière, ou mettre en valeur votre\n attestation dans des demandes d'admission.", "courseCelebration.certificateBody.upgradable": "Il nest pas trop tard pour effectuer une mise à niveau. Pour {price}, vous débloquerez l'accès à tous les\n devoirs dans ce cours. À la fin, vous recevrez une attestation qui est une source\n d'informations précieuses pour améliorer vos perspectives d'emploi et faire progresser votre carrière, ou mettre en valeur votre\n attestation dans des demandes d'admission.",
"courseCelebration.upgradeDiscountCodePrompt": "Utilisez le code {code} lors du paiement pour {percent}% de réduction!", "courseCelebration.upgradeDiscountCodePrompt": "Utilisez le code {code} lors du paiement pour {percent}% de réduction!",
@@ -275,7 +279,7 @@
"courseCelebration.dashboardInfo": "Vous pouvez accéder à ce cours et à ses supports sur votre {dashboardLink}.", "courseCelebration.dashboardInfo": "Vous pouvez accéder à ce cours et à ses supports sur votre {dashboardLink}.",
"courseExit.programs.applyForCredit": "Demander un crédit", "courseExit.programs.applyForCredit": "Demander un crédit",
"courseCelebration.certificateHeader.downloadable": "Votre attestation est disponible!", "courseCelebration.certificateHeader.downloadable": "Votre attestation est disponible!",
"courseCelebration.certificateHeader.notAvailable": "Your grade and certificate status will be available soon.", "courseCelebration.certificateHeader.notAvailable": "Le statut de votre note et de votre certificat seront bientôt disponibles.",
"courseCelebration.certificateBody.notAvailable.accessCertificate": "Si vous avez obtenu une note de passage, votre attestation sera automatiquement générée.", "courseCelebration.certificateBody.notAvailable.accessCertificate": "Si vous avez obtenu une note de passage, votre attestation sera automatiquement générée.",
"courseCelebration.certificateHeader.unverified": "Vous devez avoir complété votre vérification pour recevoir votre attestation.", "courseCelebration.certificateHeader.unverified": "Vous devez avoir complété votre vérification pour recevoir votre attestation.",
"courseCelebration.certificateHeader.requestable": "Félicitations, vous avez terminé le processus pour passer un certificat !", "courseCelebration.certificateHeader.requestable": "Félicitations, vous avez terminé le processus pour passer un certificat !",
@@ -287,7 +291,6 @@
"courseExit.courseInProgressDescription": "Il semble qu'il y ait plus de contenu dans ce cours qui sera publié dans le futur. Attendez les mises à jour par courriel ou revenez sur votre cours pour savoir quand ce contenu sera disponible.", "courseExit.courseInProgressDescription": "Il semble qu'il y ait plus de contenu dans ce cours qui sera publié dans le futur. Attendez les mises à jour par courriel ou revenez sur votre cours pour savoir quand ce contenu sera disponible.",
"courseExit.courseInProgressHeader": "Plus de contenu sera bientôt disponible!", "courseExit.courseInProgressHeader": "Plus de contenu sera bientôt disponible!",
"courseExit.dashboardLink": "Tableau de bord", "courseExit.dashboardLink": "Tableau de bord",
"courseCelebration.downloadButton": "Téléchargez mon attestation",
"courseExit.endOfCourseDescription": "Malheureusement, vous n'êtes actuellement pas éligible pour une attestatation. Vous devez recevoir une note de passage pour être admissible à une attestation.", "courseExit.endOfCourseDescription": "Malheureusement, vous n'êtes actuellement pas éligible pour une attestatation. Vous devez recevoir une note de passage pour être admissible à une attestation.",
"courseExit.endOfCourseHeader": "Vous avez atteint la fin du cours!", "courseExit.endOfCourseHeader": "Vous avez atteint la fin du cours!",
"courseExit.endOfCourseTitle": "Fin du cours", "courseExit.endOfCourseTitle": "Fin du cours",
@@ -332,7 +335,7 @@
"notification.tray.title": "Notifications", "notification.tray.title": "Notifications",
"notification.tray.no.message": "Vous n'avez aucune nouvelle notification pour le moment.", "notification.tray.no.message": "Vous n'avez aucune nouvelle notification pour le moment.",
"learn.contentLock.content.locked": "Contenu vérouillé", "learn.contentLock.content.locked": "Contenu vérouillé",
"learn.contentLock.complete.prerequisite": "Vous devez compléter le prérequis: '{prereqSectionName}' pour accéder à ce contenu.", "learn.contentLock.complete.prerequisite": "You must complete the prerequisite: ''{prereqSectionName}'' to access this content.",
"learn.contentLock.goToSection": "Aller à la section des prérequis", "learn.contentLock.goToSection": "Aller à la section des prérequis",
"learn.hiddenAfterDue.gradeAvailable": "Si vous avez complété ce travail, votre note est disponible sur {progressPage}.", "learn.hiddenAfterDue.gradeAvailable": "Si vous avez complété ce travail, votre note est disponible sur {progressPage}.",
"learn.hiddenAfterDue.header": "La date d'échéance de ce devoir est passée.", "learn.hiddenAfterDue.header": "La date d'échéance de ce devoir est passée.",
@@ -397,7 +400,7 @@
"learning.generic.upsell.fullAccessBullet.fullAccess": "Accès complet", "learning.generic.upsell.fullAccessBullet.fullAccess": "Accès complet",
"learning.generic.upsell.fullAccessBullet": "{fullAccessInBoldText} au contenu et aux supports du cours, même après la fin du cours", "learning.generic.upsell.fullAccessBullet": "{fullAccessInBoldText} au contenu et aux supports du cours, même après la fin du cours",
"learning.generic.upsell.supportMissionBullet.mission": "mission", "learning.generic.upsell.supportMissionBullet.mission": "mission",
"learning.generic.upsell.supportMissionBullet": "Supportez notre {missionInBoldText} chez edX", "learning.generic.upsell.supportMissionBullet": "Soutenez notre {missionInBoldText} à {siteName}",
"masquerade-widget.userName.error.generic": "Une erreur est survenue; veuillez réessayer.", "masquerade-widget.userName.error.generic": "Une erreur est survenue; veuillez réessayer.",
"masquerade-widget.userName.input.placeholder": "Nom d'utilisateur ou courriel", "masquerade-widget.userName.input.placeholder": "Nom d'utilisateur ou courriel",
"masquerade-widget.userName.input.label": "Se faire passer pour cet utilisateur", "masquerade-widget.userName.input.label": "Se faire passer pour cet utilisateur",
@@ -409,7 +412,7 @@
"tours.button.okay": "Okay", "tours.button.okay": "Okay",
"tours.button.beginTour": "Commencer la visite guidée", "tours.button.beginTour": "Commencer la visite guidée",
"tours.button.launchTour": "Lancer la visite guidée", "tours.button.launchTour": "Lancer la visite guidée",
"tours.newUserModal.body": "Faisons une visite guidée de edX afin de profiter au maximum de votre cours.", "tours.newUserModal.body": "Faisons un tour rapide de {siteName} afin que vous puissiez tirer le meilleur parti de votre cours.",
"tours.newUserModal.title.welcome": "Bienvenue à votre", "tours.newUserModal.title.welcome": "Bienvenue à votre",
"tours.button.skipForNow": "Ignorer pour l'instant", "tours.button.skipForNow": "Ignorer pour l'instant",
"tours.datesCheckpoint.body": "Dates importantes afin de vous maintenir sur la bonne voie.", "tours.datesCheckpoint.body": "Dates importantes afin de vous maintenir sur la bonne voie.",
@@ -424,9 +427,9 @@
"tours.weeklyGoalsCheckpoint.title": "Paramétrer un objectif de cours", "tours.weeklyGoalsCheckpoint.title": "Paramétrer un objectif de cours",
"tours.newUserModal.title": "{welcome} {siteName} au cours !", "tours.newUserModal.title": "{welcome} {siteName} au cours !",
"learning.effortEstimation.combinedEstimate": "{minutes} + {activities}", "learning.effortEstimation.combinedEstimate": "{minutes} + {activities}",
"learning.effortEstimation.activities": "{activityCount, plural, one {# activité} other {# activités}}", "learning.effortEstimation.activities": "{activityCount, plural, one {# activité} many {# activités} other {# activités}}",
"learning.effortEstimation.minutesAbbreviated": "{minuteCount, plural, one {# min} other {# min}}", "learning.effortEstimation.minutesAbbreviated": "{minuteCount, plural, one {# min} many {# min} other {# min}}",
"learning.effortEstimation.minutesFull": "{minuteCount, plural, one {# minute} other {# minutes}}", "learning.effortEstimation.minutesFull": "{minuteCount, plural, one {# minute} many {# minutes} other {# minutes}}",
"learning.streakCelebration.congratulations": "Félicitations !", "learning.streakCelebration.congratulations": "Félicitations !",
"learning.streakCelebration.body": "Continuez comme ça, vous êtes sur une lancée!", "learning.streakCelebration.body": "Continuez comme ça, vous êtes sur une lancée!",
"learning.streakCelebration.button": "Continuez ainsi", "learning.streakCelebration.button": "Continuez ainsi",

View File

@@ -4,6 +4,8 @@
"learning.accessExpiration.body": "You lose all access to this course, including your progress, on {date}.", "learning.accessExpiration.body": "You lose all access to this course, including your progress, on {date}.",
"instructorToolbar.pageBanner.courseHasExpired": "This learner no longer has access to this course. Their access expired on {date}.", "instructorToolbar.pageBanner.courseHasExpired": "This learner no longer has access to this course. Their access expired on {date}.",
"learning.accessExpiration.upgradeNow": "Upgrade now", "learning.accessExpiration.upgradeNow": "Upgrade now",
"learning.activeEnterprise.alert": " {changeActiveEnterprise}.",
"learning.activeEnterprise.change.alert": "change enterprise now",
"learning.outline.alert.start.short": "Course starts {timeRemaining} at {courseStartTime}.", "learning.outline.alert.start.short": "Course starts {timeRemaining} at {courseStartTime}.",
"learning.outline.alert.end.long": "This course is ending {timeRemaining} on {courseEndDate}.", "learning.outline.alert.end.long": "This course is ending {timeRemaining} on {courseEndDate}.",
"learning.outline.alert.end.calendar": "Dont forget to add a calendar reminder!", "learning.outline.alert.end.calendar": "Dont forget to add a calendar reminder!",
@@ -92,6 +94,7 @@
"learning.proctoringPanel.status.error": "Error", "learning.proctoringPanel.status.error": "Error",
"learning.proctoringPanel.status.otherCourseApproved": "Approved in Another Course", "learning.proctoringPanel.status.otherCourseApproved": "Approved in Another Course",
"learning.proctoringPanel.status.expiringSoon": "Expiring Soon", "learning.proctoringPanel.status.expiringSoon": "Expiring Soon",
"learning.proctoringPanel.status.expired": "Expired",
"learning.proctoringPanel.status": "Current Onboarding Status:", "learning.proctoringPanel.status": "Current Onboarding Status:",
"learning.proctoringPanel.message.notStarted": "You have not started your onboarding exam.", "learning.proctoringPanel.message.notStarted": "You have not started your onboarding exam.",
"learning.proctoringPanel.message.started": "You have started your onboarding exam.", "learning.proctoringPanel.message.started": "You have started your onboarding exam.",
@@ -101,7 +104,8 @@
"learning.proctoringPanel.message.error": "An error has occurred during your onboarding exam. Please retry onboarding.", "learning.proctoringPanel.message.error": "An error has occurred during your onboarding exam. Please retry onboarding.",
"learning.proctoringPanel.message.otherCourseApproved": "Your onboarding exam has been approved in another course.", "learning.proctoringPanel.message.otherCourseApproved": "Your onboarding exam has been approved in another course.",
"learning.proctoringPanel.detail.otherCourseApproved": "If your device has changed, we recommend that you complete this course's onboarding exam in order to ensure that your setup still meets the requirements for proctoring.", "learning.proctoringPanel.detail.otherCourseApproved": "If your device has changed, we recommend that you complete this course's onboarding exam in order to ensure that your setup still meets the requirements for proctoring.",
"learning.proctoringPanel.message.expiringSoon": "Your onboarding profile has been approved in another course. However, your onboarding status is expiring soon. Please complete onboarding again to ensure that you will be able to continue taking proctored exams.", "learning.proctoringPanel.message.expiringSoon": "Your onboarding profile has been approved. However, your onboarding status is expiring soon. Please complete onboarding again to ensure that you will be able to continue taking proctored exams.",
"learning.proctoringPanel.message.expired": "Your onboarding status has expired. Please complete onboarding again to continue taking proctored exams.",
"learning.proctoringPanel.generalInfo": "You must complete the onboarding process prior to taking any proctored exam. ", "learning.proctoringPanel.generalInfo": "You must complete the onboarding process prior to taking any proctored exam. ",
"learning.proctoringPanel.generalInfoSubmitted": "Your submitted profile is in review.", "learning.proctoringPanel.generalInfoSubmitted": "Your submitted profile is in review.",
"learning.proctoringPanel.generalTime": "Onboarding profile review can take 2+ business days.", "learning.proctoringPanel.generalTime": "Onboarding profile review can take 2+ business days.",
@@ -113,7 +117,7 @@
"learning.outline.sequence-due": "{description} due {assignmentDue}", "learning.outline.sequence-due": "{description} due {assignmentDue}",
"progress.certificateStatus.unverifiedBody": "In order to generate a certificate, you must complete ID verification. {idVerificationSupportLink}.", "progress.certificateStatus.unverifiedBody": "In order to generate a certificate, you must complete ID verification. {idVerificationSupportLink}.",
"progress.certificateStatus.downloadableBody": "Showcase your accomplishment on LinkedIn or your resumé today. You can download your certificate now and access it any time from your Dashboard and Profile.", "progress.certificateStatus.downloadableBody": "Showcase your accomplishment on LinkedIn or your resumé today. You can download your certificate now and access it any time from your Dashboard and Profile.",
"courseCelebration.certificateBody.notAvailable.endDate": "This course ends on {endDate}. Final grades and any earned certificates are\n scheduled to be available after {certAvailabilityDate}.", "courseCelebration.certificateBody.notAvailable.endDate": "Final grades and any earned certificates are scheduled to be available after {endDate}.",
"progress.certificateStatus.notPassingHeader": "Certificate status", "progress.certificateStatus.notPassingHeader": "Certificate status",
"progress.certificateStatus.notPassingBody": "In order to qualify for a certificate, you must have a passing grade.", "progress.certificateStatus.notPassingBody": "In order to qualify for a certificate, you must have a passing grade.",
"progress.certificateStatus.inProgressHeader": "More content is coming soon!", "progress.certificateStatus.inProgressHeader": "More content is coming soon!",
@@ -125,9 +129,9 @@
"progress.certificateStatus.unverifiedButton": "Verify ID", "progress.certificateStatus.unverifiedButton": "Verify ID",
"progress.certificateStatus.courseCelebration.verificationPending": "Your ID verification is pending and your certificate will be available once approved.", "progress.certificateStatus.courseCelebration.verificationPending": "Your ID verification is pending and your certificate will be available once approved.",
"progress.certificateStatus.downloadableHeader": "Your certificate is available!", "progress.certificateStatus.downloadableHeader": "Your certificate is available!",
"progress.certificateStatus.downloadableButton": "Download my certificate",
"progress.certificateStatus.viewableButton": "View my certificate", "progress.certificateStatus.viewableButton": "View my certificate",
"progress.certificateStatus.notAvailableHeader": "Certificate status", "progress.certificateStatus.notAvailableHeader": "Certificate status",
"progress.certificateBody.notAvailable.endDate": "Final grades and any earned certificates are scheduled to be available after {endDate}.",
"progress.certificateStatus.upgradeHeader": "Earn a certificate", "progress.certificateStatus.upgradeHeader": "Earn a certificate",
"progress.certificateStatus.upgradeBody": "You are in an audit track and do not qualify for a certificate. In order to work towards a certificate, upgrade your course today.", "progress.certificateStatus.upgradeBody": "You are in an audit track and do not qualify for a certificate. In order to work towards a certificate, upgrade your course today.",
"progress.certificateStatus.upgradeButton": "Upgrade now", "progress.certificateStatus.upgradeButton": "Upgrade now",
@@ -287,7 +291,6 @@
"courseExit.courseInProgressDescription": "It looks like there is more content in this course that will be released in the future. Look out for email updates or check back on your course for when this content will be available.", "courseExit.courseInProgressDescription": "It looks like there is more content in this course that will be released in the future. Look out for email updates or check back on your course for when this content will be available.",
"courseExit.courseInProgressHeader": "More content is coming soon!", "courseExit.courseInProgressHeader": "More content is coming soon!",
"courseExit.dashboardLink": "Dashboard", "courseExit.dashboardLink": "Dashboard",
"courseCelebration.downloadButton": "Download my certificate",
"courseExit.endOfCourseDescription": "Unfortunately, you are not currently eligible for a certificate. You need to receive a passing grade to be eligible for a certificate.", "courseExit.endOfCourseDescription": "Unfortunately, you are not currently eligible for a certificate. You need to receive a passing grade to be eligible for a certificate.",
"courseExit.endOfCourseHeader": "Youve reached the end of the course!", "courseExit.endOfCourseHeader": "Youve reached the end of the course!",
"courseExit.endOfCourseTitle": "End of Course", "courseExit.endOfCourseTitle": "End of Course",
@@ -332,7 +335,7 @@
"notification.tray.title": "Notifications", "notification.tray.title": "Notifications",
"notification.tray.no.message": "You have no new notifications at this time.", "notification.tray.no.message": "You have no new notifications at this time.",
"learn.contentLock.content.locked": "Content Locked", "learn.contentLock.content.locked": "Content Locked",
"learn.contentLock.complete.prerequisite": "You must complete the prerequisite: '{prereqSectionName}' to access this content.", "learn.contentLock.complete.prerequisite": "You must complete the prerequisite: ''{prereqSectionName}'' to access this content.",
"learn.contentLock.goToSection": "Go To Prerequisite Section", "learn.contentLock.goToSection": "Go To Prerequisite Section",
"learn.hiddenAfterDue.gradeAvailable": "If you have completed this assignment, your grade is available on the {progressPage}.", "learn.hiddenAfterDue.gradeAvailable": "If you have completed this assignment, your grade is available on the {progressPage}.",
"learn.hiddenAfterDue.header": "The due date for this assignment has passed.", "learn.hiddenAfterDue.header": "The due date for this assignment has passed.",
@@ -397,7 +400,7 @@
"learning.generic.upsell.fullAccessBullet.fullAccess": "Full access", "learning.generic.upsell.fullAccessBullet.fullAccess": "Full access",
"learning.generic.upsell.fullAccessBullet": "{fullAccessInBoldText} to course content and materials, even after the course ends", "learning.generic.upsell.fullAccessBullet": "{fullAccessInBoldText} to course content and materials, even after the course ends",
"learning.generic.upsell.supportMissionBullet.mission": "mission", "learning.generic.upsell.supportMissionBullet.mission": "mission",
"learning.generic.upsell.supportMissionBullet": "Support our {missionInBoldText} at edX", "learning.generic.upsell.supportMissionBullet": "Support our {missionInBoldText} at {siteName}",
"masquerade-widget.userName.error.generic": "An error has occurred; please try again.", "masquerade-widget.userName.error.generic": "An error has occurred; please try again.",
"masquerade-widget.userName.input.placeholder": "Username or email", "masquerade-widget.userName.input.placeholder": "Username or email",
"masquerade-widget.userName.input.label": "Masquerade as this user", "masquerade-widget.userName.input.label": "Masquerade as this user",
@@ -409,7 +412,7 @@
"tours.button.okay": "Okay", "tours.button.okay": "Okay",
"tours.button.beginTour": "Begin tour", "tours.button.beginTour": "Begin tour",
"tours.button.launchTour": "Launch tour", "tours.button.launchTour": "Launch tour",
"tours.newUserModal.body": "Lets take a quick tour of edX so you can get the most out of your course.", "tours.newUserModal.body": "Lets take a quick tour of {siteName} so you can get the most out of your course.",
"tours.newUserModal.title.welcome": "Welcome to your", "tours.newUserModal.title.welcome": "Welcome to your",
"tours.button.skipForNow": "Skip for now", "tours.button.skipForNow": "Skip for now",
"tours.datesCheckpoint.body": "Important dates can help you stay on track.", "tours.datesCheckpoint.body": "Important dates can help you stay on track.",

View File

@@ -4,6 +4,7 @@ import 'regenerator-runtime/runtime';
import { import {
APP_INIT_ERROR, APP_READY, subscribe, initialize, APP_INIT_ERROR, APP_READY, subscribe, initialize,
mergeConfig, mergeConfig,
getConfig,
} from '@edx/frontend-platform'; } from '@edx/frontend-platform';
import { AppProvider, ErrorPage, PageRoute } from '@edx/frontend-platform/react'; import { AppProvider, ErrorPage, PageRoute } from '@edx/frontend-platform/react';
import React from 'react'; import React from 'react';
@@ -12,6 +13,9 @@ import { Switch } from 'react-router-dom';
import { messages as footerMessages } from '@edx/frontend-component-footer'; import { messages as footerMessages } from '@edx/frontend-component-footer';
import { messages as headerMessages } from '@edx/frontend-component-header'; import { messages as headerMessages } from '@edx/frontend-component-header';
import { Helmet } from 'react-helmet';
import { fetchDiscussionTab, fetchLiveTab } from './course-home/data/thunks';
import DiscussionTab from './course-home/discussion-tab/DiscussionTab';
import appMessages from './i18n'; import appMessages from './i18n';
import { UserMessagesProvider } from './generic/user-messages'; import { UserMessagesProvider } from './generic/user-messages';
@@ -31,26 +35,42 @@ import { fetchCourse } from './courseware/data';
import initializeStore from './store'; import initializeStore from './store';
import NoticesProvider from './generic/notices'; import NoticesProvider from './generic/notices';
import PathFixesProvider from './generic/path-fixes'; import PathFixesProvider from './generic/path-fixes';
import LiveTab from './course-home/live-tab/LiveTab';
import CourseAccessErrorPage from './generic/CourseAccessErrorPage';
subscribe(APP_READY, () => { subscribe(APP_READY, () => {
ReactDOM.render( ReactDOM.render(
<AppProvider store={initializeStore()}> <AppProvider store={initializeStore()}>
<Helmet>
<link rel="shortcut icon" href={getConfig().FAVICON_URL} type="image/x-icon" />
</Helmet>
<PathFixesProvider> <PathFixesProvider>
<NoticesProvider> <NoticesProvider>
<UserMessagesProvider> <UserMessagesProvider>
<Switch> <Switch>
<PageRoute exact path="/goal-unsubscribe/:token" component={GoalUnsubscribe} /> <PageRoute exact path="/goal-unsubscribe/:token" component={GoalUnsubscribe} />
<PageRoute path="/redirect" component={CoursewareRedirectLandingPage} /> <PageRoute path="/redirect" component={CoursewareRedirectLandingPage} />
<PageRoute path="/course/:courseId/access-denied" component={CourseAccessErrorPage} />
<PageRoute path="/course/:courseId/home"> <PageRoute path="/course/:courseId/home">
<TabContainer tab="outline" fetch={fetchOutlineTab} slice="courseHome"> <TabContainer tab="outline" fetch={fetchOutlineTab} slice="courseHome">
<OutlineTab /> <OutlineTab />
</TabContainer> </TabContainer>
</PageRoute> </PageRoute>
<PageRoute path="/course/:courseId/live">
<TabContainer tab="lti_live" fetch={fetchLiveTab} slice="courseHome">
<LiveTab />
</TabContainer>
</PageRoute>
<PageRoute path="/course/:courseId/dates"> <PageRoute path="/course/:courseId/dates">
<TabContainer tab="dates" fetch={fetchDatesTab} slice="courseHome"> <TabContainer tab="dates" fetch={fetchDatesTab} slice="courseHome">
<DatesTab /> <DatesTab />
</TabContainer> </TabContainer>
</PageRoute> </PageRoute>
<PageRoute path="/course/:courseId/discussion/:path*">
<TabContainer tab="discussion" fetch={fetchDiscussionTab} slice="courseHome">
<DiscussionTab />
</TabContainer>
</PageRoute>
<PageRoute <PageRoute
path={[ path={[
'/course/:courseId/progress/:targetUserId/', '/course/:courseId/progress/:targetUserId/',

View File

@@ -376,6 +376,7 @@
@import "course-home/progress-tab/course-completion/CompletionDonutChart.scss"; @import "course-home/progress-tab/course-completion/CompletionDonutChart.scss";
@import "course-home/progress-tab/grades/course-grade/GradeBar.scss"; @import "course-home/progress-tab/grades/course-grade/GradeBar.scss";
@import "courseware/course/course-exit/CourseRecommendations"; @import "courseware/course/course-exit/CourseRecommendations";
@import "product-tours/newUserCourseHomeTour/NewUserCourseHomeTourModal.scss";
/** [MM-P2P] Experiment */ /** [MM-P2P] Experiment */
@import "experiments/mm-p2p/index.scss"; @import "experiments/mm-p2p/index.scss";

View File

@@ -36,14 +36,6 @@ function getStudioUrl(courseId, unitId) {
return urlFull; return urlFull;
} }
function getLegacyWebUrl(canViewLegacyCourseware, courseId, unitId) {
if (!canViewLegacyCourseware || !unitId) {
return undefined;
}
return `${getConfig().LMS_BASE_URL}/courses/${courseId}/jump_to/${unitId}?experience=legacy`;
}
export default function InstructorToolbar(props) { export default function InstructorToolbar(props) {
// This didMount logic became necessary once we had a page that does a redirect on a quick exit. // This didMount logic became necessary once we had a page that does a redirect on a quick exit.
// As a result, it unmounts the InstructorToolbar (which will be remounted by the new component), // As a result, it unmounts the InstructorToolbar (which will be remounted by the new component),
@@ -62,12 +54,10 @@ export default function InstructorToolbar(props) {
const { const {
courseId, courseId,
unitId, unitId,
canViewLegacyCourseware,
tab, tab,
} = props; } = props;
const urlInsights = getInsightsUrl(courseId); const urlInsights = getInsightsUrl(courseId);
const urlLegacy = getLegacyWebUrl(canViewLegacyCourseware, courseId, unitId);
const urlStudio = getStudioUrl(courseId, unitId); const urlStudio = getStudioUrl(courseId, unitId);
const [masqueradeErrorMessage, showMasqueradeError] = useState(null); const [masqueradeErrorMessage, showMasqueradeError] = useState(null);
@@ -81,17 +71,12 @@ export default function InstructorToolbar(props) {
<div className="align-items-center flex-grow-1 d-md-flex mx-1 my-1"> <div className="align-items-center flex-grow-1 d-md-flex mx-1 my-1">
<MasqueradeWidget courseId={courseId} onError={showMasqueradeError} /> <MasqueradeWidget courseId={courseId} onError={showMasqueradeError} />
</div> </div>
{(urlLegacy || urlStudio || urlInsights) && ( {(urlStudio || urlInsights) && (
<> <>
<hr className="border-light" /> <hr className="border-light" />
<span className="mr-2 mt-1 col-form-label">View course in:</span> <span className="mr-2 mt-1 col-form-label">View course in:</span>
</> </>
)} )}
{urlLegacy && (
<span className="mx-1 my-1">
<a className="btn btn-inverse-outline-primary" href={urlLegacy}>Legacy experience</a>
</span>
)}
{urlStudio && ( {urlStudio && (
<span className="mx-1 my-1"> <span className="mx-1 my-1">
<a className="btn btn-inverse-outline-primary" href={urlStudio}>Studio</a> <a className="btn btn-inverse-outline-primary" href={urlStudio}>Studio</a>
@@ -128,13 +113,11 @@ export default function InstructorToolbar(props) {
InstructorToolbar.propTypes = { InstructorToolbar.propTypes = {
courseId: PropTypes.string, courseId: PropTypes.string,
unitId: PropTypes.string, unitId: PropTypes.string,
canViewLegacyCourseware: PropTypes.bool,
tab: PropTypes.string, tab: PropTypes.string,
}; };
InstructorToolbar.defaultProps = { InstructorToolbar.defaultProps = {
courseId: undefined, courseId: undefined,
unitId: undefined, unitId: undefined,
canViewLegacyCourseware: undefined,
tab: '', tab: '',
}; };

View File

@@ -34,7 +34,6 @@ describe('Instructor Toolbar', () => {
mockData = { mockData = {
courseId: courseware.courseId, courseId: courseware.courseId,
unitId: Object.values(models.units)[0].id, unitId: Object.values(models.units)[0].id,
canViewLegacyCourseware: true,
}; };
axiosMock.reset(); axiosMock.reset();
axiosMock.onGet(masqueradeUrl).reply(200, { success: true }); axiosMock.onGet(masqueradeUrl).reply(200, { success: true });
@@ -63,32 +62,6 @@ describe('Instructor Toolbar', () => {
getConfig.mockImplementation(() => config); getConfig.mockImplementation(() => config);
render(<InstructorToolbar {...mockData} />); render(<InstructorToolbar {...mockData} />);
const linksContainer = screen.getByText('View course in:').parentElement;
['Legacy experience', 'Studio', 'Insights'].forEach(service => {
expect(getByText(linksContainer, service).getAttribute('href')).toMatch(/http.*/);
});
});
it('displays links to view course in available services - false legacy courseware flag', () => {
const config = { ...originalConfig };
config.INSIGHTS_BASE_URL = 'http://localhost:18100';
getConfig.mockImplementation(() => config);
mockData.canViewLegacyCourseware = false;
render(<InstructorToolbar {...mockData} />);
const linksContainer = screen.getByText('View course in:').parentElement;
['Studio', 'Insights'].forEach(service => {
expect(getByText(linksContainer, service).getAttribute('href')).toMatch(/http.*/);
});
});
it('displays links to view course in available services - empty unit', () => {
const config = { ...originalConfig };
config.INSIGHTS_BASE_URL = 'http://localhost:18100';
getConfig.mockImplementation(() => config);
mockData.unitId = undefined;
render(<InstructorToolbar {...mockData} />);
const linksContainer = screen.getByText('View course in:').parentElement; const linksContainer = screen.getByText('View course in:').parentElement;
['Studio', 'Insights'].forEach(service => { ['Studio', 'Insights'].forEach(service => {
expect(getByText(linksContainer, service).getAttribute('href')).toMatch(/http.*/); expect(getByText(linksContainer, service).getAttribute('href')).toMatch(/http.*/);

View File

@@ -27,7 +27,6 @@
"sku": "8CF08E5", "sku": "8CF08E5",
"upgrade_url": "http://localhost:18130/basket/add/?sku=8CF08E5" "upgrade_url": "http://localhost:18130/basket/add/?sku=8CF08E5"
}, },
"can_load_courseware": true,
"celebrations": { "celebrations": {
"first_section": false, "first_section": false,
"streak_length_to_celebrate": null, "streak_length_to_celebrate": null,
@@ -66,9 +65,6 @@
"$.body.verified_mode": { "$.body.verified_mode": {
"match": "type" "match": "type"
}, },
"$.body.can_load_courseware": {
"match": "type"
},
"$.body.celebrations": { "$.body.celebrations": {
"match": "type" "match": "type"
}, },
@@ -280,7 +276,6 @@
}, },
"show_calculator": false, "show_calculator": false,
"original_user_is_staff": true, "original_user_is_staff": true,
"can_view_legacy_courseware": true,
"is_staff": true, "is_staff": true,
"course_access": { "course_access": {
"has_access": true, "has_access": true,
@@ -305,7 +300,6 @@
"certificate_data": { "certificate_data": {
"cert_status": "audit_passing", "cert_status": "audit_passing",
"cert_web_view_url": null, "cert_web_view_url": null,
"download_url": null,
"certificate_available_date": null "certificate_available_date": null
}, },
"verify_identity_url": null, "verify_identity_url": null,
@@ -414,9 +408,6 @@
"$.body.original_user_is_staff": { "$.body.original_user_is_staff": {
"match": "type" "match": "type"
}, },
"$.body.can_view_legacy_courseware": {
"match": "type"
},
"$.body.is_staff": { "$.body.is_staff": {
"match": "type" "match": "type"
}, },

View File

@@ -13,7 +13,7 @@ const messages = defineMessages({
}, },
newUserModalBody: { newUserModalBody: {
id: 'tours.newUserModal.body', id: 'tours.newUserModal.body',
defaultMessage: 'Lets take a quick tour of edX so you can get the most out of your course.', defaultMessage: 'Lets take a quick tour of {siteName} so you can get the most out of your course.',
}, },
newUserModalTitleWelcome: { newUserModalTitleWelcome: {
id: 'tours.newUserModal.title.welcome', id: 'tours.newUserModal.title.welcome',

View File

@@ -19,6 +19,7 @@ function NewUserCourseHomeTourModal({
<MarketingModal <MarketingModal
isOpen={isOpen} isOpen={isOpen}
title="New user course home prompt" title="New user course home prompt"
className="new-user-tour-dialog"
heroIsDark heroIsDark
hasCloseButton={false} hasCloseButton={false}
heroNode={( heroNode={(
@@ -58,7 +59,7 @@ function NewUserCourseHomeTourModal({
)} )}
onClose={onDismiss} onClose={onDismiss}
> >
<p className="text-dark-900">{intl.formatMessage(messages.newUserModalBody)}</p> <p className="text-dark-900">{intl.formatMessage(messages.newUserModalBody, { siteName: getConfig().SITE_NAME })}</p>
</MarketingModal> </MarketingModal>
); );
} }

View File

@@ -0,0 +1,3 @@
[dir="rtl"] .new-user-tour-dialog .pgn__modal-hero .pgn__modal-hero-bg {
transform: scaleX(-1);
}

View File

@@ -4,7 +4,7 @@ import { getLocale } from '@edx/frontend-platform/i18n';
// This function inspects an access denied error and provides a redirect url (looks like a /redirect/... path), // This function inspects an access denied error and provides a redirect url (looks like a /redirect/... path),
// which then renders a nice little message while the browser loads the next page. // which then renders a nice little message while the browser loads the next page.
// This is basically a frontend version of check_course_access_with_redirect in the backend. // This is basically a frontend version of check_course_access_with_redirect in the backend.
export function getAccessDeniedRedirectUrl(courseId, activeTabSlug, canLoadCourseware, courseAccess, start, unitId) { export function getAccessDeniedRedirectUrl(courseId, activeTabSlug, courseAccess, start) {
let url = null; let url = null;
switch (courseAccess.errorCode) { switch (courseAccess.errorCode) {
case 'audit_expired': case 'audit_expired':
@@ -18,18 +18,20 @@ export function getAccessDeniedRedirectUrl(courseId, activeTabSlug, canLoadCours
case 'survey_required': case 'survey_required':
url = `/redirect/survey/${courseId}`; url = `/redirect/survey/${courseId}`;
break; break;
case 'data_sharing_access_required':
url = `/redirect/consent?consentPath=${encodeURIComponent(courseAccess.developerMessage)}`;
break;
case 'incorrect_active_enterprise':
url = `/course/${courseId}/access-denied`;
break;
case 'unfulfilled_milestones': case 'unfulfilled_milestones':
url = '/redirect/dashboard'; url = '/redirect/dashboard';
break; break;
case 'authentication_required': case 'authentication_required':
case 'enrollment_required': case 'enrollment_required':
default: default:
// if the learner has access to the course, but it is not enabled in the mfe, there is no if (activeTabSlug !== 'outline') {
// error message, canLoadCourseware will be false. url = `/course/${courseId}/home`;
if (activeTabSlug === 'courseware' && canLoadCourseware === false && unitId) {
url = `/redirect/courseware/${courseId}/unit/${unitId}`;
} else if (activeTabSlug !== 'outline') {
url = `/redirect/course-home/${courseId}`;
} }
} }
return url; return url;

View File

@@ -47,13 +47,13 @@ Factory.define('block')
}, },
) )
.attr( .attr(
'legacy_web_url', 'lms_web_url',
['legacy_web_url', 'host', 'courseId', 'id'], ['lms_web_url', 'host', 'courseId', 'id'],
(url, host, courseId, id) => { (url, host, courseId, id) => {
if (url) { if (url) {
return url; return url;
} }
return `${host}/courses/${courseId}/jump_to/${id}?experience=legacy`; return `${host}/courses/${courseId}/jump_to/${id}`;
}, },
); );

View File

@@ -160,8 +160,8 @@ function StreakModal({
</ModalDialog.Title> </ModalDialog.Title>
</ModalDialog.Header> </ModalDialog.Header>
<ModalDialog.Body className="modal-body"> <ModalDialog.Body className="modal-body">
<p>{intl.formatMessage(messages.streakBody)}</p> <p className="text-center">{intl.formatMessage(messages.streakBody)}</p>
<p className="modal-image"> <p className="modal-image text-center">
{!wideScreen && <img src={StreakMobileImage} alt="" className="img-fluid" />} {!wideScreen && <img src={StreakMobileImage} alt="" className="img-fluid" />}
{wideScreen && <img src={StreakDesktopImage} alt="" className="img-fluid" />} {wideScreen && <img src={StreakDesktopImage} alt="" className="img-fluid" />}
</p> </p>

View File

@@ -26,6 +26,10 @@
.modal-body { .modal-body {
padding-top: .5rem; padding-top: .5rem;
font-size: 1.2rem; font-size: 1.2rem;
.pgn__modal-body-content {
text-align: center;
}
} }
.modal-footer { .modal-footer {

View File

@@ -96,8 +96,8 @@ describe('Loaded Tab Page', () => {
await renderModal(); await renderModal();
const endDateText = `Ends ${new Date(Date.now() + 14 * 24 * 60 * 60 * 1000).toLocaleDateString({ timeZone: 'UTC' })}.`; const endDateText = `Ends ${new Date(Date.now() + 14 * 24 * 60 * 60 * 1000).toLocaleDateString({ timeZone: 'UTC' })}.`;
expect(screen.getByText('Youve unlocked a 14% off discount when you upgrade this course for a limited time only.')).toBeInTheDocument(); expect(screen.getByText('Youve unlocked a 14% off discount when you upgrade this course for a limited time only.', { exact: false })).toBeInTheDocument();
expect(screen.getByText(endDateText)).toBeInTheDocument(); expect(screen.getByText(endDateText, { exact: false })).toBeInTheDocument();
expect(screen.getByText('Continue with course')).toBeInTheDocument(); expect(screen.getByText('Continue with course')).toBeInTheDocument();
expect(screen.queryByText('Keep it up')).not.toBeInTheDocument(); expect(screen.queryByText('Keep it up')).not.toBeInTheDocument();
expect(sendTrackEvent).toHaveBeenCalledWith('edx.bi.course.streak_discount_enabled', { expect(sendTrackEvent).toHaveBeenCalledWith('edx.bi.course.streak_discount_enabled', {

View File

@@ -24,7 +24,6 @@ function LoadedTabPage({
}) { }) {
const { const {
celebrations, celebrations,
canViewLegacyCourseware,
org, org,
originalUserIsStaff, originalUserIsStaff,
tabs, tabs,
@@ -58,7 +57,6 @@ function LoadedTabPage({
<InstructorToolbar <InstructorToolbar
courseId={courseId} courseId={courseId}
unitId={unitId} unitId={unitId}
canViewLegacyCourseware={canViewLegacyCourseware}
tab={activeTabSlug} tab={activeTabSlug}
/> />
)} )}

View File

@@ -23,7 +23,6 @@ function TabPage({ intl, ...props }) {
courseId, courseId,
courseStatus, courseStatus,
metadataModel, metadataModel,
unitId,
} = props; } = props;
const { const {
toastBodyLink, toastBodyLink,
@@ -32,7 +31,6 @@ function TabPage({ intl, ...props }) {
} = useSelector(state => state.courseHome); } = useSelector(state => state.courseHome);
const dispatch = useDispatch(); const dispatch = useDispatch();
const { const {
canLoadCourseware,
courseAccess, courseAccess,
number, number,
org, org,
@@ -53,9 +51,7 @@ function TabPage({ intl, ...props }) {
} }
if (courseStatus === 'denied') { if (courseStatus === 'denied') {
const redirectUrl = getAccessDeniedRedirectUrl( const redirectUrl = getAccessDeniedRedirectUrl(courseId, activeTabSlug, courseAccess, start);
courseId, activeTabSlug, canLoadCourseware, courseAccess, start, unitId,
);
if (redirectUrl) { if (redirectUrl) {
return (<Redirect to={redirectUrl} />); return (<Redirect to={redirectUrl} />);
} }