Compare commits

..

43 Commits

Author SHA1 Message Date
Ihor Romaniuk
80f93fe5b1 feat: replace hardcoded edx string in page head (#424)
* feat: replace hardcoded edx string in page head with site_name from configs

* feat: add ability to obtain site name dynamically and fix tests
2023-03-09 13:51:14 -05:00
Ihor Romaniuk
68b71f2c33 feat: replace hardcoded logo with logo from configs (#423) 2023-01-24 13:23:55 -05:00
Adolfo R. Brandes
8b656b5895 fix: Editors should support runtime configuration
Fetching settings directly via `process.env` circumvents the runtime
configuration mechanism.  Change the editor page to use `getConfig()`
instead.
2022-12-15 12:36:15 +00:00
Ghassan Maslamani
a49bff03dc fix: force studio url to reload if changed
This chagne make it possible if this module was loaded **then**
  the configuration for studio url is changed, then it will pick
  the last value.

  More context overhangio/tutor-mfe/issues/86
2022-12-12 09:00:06 +00:00
Zachary Hancock
f54dc17788 feat: configure lti exam providers (#356)
Allows setting a course exam provider to integrations managed by edx-exams. This option is gated by the CourseWaffleFlag course_apps.exams_ida in edx-platform.
2022-10-11 11:54:33 -04:00
Jenkins
7954baf6bd chore(i18n): update translations 2022-10-09 17:32:24 -04:00
connorhaugh
1ff270f6e6 feat: update FLCC (#359) 2022-10-07 10:35:38 -04:00
Kristin Aoki
4ec612dd67 feat: upgrade lib-content-components to 1.41.0 (#352) 2022-10-07 09:15:06 -04:00
Jenkins
a7b0b46e37 chore(i18n): update translations 2022-10-02 17:33:09 -04:00
connorhaugh
ce66be7678 feat: add schedule to renovate jobs
this will make patch/minor upgrade renovate jobs be run daily.
2022-09-28 12:46:25 -04:00
SaadYousaf
3567b36ece fix: handle null blackout dates 2022-09-26 13:25:15 +05:00
Ahtisham Shahid
9db3ded793 fix: changed client secret input type to password (#351)
* fix: changed client secret input type to password

* fix: made secret not required field
2022-09-21 14:44:13 +05:00
Sarina Canelake
4755ae4d67 Fix github url strings (org edx -> openedx) (#348)
* fix: fix github url strings (org edx -> openedx)

* fix: update path to .github workflows to read from openedx org
2022-09-12 14:37:49 -04:00
Jenkins
022502c2a3 chore(i18n): update translations 2022-09-11 17:25:40 -04:00
Kristin Aoki
8a69e51526 feat: upgrade content components 1.39.0 2022-09-08 14:21:31 -04:00
Arunmozhi
38f9f689c9 Simplify in-context discussion toggles [BD-38] (#339)
* refactor: remove unit level visibility toggle

* refactor: remove the in-context discussion settings toggle

* refactor: rename graded units label of in-context discussion setting
2022-09-07 16:41:48 +05:00
connorhaugh
7922ceb711 feat: upgrade flcc minor version (#347) 2022-09-06 15:27:59 -04:00
Mehak Nasir
b142befdc1 fix: dont show free tier in options if no free tier available
fix: free_tier not sent in zoom configs and lti config removed from free_tier

fix: bbb all use cases fixed
2022-09-06 17:05:57 +05:00
Kristin Aoki
ca6848693a feat: update lib-content-components to 1.38.0 2022-08-31 16:43:15 -04:00
Kristin Aoki
ce7fd1593f feat: upgrade lib-content-components to 1.37.0 2022-08-31 16:16:09 -04:00
Kristin Aoki
21c7250672 feat: update lib-content-components to 1.35.0 2022-08-29 10:53:58 -04:00
Muhammad Abdullah Waheed
8dfac206c5 refactor: updated renovate config to auto update minor and patch versions of edx dependencies (#335) 2022-08-24 16:51:39 -04:00
Kristin Aoki
2965508ad1 feat: update lib-content-components to 1.34.0 2022-08-24 14:48:39 -04:00
Mehak Nasir
4bd251d849 fix: fix crash issue on accessing undefined keys (#337) 2022-08-24 16:31:52 +05:00
connorhaugh
325f8727c8 feat: update to flcc 1.33 (#336)
* feat: update to flcc 1.33

* fix: use better npm version
2022-08-23 14:27:01 -04:00
Kristin Aoki
2f212919bd feat: update lib-content-components to 1.31.0 2022-08-18 10:15:34 -04:00
Kristin Aoki
f88411adee feat: update lib-content-components to 1.30.0 2022-08-15 12:48:24 -04:00
Kristin Aoki
f1830dd63e feat: update lib-content-components to 1.29.0 2022-08-12 11:32:23 -04:00
Kristin Aoki
5f73e4277f feat: update lib-content-components to 1.28.0 2022-08-10 11:24:26 -04:00
Kristin Aoki
9fce8ab363 feat: update lib-content-components to 1.27.0 2022-08-08 16:50:49 -04:00
Kristin Aoki
6bc682565c feat: update lib-content-components to 1.26.0 2022-08-08 13:21:11 -04:00
edx-semantic-release
0faba86890 chore(i18n): update translations 2022-08-07 17:31:33 -04:00
Raymond Zhou
154c4ba629 feat: update paragon to 20.6.1 (#326) 2022-08-04 12:35:23 -04:00
Raymond Zhou
e0e3347cda feat: update frontend-platform (#325) 2022-08-02 21:23:38 -04:00
Raymond Zhou
c94a94c08e feat: update lib-content-components to 1.25.1 (#324) 2022-08-02 16:25:13 -04:00
Maman Khan
fcb9e91aa2 Moving code coverage from codecov package to CI (#311)
* fix: removed depreciated package codecov

* fix: syncing package and package-lock
2022-07-28 16:31:41 +05:00
Matt Hughes
21c36a45fa Fixing translations job in this repo (#321)
* chore(i18n): update translations

* fix: upgrade frontend-lib-content-component version

* build: update js dependency lockfile

Co-authored-by: edx-semantic-release <edx-semantic-release@edx.org>
Co-authored-by: Alie Langston <alangsto@wellesley.edu>
2022-07-13 08:34:58 -04:00
alangsto
26a7974cd1 fix: upgrade frontend-lib-content-components version (#320) 2022-07-12 11:14:05 -04:00
Mehak Nasir
d6997b8b4f feat: bbb screens integrated in live (#316)
* feat: bbb screens integrated in live

test: test cases added for bbb screens

fix: field populate issue resolved

fix: added link for read more about plans

fix: launch email is only visible in case of zoom

* fix: free plan message improved

* chore: frontend lib content version update

* chore: frontend lib dependency updated
2022-07-07 19:39:42 +05:00
Raymond Zhou
1bd884c7ca Feat dependency updates (#314) 2022-06-28 16:19:25 -04:00
Raymond Zhou
e8dd28b490 feat: frontend-lib-content-components v1.22 (#313) 2022-06-28 14:50:42 -04:00
Raymond Zhou
9898a00c70 feat: update frontend-lib-content-components (#310) 2022-06-10 10:59:57 -04:00
connorhaugh
6fa312f7b4 feat: upgrade FLCC version (#309)
Co-authored-by: rayzhou-bit <rzhou@2u.com>
2022-06-09 16:08:04 -04:00
48 changed files with 13361 additions and 25556 deletions

1
.env
View File

@@ -5,6 +5,7 @@ CREDENTIALS_BASE_URL=''
CSRF_TOKEN_API_PATH=''
DISCOVERY_API_BASE_URL=''
ECOMMERCE_BASE_URL=''
EXAMS_BASE_URL=''
FAVICON_URL=''
LANGUAGE_PREFERENCE_COOKIE_NAME=''
LMS_BASE_URL=''

View File

@@ -4,6 +4,7 @@ BASE_URL='localhost:2001'
CREDENTIALS_BASE_URL='http://localhost:18150'
CSRF_TOKEN_API_PATH='/csrf/api/v1/token'
DISCOVERY_API_BASE_URL=
EXAMS_BASE_URL=
ECOMMERCE_BASE_URL='http://localhost:18130'
FAVICON_URL='https://edx-cdn.org/v3/default/favicon.ico'
LANGUAGE_PREFERENCE_COOKIE_NAME='openedx-language-preference'

View File

@@ -4,6 +4,7 @@ CREDENTIALS_BASE_URL='http://localhost:18150'
CSRF_TOKEN_API_PATH='/csrf/api/v1/token'
DISCOVERY_API_BASE_URL='http://localhost:18381'
ECOMMERCE_BASE_URL='http://localhost:18130'
EXAMS_BASE_URL=
FAVICON_URL='https://edx-cdn.org/v3/default/favicon.ico'
LANGUAGE_PREFERENCE_COOKIE_NAME='openedx-language-preference'
LMS_BASE_URL='http://localhost:18000'

View File

@@ -7,6 +7,7 @@ module.exports = createConfig('eslint',
controlComponents: ['Input'],
}],
'template-curly-spacing': 'off',
'react-hooks/exhaustive-deps': 'off',
indent: 'off',
},
});

View File

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

View File

@@ -10,4 +10,4 @@ on:
jobs:
version-check:
uses: edx/.github/.github/workflows/lockfileversion-check.yml@master
uses: openedx/.github/.github/workflows/lockfileversion-check.yml@master

View File

@@ -19,6 +19,6 @@ jobs:
node-version: ${{ matrix.node }}
- run: make validate.ci
- name: Upload coverage
uses: codecov/codecov-action@v2
uses: codecov/codecov-action@v3
with:
fail_ci_if_error: true

View File

@@ -15,7 +15,7 @@ Installation and Startup
1. Clone the repo:
``git clone https://github.com/edx/frontend-app-course-authoring.git``
``git clone https://github.com/openedx/frontend-app-course-authoring.git``
2. Install npm dependencies:

View File

@@ -2,4 +2,4 @@
React App i18n HOWTO
####################
This document has moved to the frontend-platform repo: https://github.com/edx/frontend-platform/blob/master/docs/how_tos/i18n.rst
This document has moved to the frontend-platform repo: https://github.com/openedx/frontend-platform/blob/master/docs/how_tos/i18n.rst

36616
package-lock.json generated

File diff suppressed because it is too large Load Diff

View File

@@ -4,7 +4,7 @@
"description": "Frontend application template",
"repository": {
"type": "git",
"url": "git+https://github.com/edx/frontend-app-course-authoring.git"
"url": "git+https://github.com/openedx/frontend-app-course-authoring.git"
},
"browserslist": [
"extends @edx/browserslist-config"
@@ -25,20 +25,20 @@
},
"author": "edX",
"license": "AGPL-3.0",
"homepage": "https://github.com/edx/frontend-app-course-authoring#readme",
"homepage": "https://github.com/openedx/frontend-app-course-authoring#readme",
"publishConfig": {
"access": "public"
},
"bugs": {
"url": "https://github.com/edx/frontend-app-course-authoring/issues"
"url": "https://github.com/openedx/frontend-app-course-authoring/issues"
},
"dependencies": {
"@edx/frontend-build": "8.0.4",
"@edx/brand": "npm:@edx/brand-openedx@1.1.0",
"@edx/frontend-component-footer": "10.1.6",
"@edx/frontend-lib-content-components": "1.20.0",
"@edx/frontend-platform": "1.15.5",
"@edx/paragon": "19.9.0",
"@edx/frontend-build": "^11.0.0",
"@edx/frontend-component-footer": "11.1.1",
"@edx/frontend-lib-content-components": "^1.43.0",
"@edx/frontend-platform": "2.5.1",
"@edx/paragon": "20.6.1",
"@fortawesome/fontawesome-svg-core": "1.2.28",
"@fortawesome/free-brands-svg-icons": "5.11.2",
"@fortawesome/free-regular-svg-icons": "5.11.2",
@@ -54,6 +54,7 @@
"prop-types": "15.7.2",
"react": "16.14.0",
"react-dom": "16.14.0",
"react-helmet": "^6.1.0",
"react-redux": "7.1.3",
"react-responsive": "8.1.0",
"react-router": "5.1.2",
@@ -66,19 +67,17 @@
},
"devDependencies": {
"@edx/browserslist-config": "1.0.0",
"@edx/frontend-build": "8.0.4",
"@edx/frontend-build": "^11.0.0",
"@edx/reactifex": "^1.0.3",
"enzyme": "3.11.0",
"enzyme-adapter-react-16": "1.15.6",
"enzyme-to-json": "^3.6.2",
"@testing-library/jest-dom": "5.14.1",
"@testing-library/jest-dom": "5.16.4",
"@testing-library/react": "12.1.1",
"@testing-library/user-event": "^13.2.1",
"axios-mock-adapter": "1.20.0",
"codecov": "3.8.3",
"enzyme": "3.11.0",
"enzyme-adapter-react-16": "1.15.6",
"enzyme-to-json": "^3.6.2",
"glob": "7.1.6",
"husky": "3.1.0",
"jest": "24.9.0",
"react-test-renderer": "16.9.0",
"reactifex": "1.1.1"
}

View File

@@ -1,7 +1,7 @@
<!doctype html>
<html lang="en-us">
<head>
<title>Course Authoring | edX</title>
<title>Course Authoring | <%= process.env.SITE_NAME %></title>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<link rel="shortcut icon" href="<%= process.env.FAVICON_URL %>" type="image/x-icon" />

View File

@@ -1,9 +1,19 @@
{
"extends": [
"config:base"
"config:base",
"schedule:daily",
":rebaseStalePrs",
":semanticCommits"
],
"patch": {
"automerge": true
},
"rebaseStalePrs": true
"rebaseStalePrs": true,
"packageRules": [
{
"matchPackagePatterns": ["@edx"],
"matchUpdateTypes": ["minor", "patch"],
"automerge": true
}
]
}

View File

@@ -28,6 +28,7 @@ export default function CourseAuthoringPage({ courseId, children }) {
const courseTitle = courseDetail ? courseDetail.name : courseId;
const courseAppsApiStatus = useSelector(getCourseAppsApiStatus);
const inProgress = useSelector(getLoadingStatus) === RequestStatus.IN_PROGRESS;
const { pathname } = useLocation();
if (courseAppsApiStatus === RequestStatus.DENIED) {
return (
<PermissionDeniedAlert />
@@ -49,7 +50,6 @@ export default function CourseAuthoringPage({ courseId, children }) {
</div>
);
const { pathname } = useLocation();
return (
<div className="bg-light-200">
{/* While V2 Editors are tempoarily served from thier own pages

View File

@@ -0,0 +1,34 @@
import { getAuthenticatedHttpClient } from '@edx/frontend-platform/auth';
import { getConfig } from '@edx/frontend-platform';
class ExamsApiService {
static isAvailable() {
return !!this.getExamsBaseUrl();
}
static getExamsBaseUrl() {
return getConfig().EXAMS_BASE_URL;
}
static getExamConfigurationUrl(courseId) {
return `${ExamsApiService.getExamsBaseUrl()}/api/v1/configs/course_id/${courseId}`;
}
static getAvailableProviders() {
const apiClient = getAuthenticatedHttpClient();
const providersUrl = `${ExamsApiService.getExamsBaseUrl()}/api/v1/providers`;
return apiClient.get(providersUrl);
}
static getCourseExamConfiguration(courseId) {
const apiClient = getAuthenticatedHttpClient();
return apiClient.get(this.getExamConfigurationUrl(courseId));
}
static saveCourseExamConfiguration(courseId, dataToSave) {
const apiClient = getAuthenticatedHttpClient();
return apiClient.patch(this.getExamConfigurationUrl(courseId), dataToSave);
}
}
export default ExamsApiService;

View File

@@ -2,6 +2,7 @@ import React from 'react';
import PropTypes from 'prop-types';
import { useParams } from 'react-router';
import { EditorPage } from '@edx/frontend-lib-content-components';
import { getConfig } from '@edx/frontend-platform';
const EditorContainer = ({
courseId,
@@ -13,8 +14,8 @@ const EditorContainer = ({
courseId={courseId}
blockType={blockType}
blockId={blockId}
studioEndpointUrl={process.env.STUDIO_BASE_URL}
lmsEndpointUrl={process.env.LMS_BASE_URL}
studioEndpointUrl={getConfig().STUDIO_BASE_URL}
lmsEndpointUrl={getConfig().LMS_BASE_URL}
/>
</div>
);

21
src/head/Head.jsx Normal file
View File

@@ -0,0 +1,21 @@
import React from 'react';
import { Helmet } from 'react-helmet';
import { injectIntl, intlShape } from '@edx/frontend-platform/i18n';
import { getConfig } from '@edx/frontend-platform';
import messages from './messages';
const Head = ({ intl }) => (
<Helmet>
<title>
{intl.formatMessage(messages['course-authoring.page.title'], { siteName: getConfig().SITE_NAME })}
</title>
<link rel="shortcut icon" href={getConfig().FAVICON_URL} type="image/x-icon" />
</Helmet>
);
Head.propTypes = {
intl: intlShape.isRequired,
};
export default injectIntl(Head);

17
src/head/Head.test.jsx Normal file
View File

@@ -0,0 +1,17 @@
import React from 'react';
import { IntlProvider } from '@edx/frontend-platform/i18n';
import { Helmet } from 'react-helmet';
import { mount } from 'enzyme';
import { getConfig } from '@edx/frontend-platform';
import Head from './Head';
describe('Head', () => {
const props = {};
it('should match render title tag and favicon with the site configuration values', () => {
mount(<IntlProvider locale="en"><Head {...props} /></IntlProvider>);
const helmet = Helmet.peek();
expect(helmet.title).toEqual(`Course Authoring | ${getConfig().SITE_NAME}`);
expect(helmet.linkTags[0].rel).toEqual('shortcut icon');
expect(helmet.linkTags[0].href).toEqual(getConfig().FAVICON_URL);
});
});

11
src/head/messages.js Normal file
View File

@@ -0,0 +1,11 @@
import { defineMessages } from '@edx/frontend-platform/i18n';
const messages = defineMessages({
'course-authoring.page.title': {
id: 'course-authoring.page.title',
defaultMessage: 'Course Authoring | {siteName}',
description: 'Title tag',
},
});
export default messages;

View File

@@ -1,52 +1,52 @@
{
"authoring.alert.error.connection": "We encountered a technical error when loading this page. This might be a temporary issue, so please try again in a few minutes. If the problem persists, please go to the {supportLink} for help.",
"authoring.loading": "Loading...",
"authoring.alert.error.permission": "You are not authorized to view this page. If you feel you should have access, please reach out to your course team admin to be given access.",
"authoring.alert.save.error.connection": "We encountered a technical error when applying changes. This might be a temporary issue, so please try again in a few minutes. If the problem persists, please go to the {supportLink} for help.",
"authoring.alert.support.text": "Support Page",
"course-authoring.pages-resources.app-settings-modal.button.cancel": "Cancel",
"course-authoring.pages-resources.app-settings-modal.button.save": "Save",
"course-authoring.pages-resources.app-settings-modal.button.saving": "Saving",
"course-authoring.pages-resources.app-settings-modal.button.saved": "Saved",
"course-authoring.pages-resources.app-settings-modal.button.retry": "Retry",
"course-authoring.pages-resources.app-settings-modal.badge.enabled": "Enabled",
"course-authoring.pages-resources.app-settings-modal.badge.disabled": "Disabled",
"course-authoring.pages-resources.app-settings-modal.save-error.title": "We couldn't apply your changes.",
"course-authoring.pages-resources.app-settings-modal.save-error.message": "Please check your entries and try again.",
"course-authoring.pages-resources.calculator.heading": "Configure calculator",
"course-authoring.pages-resources.calculator.enable-calculator.label": "Calculator",
"course-authoring.pages-resources.calculator.enable-calculator.help": "The calculator supports numbers, operators, constants,\n functions, and other mathematical concepts. When enabled, an icon to\n access the calculator appears on all pages in the body of your course.",
"course-authoring.pages-resources.calculator.enable-calculator.link": "Learn more about the calculator",
"authoring.discussions.documentationPage": "Visit the {name} documentation page",
"authoring.discussions.formInstructions": "Complete the fields below to set up your discussion tool.",
"authoring.discussions.consumerKey": "Consumer Key",
"authoring.discussions.consumerKey.required": "Consumer key is a required field",
"authoring.discussions.consumerSecret": "Consumer Secret",
"authoring.discussions.consumerSecret.required": "Consumer secret is a required field",
"authoring.discussions.launchUrl": "Launch URL",
"authoring.discussions.launchUrl.required": "Launch URL is a required field",
"authoring.discussions.stuffOnlyConfigInfo": "To enable {providerName} for your course, please contact their support team at {supportEmail} to learn more about pricing and usage.",
"authoring.discussions.stuffOnlyConfigGuide": "To fully configure {providerName} will also require sharing usernames and emails for learners and course team. Please contact your edX project coordinator to enable PII sharing for this course.",
"authoring.discussions.piiSharing": "Optionally share a user's username and/or email with the LTI provider:",
"authoring.discussions.piiShareUsername": "Share username",
"authoring.discussions.piiShareEmail": "Share email",
"authoring.discussions.appDocInstructions.contact": "Contact: {link}",
"authoring.discussions.appDocInstructions.documentationLink": "General documentation",
"authoring.discussions.appDocInstructions.accessibilityDocumentationLink": "Accessibility documentation",
"authoring.discussions.appDocInstructions.configurationLink": "Configuration documentation",
"authoring.discussions.appDocInstructions.learnMoreLink": "Learn more about {providerName}",
"authoring.discussions.appDocInstructions.linkTextHeading": "External help and documentation",
"authoring.alert.error.connection": "واجهنا خطأ تقنيًا أثناء تحميل هذه الصفحة. قد تكون هذه مشكلة عارضة، لذا يرجى المحاولة مجددًا خلال بضع دقائق. إن استمرت المشكلة فيرجى الذهاب إلى {support_link} للحصول على المساعدة.",
"authoring.loading": "التحميل جارٍ...",
"authoring.alert.error.permission": "لست مخوّلا بمشاهدة هذه الصفحة. إن كنت ترى أن لديك حقًا في ذلك، فيرجى التواصل مع مدير فريق مساقك ليمنحك حق الوصول.",
"authoring.alert.save.error.connection": "واجهنا خطأ تقنيًا أثناء تطبيق التغييرات. قد تكون هذه مشكلة عارضة، لذا يرجى المحاولة مجددًا خلال بضع دقائق. إن استمرت المشكلة فيرجى الذهاب إلى {support_link} للحصول على المساعدة.",
"authoring.alert.support.text": "صفحة الدعم",
"course-authoring.pages-resources.app-settings-modal.button.cancel": "إلغاء",
"course-authoring.pages-resources.app-settings-modal.button.save": "حفظ",
"course-authoring.pages-resources.app-settings-modal.button.saving": "الحفظ جارٍ",
"course-authoring.pages-resources.app-settings-modal.button.saved": "تم الحفظ",
"course-authoring.pages-resources.app-settings-modal.button.retry": "إعادة المحاولة",
"course-authoring.pages-resources.app-settings-modal.badge.enabled": "مفعّل",
"course-authoring.pages-resources.app-settings-modal.badge.disabled": "معطل",
"course-authoring.pages-resources.app-settings-modal.save-error.title": "لم نتمكن من تطبيق تغييراتك.",
"course-authoring.pages-resources.app-settings-modal.save-error.message": "رجاءً تحقق من مدخلاتك و حاول مجددًا.",
"course-authoring.pages-resources.calculator.heading": "تهيئة الآلة الحاسبة",
"course-authoring.pages-resources.calculator.enable-calculator.label": "الآلة الحاسبة",
"course-authoring.pages-resources.calculator.enable-calculator.help": "تقبل الحاسبة الأرقام، العمليات، الثوابت، الدوال، و مفاهيم رياضية أخرى. عند تمكينها، ستظهر أيقونة للوصول إلى الآلة الحاسبة في كل صفحة من متن مساقك.",
"course-authoring.pages-resources.calculator.enable-calculator.link": "معرفة المزيد عن الآلة الحاسبة",
"authoring.discussions.documentationPage": "زيارة صفجة وثائق {name}.",
"authoring.discussions.formInstructions": "أكمل الحقول أدناه لتثبيت أداة المناقشة.",
"authoring.discussions.consumerKey": "مفتاح المستهلك (Consumer Key)",
"authoring.discussions.consumerKey.required": "حقل مفتاح المستهلك مطلوب",
"authoring.discussions.consumerSecret": "سر المستهلك (Consumer Secret)",
"authoring.discussions.consumerSecret.required": "حقل سر المستهلك مطلوب",
"authoring.discussions.launchUrl": "عنوان التشغيل (Launch URL)",
"authoring.discussions.launchUrl.required": "حقل تفعيل عنوان التشغيل مطلوب",
"authoring.discussions.stuffOnlyConfigInfo": "لتفعيل {providerName} لمساقك، يرجى الاتصال بفريق الدعم الخاص بهم {supportEmail} لمعرفة المزيد حول الأسعار و الاستخدام.",
"authoring.discussions.stuffOnlyConfigGuide": "إتمام تهيئة {providerName} يتطلب كذلك مشاركة أسماء المستخدمين و عناوين البريد الإلكتروني الخاصة بالمتعلمي و بفريق المساق. يرجى الاتصال بمنسق مشروعك على edX لتفعيل مشاركة معلومات التعريف الشخصية لهذا المساق.",
"authoring.discussions.piiSharing": "شارك اختياريًا اسم المستخدم و / أو بريده الالكتروني مع مزود LTI.",
"authoring.discussions.piiShareUsername": "مشاركة اسم المستخدم",
"authoring.discussions.piiShareEmail": "مشاركة البريد الإلكتروني",
"authoring.discussions.appDocInstructions.contact": "اتصل بـ: {link}",
"authoring.discussions.appDocInstructions.documentationLink": "الوثائق العامة",
"authoring.discussions.appDocInstructions.accessibilityDocumentationLink": "وثائق قابلية الوصول",
"authoring.discussions.appDocInstructions.configurationLink": "وثائق التهيئة",
"authoring.discussions.appDocInstructions.learnMoreLink": "معرفة المزيد عن {providerName}",
"authoring.discussions.appDocInstructions.linkTextHeading": "مساعدة و وثائق خارجية",
"authoring.discussions.appDocInstructions.linkText": "{link}",
"authoring.discussions.configurationChangeConsequences": "Students will lose access to any active or previous discussion posts for your course.",
"authoring.discussions.configure.app": "Configure {name}",
"authoring.discussions.configure": "Configure discussions",
"authoring.discussions.ok": "OK",
"authoring.discussions.cancel": "Cancel",
"authoring.discussions.confirmConfigurationChange": "Are you sure you want to change the discussion settings?",
"authoring.discussions.backButton": "Back",
"authoring.discussions.saveButton": "Save",
"authoring.discussions.savingButton": "Saving",
"authoring.discussions.savedButton": "Saved",
"authoring.discussions.configurationChangeConsequences": "سيفقد الطلبة امكانية الوصول لأي منشورات مناقشة حالية أو سابقة في مساقك.",
"authoring.discussions.configure.app": "تهيئة {name}",
"authoring.discussions.configure": "تهيئة المناقشات",
"authoring.discussions.ok": "حسناً",
"authoring.discussions.cancel": "إلغاء",
"authoring.discussions.confirmConfigurationChange": "أمتأكد أنك تريد تغيير إعدادات المناقشة؟",
"authoring.discussions.backButton": "رجوع",
"authoring.discussions.saveButton": "حفظ",
"authoring.discussions.savingButton": "الحفظ جارٍ",
"authoring.discussions.savedButton": "تم الحفظ",
"authoring.discussions.appConfigForm.appName-piazza": "Piazza",
"authoring.discussions.appConfigForm.appName-yellowdig": "Yellowdig",
"authoring.discussions.appConfigForm.appName-inscribe": "InScribe",
@@ -54,290 +54,291 @@
"authoring.discussions.appConfigForm.appName-ed-discuss": "Ed Discussion",
"authoring.discussions.appConfigForm.appName-legacy": "edX",
"authoring.discussions.appConfigForm.appName-openedx": "edX",
"authoring.discussions.builtIn.divisionByGroup": "Cohorts",
"authoring.discussions.builtIn.divideByCohorts.label": "Divide discussions by cohorts",
"authoring.discussions.builtIn.divideByCohorts.help": "Learners will only be able to view and respond to discussions posted by members of their cohort.",
"authoring.discussions.builtIn.divideCourseTopicsByCohorts.label": "Divide course-wide discussion topics",
"authoring.discussions.builtIn.divideCourseTopicsByCohorts.help": "Choose which of your general course-wide discussion topics you would like to divide.",
"authoring.discussions.builtIn.divideGeneralTopic.label": "General",
"authoring.discussions.builtIn.divideQuestionsForTAsTopic.label": "Questions for the TAs",
"authoring.discussions.builtIn.visibilityInContext": "Visibility of in-context discussions",
"authoring.discussions.builtIn.inContextDiscussion.label": "In-context discussion",
"authoring.discussions.builtIn.inContextDiscussion.help": "Learners will be able to view or hide a discussion side panel to engage with discussion on the course unit page.",
"authoring.discussions.builtIn.gradedUnitPages.label": "Graded unit pages",
"authoring.discussions.builtIn.gradedUnitPages.help": "Allow learners to engage with discussion on all graded unit pages except timed exams.",
"authoring.discussions.builtIn.groupInContextSubsection.label": "Group in context discussion at the subsection level",
"authoring.discussions.builtIn.groupInContextSubsection.help": "Learners will be able to view any post in the sub-section no matter which unit page they are viewing. While this is not recommended, if your course has short learning sequences or low enrollment grouping may increase engagement.",
"authoring.discussions.builtIn.allowUnitLevelVisibility.label": "Allow visibility configuration for each course unit",
"authoring.discussions.builtIn.allowUnitLevelVisibility.help": "With this advanced setting enabled you will be able to override the global visibility setting and turn discussions on or off for each unit from the course outline view..",
"authoring.discussions.builtIn.anonymousPosting": "Anonymous posting",
"authoring.discussions.builtIn.allowAnonymous.label": "Allow anonymous discussion posts",
"authoring.discussions.builtIn.allowAnonymous.help": "If enabled, learners can create posts that are anonymous to all users.",
"authoring.discussions.builtIn.allowAnonymousPeers.label": "Allow anonymous discussion posts to peers",
"authoring.discussions.builtIn.allowAnonymousPeers.help": "Learners will be able to post anonymously to other peers but all posts will be visible to course staff.",
"authoring.discussions.builtIn.reportedContentEmailNotifications": "Notifications",
"authoring.discussions.builtIn.reportedContentEmailNotifications.label": "Email notifications for reported content",
"authoring.discussions.builtIn.reportedContentEmailNotifications.help": "Discussion Admins, Moderators, Community TAs and Group Community TAs (only for their own cohort) will receive an email notification when content is reported.",
"authoring.discussions.discussionTopics": "Discussion topics",
"authoring.discussions.discussionTopics.label": "General discussion topics",
"authoring.discussions.discussionTopics.help": "Discussions can include general topics not contained to the course structure. All courses have a general topic by default.",
"authoring.discussions.discussionTopic.required": "Topic name is a required field",
"authoring.discussions.discussionTopic.alreadyExistError": "It looks like this name is already in use",
"authoring.discussions.addTopicButton": "Add topic",
"authoring.discussions.deleteButton": "Delete",
"authoring.discussions.cancelButton": "Cancel",
"authoring.discussions.discussionTopicDeletion.help": "edX recommends that you do not delete discussion topics once your course is running.",
"authoring.discussions.discussionTopicDeletion.label": "Delete this topic?",
"authoring.discussions.builtIn.renameGeneralTopic.label": "Rename general topic",
"authoring.discussions.generalTopicHelp.help": "This is the default discussion topic for your course.",
"authoring.discussions.builtIn.configureAdditionalTopic.label": "Configure topic",
"authoring.discussions.addTopicHelpText": "Choose a unique name for your topic",
"authoring.discussions.blackoutDates": "Discussion blackout dates",
"authoring.discussions.builtIn.blackoutDates.label": "Blackout dates",
"authoring.discussions.builtIn.blackoutDates.help": "If added, learners will not be able to post in discussions between these dates.",
"authoring.discussions.addBlackoutDatesButton": "Add blackout date range",
"authoring.discussions.builtIn.configureBlackoutDates.label": "Configure blackout date range",
"authoring.discussions.blackoutStartDate.help": "Enter a start date, e.g. 12/10/2023",
"authoring.discussions.blackoutEndDate.help": "Enter an end date, e.g. 12/17/2023",
"authoring.discussions.blackoutStartTime.help": "Enter a start time, e.g. 09:00 AM",
"authoring.discussions.blackoutEndTime.help": "Enter an end time, e.g. 05:00 PM",
"authoring.discussions.activeBlackoutDatesDeletion.help": "These blackout dates are currently active. If deleted, learners will be able to post in discussions during these dates. Are you sure you want to proceed?",
"authoring.discussions.blackoutDatesDeletion.help": "If deleted, learners will be able to post in discussions during these dates.",
"authoring.discussions.completeBlackoutDatesDeletion.help": "Are you sure you want to delete these blackout dates?",
"authoring.discussions.activeBlackoutDatesDeletion.label": "Delete active blackout dates?",
"authoring.discussions.blackoutDatesDeletion.label": "Delete blackout dates?",
"authoring.blackoutDates.delete": "Delete Blackout Dates",
"authoring.discussions.builtIn.divisionByGroup": "الأفواج",
"authoring.discussions.builtIn.divideByCohorts.label": "تقسيم المناقشات حسب الأفواج",
"authoring.discussions.builtIn.divideByCohorts.help": "سيستطيع المتعلمون القراءة و الرد على المناقشات التي ينشرها أعضاء فوجهم فقط.",
"authoring.discussions.builtIn.divideCourseTopicsByCohorts.label": "تقسيم مناقشات المساق العامة",
"authoring.discussions.builtIn.divideCourseTopicsByCohorts.help": "اختر من بين مواضيع المناقشة العامة على مستوى مساقك، أي المواضيع تريد أن تقسّم.",
"authoring.discussions.builtIn.divideGeneralTopic.label": "عام",
"authoring.discussions.builtIn.divideQuestionsForTAsTopic.label": "أسئلة للأساتذة المساعدين",
"authoring.discussions.builtIn.visibilityInContext": "رؤية المناقشات ذات السياق.",
"authoring.discussions.builtIn.gradedUnitPages.label": "تفعيل المناقشات على الوحدات المتواجدة في أقسام فرعية منقّطة",
"authoring.discussions.builtIn.gradedUnitPages.help": "السماح للطلاب بالتفاعل مع المناقشات على جميع صفحات الوحدات باستثناء الامتحانات الموقوتة.",
"authoring.discussions.builtIn.groupInContextSubsection.label": "تجميع المناقشات ذات السياق على مستوى الأقسام الفرعية.",
"authoring.discussions.builtIn.groupInContextSubsection.help": "سيستطيع المتعلمون عرض أي منشور في القسم الفرعي بغض النظر عن صفحة الوحدة التي يشاهدونها. رغم أن هذا غير مستحسن، فإنه قد يزيد من المشاركة إن كان مساقك يحتوي على سلاسل تعلبمية قصيرة أو يتضمن أفواجًا صغيرة من المسجلين.",
"authoring.discussions.builtIn.anonymousPosting": "النشر كمجهول",
"authoring.discussions.builtIn.allowAnonymous.label": "السماح بمنشورات المناقشة مجهولة الكاتب",
"authoring.discussions.builtIn.allowAnonymous.help": "عند التفعيل، سيستطيع الطلاب إنشاء منشورات تبدو لجميع المستخدمين مجهولة الكاتب.",
"authoring.discussions.builtIn.allowAnonymousPeers.label": "السماح للأقران بنشر مناقشات مجهولة الكاتب.",
"authoring.discussions.builtIn.allowAnonymousPeers.help": "سيستطيع المتعلمون النشر دون الكشف عن هويتهم لأقرانهم، لكن جميع المنشورات ستكون مرئية لطاقم المساق.",
"authoring.discussions.builtIn.reportedContentEmailNotifications": "الإشعارات",
"authoring.discussions.builtIn.reportedContentEmailNotifications.label": "بريد إلكتروني للإشعار بالمحتوى المبلغ عنه",
"authoring.discussions.builtIn.reportedContentEmailNotifications.help": "سيتلقى مدراء المناقشة، المشرفون، أساتذة المجتمع المساعدون، و أساتذة الفوج المساعدون (فقط في أفواجهم) بريدًا إلكترونيا عند التبليغ عن محتوى.",
"authoring.discussions.discussionTopics": "مواضيع المناقشة",
"authoring.discussions.discussionTopics.label": "مواضيع المناقشة العامة",
"authoring.discussions.discussionTopics.help": "قد تحتوى المناقشات مواضيع عامة ليست ضمن هيكل المساق. لدى جميع المساقات موضوع عام بشكل افتراضي.",
"authoring.discussions.discussionTopic.required": "حقل اسم الموضوع مطلوب",
"authoring.discussions.discussionTopic.alreadyExistError": "يبدو أن هذا الاسم مستخدم من قبل",
"authoring.discussions.addTopicButton": "إضافة موضوع",
"authoring.discussions.deleteButton": "حذف",
"authoring.discussions.cancelButton": "إلغاء",
"authoring.discussions.discussionTopicDeletion.help": "ننصح في edX بعدم حذف مواضيع المناقشة بعد إنطلاق مساقك.",
"authoring.discussions.discussionTopicDeletion.label": "هل تريد حذف هذا الموضوع؟",
"authoring.discussions.builtIn.renameGeneralTopic.label": "تعديل اسم الموضوع العام",
"authoring.discussions.generalTopicHelp.help": "هذا هو موضوع المناقشة الافتراضي لمساقك.",
"authoring.discussions.builtIn.configureAdditionalTopic.label": "تهيئة الموضوع",
"authoring.discussions.addTopicHelpText": "اختر اسما فريدًا لموضوعك",
"authoring.discussions.blackoutDates": "مواعيد تعطيل المناقشة",
"authoring.discussions.builtIn.blackoutDates.label": "مواعيد التعطيل",
"authoring.discussions.builtIn.blackoutDates.help": "عند الإضافة لن يستطيع المتعلمون النشر في المناقشات بين هذه التواريخ.",
"authoring.discussions.addBlackoutDatesButton": "إضافة نطاق زمني للتعطيل",
"authoring.discussions.builtIn.configureBlackoutDates.label": "تهيئة النطاق الزمني للتعطيل",
"authoring.discussions.blackoutStartDate.help": "أدخل تاريخ بدء، مثلا 12/10/2023",
"authoring.discussions.blackoutEndDate.help": "أدخل تاريخ انتهاء، مثلا 12/17/2023",
"authoring.discussions.blackoutStartTime.help": "أدخل وقت البدء، مثلا 09:00 صباحًا",
"authoring.discussions.blackoutEndTime.help": "أدخل وقت الانتهاء، مثلا 05:00 مساءً",
"authoring.discussions.activeBlackoutDatesDeletion.help": "مواعيد التعطيل هذه مفعّلة حاليا. إن تم حذفها، فسيستطيع المتعلمون النشر في المناقشات خلال هذه التواريخ. هل انت متأكد أنك تريد المواصلة؟",
"authoring.discussions.blackoutDatesDeletion.help": "عند الحذف سيستطيع المتعلمون النشر في المناقشات خلال هذه التواريخ.",
"authoring.discussions.completeBlackoutDatesDeletion.help": "هل أنت متأكد أنك تريد حذف تواريخ التعطيل هذه؟",
"authoring.discussions.activeBlackoutDatesDeletion.label": "هل تريد حذف تواريخ التعطيل المفعّلة؟",
"authoring.discussions.blackoutDatesDeletion.label": "هل تريد حذف تواريخ التعطيل؟",
"authoring.blackoutDates.delete": "حذف تواريخ التعطيل",
"authoring.blackoutDates.status": "{status}",
"authoring.blackoutDates.startDate.required": "Start date is a required field",
"authoring.blackoutDates.endDate.required": "End date is a required field",
"authoring.blackoutDates.startDate.inPast": "Start date cannot be after end date",
"authoring.blackoutDates.endDate.inPast": "End date cannot be before start date",
"authoring.blackoutDates.startTime.inPast": "Start time cannot be after end time",
"authoring.blackoutDates.endTime.inPast": "End time cannot be before start time",
"authoring.blackoutDates.startTime.inValidFormat": "Enter a valid start time",
"authoring.blackoutDates.endTime.inValidFormat": "Enter a valid end time",
"authoring.blackoutDates.startDate.inValidFormat": "Enter a valid start Date",
"authoring.blackoutDates.endDate.inValidFormat": "Enter a valid end date",
"authoring.topics.delete": "Delete Topic",
"authoring.topics.expand": "Expand",
"authoring.topics.collapse": "Collapse",
"authoring.blackoutDates.start.date": "Start date",
"authoring.blackoutDates.start.time": "Start time (optional) ({zone})",
"authoring.blackoutDates.end.date": "End date",
"authoring.blackoutDates.end.time": "End time (optional) ({zone})",
"authoring.discussions.heading": "Select a discussion tool for this course",
"authoring.discussions.supportedFeatures": "Supported features",
"authoring.discussions.supportedFeatureList-mobile-show": "Show supported features",
"authoring.discussions.supportedFeatureList-mobile-hide": "Hide supported features",
"authoring.discussions.noApps": "There are no discussions providers available for your course.",
"authoring.discussions.nextButton": "Next",
"authoring.discussions.appFullSupport": "Full support",
"authoring.discussions.appBasicSupport": "Basic support",
"authoring.discussions.selectApp": "Select {appName}",
"authoring.blackoutDates.startDate.required": "حقل تاريخ البدء مطلوب",
"authoring.blackoutDates.endDate.required": "حقل تاريخ الانتهاء مطلوب",
"authoring.blackoutDates.startDate.inPast": "لا يمكن لتاريخ البدء أن يأتي بعد تاريخ الانتهاء",
"authoring.blackoutDates.endDate.inPast": "لا يمكن لتاريخ الانتهاء أن يسبق تاريخ البدء",
"authoring.blackoutDates.startTime.inPast": "لا يمكن لوقت البدء أن يأتي بعد وقت الانتهاء",
"authoring.blackoutDates.endTime.inPast": "لا يمكن لوقت الانتهاء أن يسبق وقت البدء",
"authoring.blackoutDates.startTime.inValidFormat": "أدخل وقت بدء صالحًا",
"authoring.blackoutDates.endTime.inValidFormat": "أدخل وقت انتهاء صالحًا",
"authoring.blackoutDates.startDate.inValidFormat": "أدخل تاريخ بدء صالحًا",
"authoring.blackoutDates.endDate.inValidFormat": "أدخل تاريخ انتهاء صالحًا",
"authoring.topics.delete": "حذف الموضوع",
"authoring.topics.expand": "توسيع",
"authoring.topics.collapse": "طي",
"authoring.blackoutDates.start.date": "تاريخ البدء",
"authoring.blackoutDates.start.time": "وقت البدء (اختياري) ({zone})",
"authoring.blackoutDates.end.date": "تاريخ الانتهاء",
"authoring.blackoutDates.end.time": "وقت الانتهاء (اختياري) ({zone})",
"authoring.discussions.heading": "حدد أداة مناقشة لهذا المساق.",
"authoring.discussions.supportedFeatures": "الميزات المدعومة",
"authoring.discussions.supportedFeatureList-mobile-show": "إظهار الميزات المدعومة",
"authoring.discussions.supportedFeatureList-mobile-hide": "إخفاء الميزات المدعومة",
"authoring.discussions.noApps": "لا يوجد أي مزود مناقشات متوفّر لمساقك.",
"authoring.discussions.nextButton": "التالي",
"authoring.discussions.appFullSupport": "دعم كامل",
"authoring.discussions.appBasicSupport": "دعم قاعدي",
"authoring.discussions.selectApp": "اختيار {اسم تطبيق}",
"authoring.discussions.appList.appName-legacy": "edX",
"authoring.discussions.appList.appDescription-legacy": "Start conversations with other learners, ask questions, and interact with other learners in the course.",
"authoring.discussions.appList.appDescription-legacy": "ابدأ مناقشات مع متعلمين آخرين، اطرح أسئلة، و تفاعل مع المتعلمين في المساق.",
"authoring.discussions.appList.appName-openedx": "edX",
"authoring.discussions.appList.appDescription-openedx": "Start conversations with other learners, ask questions, and interact with other learners in the course.",
"authoring.discussions.appList.appDescription-openedx": "ابدأ مناقشات مع متعلمين آخرين، اطرح أسئلة، و تفاعل مع المتعلمين في المساق.",
"authoring.discussions.appList.appName-piazza": "Piazza",
"authoring.discussions.appList.appDescription-piazza": "Piazza is designed to connect students, TAs, and professors so every student can get the help they need when they need it.",
"authoring.discussions.appList.appDescription-yellowdig": "Yellowdig offers educators a gameful learning digital solution to improve student engagement by building learning communities for any course modality.",
"authoring.discussions.appList.appDescription-inscribe": "InScribe leverages the power of community + artificial intelligence to connect individuals to the answers, resources, and people they need to succeed.",
"authoring.discussions.appList.appDescription-discourse": "Discourse is modern forum software for your community. Use it as a mailing list, discussion forum, long-form chat room, and more!",
"authoring.discussions.appList.appDescription-ed-discus": "Ed Discussion helps scale class communication in a beautiful and intuitive interface. Questions reach and benefit the whole class. Less emails, more time saved.",
"authoring.discussions.featureName-discussion-page": "Discussion page",
"authoring.discussions.featureName-embedded-course-sections": "Embedded course sections",
"authoring.discussions.featureName-advanced-in-context-discussion": "Advanced in context discussion",
"authoring.discussions.featureName-anonymous-posting": "Anonymous posting",
"authoring.discussions.featureName-automatic-learner-enrollment": "Automatic learner enrollment",
"authoring.discussions.featureName-blackout-discussion-dates": "Blackout discussion dates",
"authoring.discussions.featureName-community-ta-support": "Community TA support",
"authoring.discussions.featureName-course-cohort-support": "Course cohort support",
"authoring.discussions.featureName-direct-messages-from-instructors": "Direct messages from instructors",
"authoring.discussions.featureName-discussion-content-prompts": "Discussion content prompts",
"authoring.discussions.featureName-email-notifications": "Email notifications",
"authoring.discussions.featureName-graded-discussions": "Graded discussions",
"authoring.discussions.featureName-in-platform-notifications": "In-platform notifications",
"authoring.discussions.featureName-internationalization-support": "Internationalization support",
"authoring.discussions.featureName-lti-advanced-sharing-mode": "LTI advanced sharing",
"authoring.discussions.featureName-basic-configuration": "Basic configuration",
"authoring.discussions.featureName-primary-discussion-app-experience": "Primary discussion app experience",
"authoring.discussions.featureName-question-&-discussion-support": "Question & discussion support",
"authoring.discussions.featureName-report/flag-content-to-moderators": "Report content to moderators",
"authoring.discussions.featureName-research-data-events": "Research data events",
"authoring.discussions.featureName-simplified-in-context-discussion": "Simplified in-context discussion",
"authoring.discussions.featureName-user-mentions": "User mentions",
"authoring.discussions.featureName-wcag-2.1": "WCAG 2.1 support",
"authoring.discussions.wcag-2.0-support": "WCAG 2.0 support",
"authoring.discussions.basic-support": "Basic support",
"authoring.discussions.partial-support": "Partial support",
"authoring.discussions.full-support": "Full support",
"authoring.discussions.common-support": "Commonly requested",
"authoring.discussions.settings": "Settings",
"authoring.discussions.applyButton": "Apply",
"authoring.discussions.applyingButton": "Applying",
"authoring.discussions.appliedButton": "Applied",
"authoring.discussions.noProviderSwitchAfterCourseStarted": "Discussion provider can't be changed after course has started, please reach out to partner support.",
"authoring.discussions.providerSelection": "Provider selection",
"authoring.discussions.Incomplete": "Incomplete",
"course-authoring.pages-resources.notes.heading": "Configure notes",
"course-authoring.pages-resources.notes.enable-notes.label": "Notes",
"course-authoring.pages-resources.notes.enable-notes.help": "Learners can access their notes either in the body of the\n course of on a notes page. On the notes page, a learner can see all the\n notes made during the course. The page also contains links to the location\n of the notes in the course body.",
"course-authoring.pages-resources.notes.enable-notes.link": "Learn more about notes",
"authoring.pagesAndResources.live.enableLive.heading": "Configure Live",
"authoring.pagesAndResources.live.enableLive.label": "Live",
"authoring.pagesAndResources.live.enableLive.help": "Schedule meetings and conduct live course sessions with learners.",
"authoring.pagesAndResources.live.enableLive.link": "Learn more about live",
"authoring.live.selectProvider": "Select a video conferencing tool",
"authoring.live.formInstructions": "Complete the fields below to set up your video conferencing tool.",
"authoring.live.consumerKey": "Consumer Key",
"authoring.live.consumerKey.required": "Consumer key is a required field",
"authoring.live.consumerSecret": "Consumer Secret",
"authoring.live.consumerSecret.required": "Consumer secret is a required field",
"authoring.live.launchUrl": "Launch URL",
"authoring.live.launchUrl.required": "Launch URL is a required field",
"authoring.live.launchEmail": "Launch Email",
"authoring.live.launchEmail.required": "Launch Email is a required field",
"authoring.live.provider.helpText": "This configuration will require sharing username and emails of learners and the course team with {providerName}.",
"authoring.live.requestPiiSharingEnable": "This configuration will require sharing usernames and emails of learners and the course team with {provider}. To access the LTI configuration for {provider}, please request your edX project coordinator to get PII sharing enabled for this course.",
"authoring.live.appDocInstructions.documentationLink": "General documentation",
"authoring.live.appDocInstructions.accessibilityDocumentationLink": "Accessibility documentation",
"authoring.live.appDocInstructions.configurationLink": "Configuration documentation",
"authoring.live.appDocInstructions.learnMoreLink": "Learn more about {providerName}",
"authoring.live.appDocInstructions.linkTextHeading": "External help and documentation",
"authoring.discussions.appList.appDescription-piazza": "Piazza مصمم لربط الطلبة، الأساتذة، و الأساتذة المساعدين بما يمكن كل طالب من الحصول على المساعدة التي يحتاجها في حينها.",
"authoring.discussions.appList.appDescription-yellowdig": "Yellowdig يقدم للمعلمين حلاً رقميًا تعليميًا ممتعًا لتحسين مشاركة الطلاب من خلال بناء مجتمعات متعلمين لأي نمط مساق. ",
"authoring.discussions.appList.appDescription-inscribe": " InScribe يستفيد من قوة المجتمع + الذكاء الاصطناعي لربط الأفراد بالإجابات و الموارد و الأشخاص الذين يحتاجونهم لينجحوا.",
"authoring.discussions.appList.appDescription-discourse": "Discourse برنامج منتدى حديث لمجتمعك. استخدمه كقائمة بريدية، كمنتدى مناقشة، كغرفة دردشة طويلة، و أكثر من ذلك!",
"authoring.discussions.appList.appDescription-ed-discus": "يساعد Ed Discussion على توسيع نطاق التواصل في الفصل في واجهة جميلة وبديهية. الأسئلة تبلغ و تفيد الفصل بأكمله. قلل من رسائل البريد الإلكتروني و اربح مزيدًا من الوقت.",
"authoring.discussions.featureName-discussion-page": "صفحة المناقشة",
"authoring.discussions.featureName-embedded-course-sections": "أقسام فرعية للمساق",
"authoring.discussions.featureName-advanced-in-context-discussion": "متقدم في المناقشات ذات السياق",
"authoring.discussions.featureName-anonymous-posting": "النشر كمجهول",
"authoring.discussions.featureName-automatic-learner-enrollment": "تسجيل المتعلمين تلقائيا",
"authoring.discussions.featureName-blackout-discussion-dates": "مواعيد تعطيل المناقشة",
"authoring.discussions.featureName-community-ta-support": "دعم الأساتذة المساعدين من المجتمع",
"authoring.discussions.featureName-course-cohort-support": "دعم أفواج المساق",
"authoring.discussions.featureName-direct-messages-from-instructors": "الرسائل المباشرة من الأساتذة",
"authoring.discussions.featureName-discussion-content-prompts": "إرشادات عن محتوى المناقشة",
"authoring.discussions.featureName-email-notifications": "إشعارات البريد الإلكتروني",
"authoring.discussions.featureName-graded-discussions": "المناقشات المنقّطة",
"authoring.discussions.featureName-in-platform-notifications": "الإشعارات داخل المنصة",
"authoring.discussions.featureName-internationalization-support": "دعم التدويل",
"authoring.discussions.featureName-lti-advanced-sharing-mode": "مشاركة LTI متقدمة",
"authoring.discussions.featureName-basic-configuration": "التهيئة القاعدية",
"authoring.discussions.featureName-primary-discussion-app-experience": "التجربة الأساسية لتطبيق المناقشة",
"authoring.discussions.featureName-question-&-discussion-support": "دعم الأسئلة و المناقشات",
"authoring.discussions.featureName-report/flag-content-to-moderators": "تبليغ المشرفين عن المحتوى",
"authoring.discussions.featureName-research-data-events": "أحداث بيانات البحث",
"authoring.discussions.featureName-simplified-in-context-discussion": "مناقشات ذات سياق مبسطة",
"authoring.discussions.featureName-user-mentions": "الإشارة للمستخدمين",
"authoring.discussions.featureName-wcag-2.1": "دعم WCAG 2.1",
"authoring.discussions.wcag-2.0-support": "دعم WCAG 2.0",
"authoring.discussions.basic-support": "دعم القاعدي",
"authoring.discussions.partial-support": "دعم جزئي",
"authoring.discussions.full-support": "دعم كامل",
"authoring.discussions.common-support": "مطلوبة بكثرة",
"authoring.discussions.settings": "الإعدادات",
"authoring.discussions.applyButton": "تقديم طلب",
"authoring.discussions.applyingButton": "تقديم الطلب جارٍ",
"authoring.discussions.appliedButton": "تم تقديم الطلب",
"authoring.discussions.noProviderSwitchAfterCourseStarted": "لا يمكن تغيير مزود المناقشة بعد انطلاق المساق، يرجى التواصل مع دعم الشركاء.",
"authoring.discussions.providerSelection": "اختيار المزود",
"authoring.discussions.Incomplete": "غير مكتملة",
"course-authoring.pages-resources.notes.heading": "تهيئة الملاحظات",
"course-authoring.pages-resources.notes.enable-notes.label": "الملاحظات",
"course-authoring.pages-resources.notes.enable-notes.help": "يمكن للمتعلمين الوصول إلى ملاحظاتهم إما في متن المساق أو في صفحة الملاحظات. يمكن للمتعلم رؤية جميع الملاحظات التي دونت خلال هذا المساق في صفحة الملاحظات. تحتوي الصفحة كذلك على روابط لمواضع الملاحظات في متن المساق.",
"course-authoring.pages-resources.notes.enable-notes.link": "معرفة المزيد عن الملاحظات",
"authoring.live.bbb.selectPlan.label": "خدد خطة",
"authoring.pagesAndResources.live.enableLive.heading": "تهيئة البث المباشر",
"authoring.pagesAndResources.live.enableLive.label": "البث المباشر",
"authoring.pagesAndResources.live.enableLive.help": "قم بجدولة اجتماعات و نظّم جلسات تدريس مباشرة مع الطلبة.",
"authoring.pagesAndResources.live.enableLive.link": "معرفة المزيد عن البث المباشر",
"authoring.live.selectProvider": "حدد أداة مؤتمرات الفيديو",
"authoring.live.formInstructions": "أكمل الحقول أدناه لتثبيت أداة مؤتمرات الفيديو الخاصة بك.",
"authoring.live.consumerKey": "مفتاح المستهلك (Consumer Key)",
"authoring.live.consumerKey.required": "حقل مفتاح المستهلك مطلوب",
"authoring.live.consumerSecret": "سر المستهلك (Consumer Secret)",
"authoring.live.consumerSecret.required": "حقل سر المستهلك مطلوب",
"authoring.live.launchUrl": "عنوان التشغيل (Launch URL)",
"authoring.live.launchUrl.required": "حقل عنوان التشغيل مطلوب",
"authoring.live.launchEmail": "عنوان بريد التشغيل الإلكتروني (Launch Email)",
"authoring.live.launchEmail.required": "حقل عنوان بريد التشغيل الإلكتروني مطلوب",
"authoring.live.provider.helpText": "تتطلب هذه التهيئة مشاركة {providerName} أسماء المستخدمين و عناوين البريد الإلكتروني الخاصة بالمتعلمين و فريق المساق.",
"authoring.live.requestPiiSharingEnable": "تتطلب هذه التهيئة مشاركة {provider} أسماء المستخدمين و عناوين البريد الإلكتروني الخاصة بالمتعلمين و فريق المساق. للوصول إلى تهيئة LTI الخاصة بـ{provider}، يرجى الاتصال بمنسق مشروعك على edX لتفعيل مشاركة معلومات التعريف الشخصية لهذا المساق.",
"authoring.live.appDocInstructions.documentationLink": "الوثائق العامة",
"authoring.live.appDocInstructions.accessibilityDocumentationLink": "وثائق قابلية الوصول",
"authoring.live.appDocInstructions.configurationLink": "وثائق التهيئة",
"authoring.live.appDocInstructions.learnMoreLink": "معرفة المزيد عن {providerName}",
"authoring.live.appDocInstructions.linkTextHeading": "مساعدة و وثائق خارجية",
"authoring.live.appDocInstructions.linkText": "{link}",
"authoring.live.appName-yellowdig": "Zoom",
"authoring.live.appName-googleMeet": "Google Meet",
"authoring.live.appName-microsoftTeams": "Microsoft Teams",
"course-authoring.pages-resources.heading": "الصفحات والموارد",
"course-authoring.pages-resources.resources.settings.button": "settings",
"course-authoring.pages-resources.viewLive.button": "View live",
"course-authoring.badge.enabled": "Enabled",
"authoring.proctoring.no": "No",
"authoring.proctoring.yes": "Yes",
"authoring.proctoring.support.text": "Support Page",
"authoring.proctoring.enableproctoredexams.label": "Proctored exams",
"authoring.proctoring.enableproctoredexams.help": "Enable and configure proctored exams in your course.",
"authoring.proctoring.enabled": "Enabled",
"authoring.proctoring.learn.more": "Learn more about proctoring",
"authoring.proctoring.provider.label": "Proctoring provider",
"authoring.proctoring.provider.help": "Select the proctoring provider you want to use for this course run.",
"authoring.proctoring.provider.help.aftercoursestart": "Proctoring provider cannot be modified after course start date.",
"authoring.proctoring.escalationemail.label": "Proctortrack escalation email",
"authoring.proctoring.escalationemail.help": "Provide an email address to be contacted by the support team for escalations (e.g. appeals, delayed reviews).",
"authoring.proctoring.escalationemail.error.blank": "The Proctortrack Escalation Email field cannot be empty if proctortrack is the selected provider.",
"authoring.proctoring.escalationemail.error.invalid": "The Proctortrack Escalation Email field is in the wrong format and is not valid.",
"authoring.proctoring.allowoptout.label": "Allow learners to opt out of proctoring on proctored exams",
"authoring.proctoring.createzendesk.label": "Create Zendesk tickets for suspicious attempts",
"authoring.proctoring.error.single": "There is 1 error in this form.",
"authoring.proctoring.escalationemail.error.multiple": "There are {numOfErrors} errors in this form.",
"authoring.proctoring.save": "Save",
"authoring.proctoring.saving": "Saving...",
"authoring.proctoring.cancel": "Cancel",
"authoring.proctoring.studio.link.text": "Go back to your course in Studio",
"authoring.proctoring.alert.success": "\n Proctored exam settings saved successfully. {studioCourseRunURL}.\n ",
"authoring.examsettings.alert.error": "\n We encountered a technical error while trying to save proctored exam settings.\n This might be a temporary issue, so please try again in a few minutes.\n If the problem persists,\n please go to the {support_link} for help.\n ",
"course-authoring.pages-resources.progress.heading": "Configure progress",
"course-authoring.pages-resources.progress.enable-progress.label": "Progress",
"course-authoring.pages-resources.progress.enable-progress.help": "As students work through graded assignments, scores\n will appear under the progress tab. The progress tab contains a chart of\n all graded assignments in the course, with a list of all assignments and\n scores below.",
"course-authoring.pages-resources.progress.enable-progress.link": "Learn more about progress",
"course-authoring.pages-resources.progress.enable-graph.label": "Enable progress graph",
"course-authoring.pages-resources.progress.enable-graph.help": "If enabled, students can view the progress graph",
"authoring.pagesAndResources.teams.heading": "Configure teams",
"authoring.pagesAndResources.teams.enableTeams.label": "Teams",
"authoring.pagesAndResources.teams.enableTeams.help": "Allow learners to work together on specific projects or activities.",
"authoring.pagesAndResources.teams.enableTeams.link": "Learn more about teams",
"authoring.pagesAndResources.teams.teamSize.heading": "Team size",
"authoring.pagesAndResources.teams.teamSize.maxTeamSize": "Max team size",
"authoring.pagesAndResources.teams.teamSize.maxTeamSizeHelp": "The maximum number of learners that can join a team",
"authoring.pagesAndResources.teams.teamSize.maxTeamSizeEmpty": "Enter max team size",
"authoring.pagesAndResources.teams.teamSize.maxTeamSizeInvalid": "Max team size must be a positive number larger than zero.",
"authoring.pagesAndResources.teams.teamSize.maxTeamSizeTooHigh": "Max team size cannot be greater than {max}",
"authoring.pagesAndResources.teams.groups.heading": "Groups",
"authoring.pagesAndResources.teams.groups.help": "Groups are spaces where learners can create or join teams.",
"authoring.pagesAndResources.teams.configureGroup.heading": "Configure group",
"authoring.pagesAndResources.teams.group.name.label": "Name",
"authoring.pagesAndResources.teams.group.name.help": "Choose a unique name for this group",
"authoring.pagesAndResources.teams.group.name.error.empty": "Enter a unique name for this group",
"authoring.pagesAndResources.teams.group.name.error.exists": "It looks like this name is already in use",
"authoring.pagesAndResources.teams.group.description.label": "Description",
"authoring.pagesAndResources.teams.group.description.help": "Enter details about this group",
"authoring.pagesAndResources.teams.group.description.error": "Enter a description for this group",
"authoring.pagesAndResources.teams.group.type.label": "Type",
"authoring.pagesAndResources.teams.group.type.help": "Control who can see, create and join teams",
"authoring.pagesAndResources.teams.group.types.open": "Open",
"authoring.pagesAndResources.teams.group.types.open.description": "Learners can create, join, leave, and see other teams",
"authoring.pagesAndResources.teams.group.types.public_managed": "Public managed",
"authoring.pagesAndResources.teams.group.types.public_managed.description": "Only course staff can control teams and memberships. Learners can see other teams.",
"authoring.pagesAndResources.teams.group.types.private_managed": "Private managed",
"authoring.pagesAndResources.teams.group.types.private_managed.description": "Only course staff can control teams, memberships, and see other teams",
"authoring.pagesAndResources.teams.group.maxSize.label": "Max team size (optional)",
"authoring.pagesAndResources.teams.group.maxSize.help": "Override the global max team size",
"authoring.pagesAndResources.teams.addGroup.button": "Add group",
"authoring.pagesAndResources.teams.group.delete": "Delete",
"authoring.pagesAndResources.teams.group.expand": "Expand group editor",
"authoring.pagesAndResources.teams.group.collapse": "Close group editor",
"authoring.pagesAndResources.teams.deleteGroup.initiateDelete": "Delete",
"authoring.pagesAndResources.teams.deleteGroup.cancel-delete.button": "Cancel",
"authoring.pagesAndResources.teams.deleteGroup.heading": "Delete this group?",
"authoring.pagesAndResources.teams.deleteGroup.body": "edX recommends that you do not delete groups once your course is running.\n Your group will no longer be visible in the LMS and learners will not be able to leave teams associated with it.\n Please delete learners from teams before deleting the associated group.",
"authoring.pagesAndResources.teams.enableGroups.error.noGroupsFound.title": "No groups found",
"authoring.pagesAndResources.teams.enableGroups.error.noGroupsFound.message": "Add one or more groups to enable teams.",
"course-authoring.pages-resources.wiki.heading": "Configure wiki",
"course-authoring.pages-resources.wiki.enable-wiki.label": "Wiki",
"course-authoring.pages-resources.wiki.enable-wiki.help": "The course wiki can be set up based on the needs of your\n course. Common uses might include sharing answers to course FAQs, sharing\n editable course information, or providing access to learner-created\n resources.",
"course-authoring.pages-resources.wiki.enable-wiki.link": "Learn more about the wiki",
"course-authoring.pages-resources.wiki.enable-public-wiki.label": "Enable public wiki access",
"course-authoring.pages-resources.wiki.enable-public-wiki.help": "If enabled, edX users can view the course wiki even when\n they're not enrolled in the course.",
"authoring.examsettings.enableproctoredexams.help": "إذا تم تحديده ، يتم تفعيل الامتحانات المراقبة في مساقك.",
"authoring.live.appName-bigBlueButton": "BigBlueButton",
"authoring.live.requestPiiSharingEnableForBbb": "تتطلب هذه التهيئة مشاركة {provider} أسماء المستخدمين الخاصة بالمتعلمين و فريق المساق.",
"authoring.live.piiSharingEnableHelpText": "لتفعيل هذه الميزة، يرجى الاتصال بفريق دعم edX لتفعيل مشاركة معلومات التعريف الشخصية لهذا المساق.",
"authoring.live.freePlanMessage": "الخطة المجانية مهيأة سلفًا، و لا حاجة ﻷي تهيئة إضافية.",
"course-authoring.pages-resources.heading": "الصفحات و الموارد",
"course-authoring.pages-resources.resources.settings.button": "الإعدادات",
"course-authoring.pages-resources.viewLive.button": "مشاهدة النسخة الحية",
"course-authoring.badge.enabled": "مفعّل",
"authoring.proctoring.no": "لا",
"authoring.proctoring.yes": "نعم",
"authoring.proctoring.support.text": "صفحة الدعم",
"authoring.proctoring.enableproctoredexams.label": "الامتحانات المراقبة",
"authoring.proctoring.enableproctoredexams.help": "قم بتفعيل و تهيئة الامتحانات المراقبة في مساقك.",
"authoring.proctoring.enabled": "مفعّل",
"authoring.proctoring.learn.more": "معرفة المزيد عن المراقبة",
"authoring.proctoring.provider.label": "مزود المراقبة",
"authoring.proctoring.provider.help": "حدد مزود المراقبة الذي تريد استخدامه لهذا المساق.",
"authoring.proctoring.provider.help.aftercoursestart": "لا يمكن تعديل مزود المراقبة بعد تاريخ بدء المساق.",
"authoring.proctoring.escalationemail.label": "عنوان بريد التصعيد الإلكتروني لـ Proctortrack",
"authoring.proctoring.escalationemail.help": "أدخل عنوان بريد إلكتروني لتلقي رسائل التصعيد (مثلا طلبات الاستئناف، المراجعات المتأخرة، و غيرها) من فريق الدعم.",
"authoring.proctoring.escalationemail.error.blank": "حقل عنوان بريد التصعيد الإلكتروني لـ Proctortrack لا يمكن أن يكون فارغًا إن كان Proctortrack هو المزود المختار.",
"authoring.proctoring.escalationemail.error.invalid": "حقل بريد التصعيد الإلكتروني لـ Proctortrack صيغته خاطئة و هو غير صالح.",
"authoring.proctoring.allowoptout.label": "السماح للمتعلمين بعدم الخضوع للمراقبة في الامتحانات المراقَبة",
"authoring.proctoring.createzendesk.label": "إنشاء تذاكر Zendesk للمحاولات المشبوهة",
"authoring.proctoring.error.single": "يوجد خطأ واحد في هذه الاستمارة.",
"authoring.proctoring.escalationemail.error.multiple": "{numOfErrors, plural,\n zero {لا يوجد خطأ}\n one {يوجد خطأ واحد}\n two {يوجد خطآن}\n few {توجد # أخطا}\n many {يوجد # خطأً}\n other {يوجد # خطإٍ}\n} في هذا النموذج.",
"authoring.proctoring.save": "حفظ",
"authoring.proctoring.saving": "الحفظ جارٍ...",
"authoring.proctoring.cancel": "إلغاء",
"authoring.proctoring.studio.link.text": "الرجوع إلى مساقك في الاستوديو",
"authoring.proctoring.alert.success": "تم حفظ إعدادات الامتحانات المراقبة بنجاح. {studioCourseRunURL}.",
"authoring.examsettings.alert.error": "واجهنا خطأ تقنيًا أثناء محاولة حفظ إعدادات الامتحان المراقب. قد تكون هذه مشكلة عارضة، لذا يرجى المحاولة مجددًا خلال بضع دقائق. إن استمرت المشكلة فيرجى الذهاب إلى {support_link} للحصول على المساعدة.",
"course-authoring.pages-resources.progress.heading": "تهيئة التقدم",
"course-authoring.pages-resources.progress.enable-progress.label": "التقدم",
"course-authoring.pages-resources.progress.enable-progress.help": "بينما يعمل الطلاب على واجباتهم المنقّطة، ستظهر الدرجات تحت تبويت التقدم. يحتوي تبويب التقدّم مخططًا لجميع الواجبات المنقّطة في المساق، مع قائمة بجميع الواجبات و الدرجات أدناه.",
"course-authoring.pages-resources.progress.enable-progress.link": "معرفة المزيد عن التقدم",
"course-authoring.pages-resources.progress.enable-graph.label": "تفعيل مخطط التقدم البياني",
"course-authoring.pages-resources.progress.enable-graph.help": "عند التفعيل، سيستطيع الطلاب رؤية مخطط التقدم البياني",
"authoring.pagesAndResources.teams.heading": "تهيئة الفِرق",
"authoring.pagesAndResources.teams.enableTeams.label": "الفِرق",
"authoring.pagesAndResources.teams.enableTeams.help": "تتيح للمتعلمين العمل معًا على مشاريع أو أنشطة محددة.",
"authoring.pagesAndResources.teams.enableTeams.link": "معرفة المزيد عن الفِرق",
"authoring.pagesAndResources.teams.teamSize.heading": "حجم الفريق",
"authoring.pagesAndResources.teams.teamSize.maxTeamSize": "الحجم الأقصى للفريق",
"authoring.pagesAndResources.teams.teamSize.maxTeamSizeHelp": "العدد الأقصى من المتعلمين القادرين على الانضمام لفريق ما.",
"authoring.pagesAndResources.teams.teamSize.maxTeamSizeEmpty": "أدخل أقصى حجم للفريق",
"authoring.pagesAndResources.teams.teamSize.maxTeamSizeInvalid": "يجب أن يكون الحجم الأقصى للفريق عددًا موجبًا أكبر من الصفر.",
"authoring.pagesAndResources.teams.teamSize.maxTeamSizeTooHigh": "لا يمكن للحجم الأقصى للفريق أن يفوق {max}",
"authoring.pagesAndResources.teams.groups.heading": "المجموعات",
"authoring.pagesAndResources.teams.groups.help": "المجموعات فضاءات يمكن للمتعلمين فيها إنشاء فرق أو الانضمام إليها.",
"authoring.pagesAndResources.teams.configureGroup.heading": "تهيئة المجموعة",
"authoring.pagesAndResources.teams.group.name.label": "الاسم",
"authoring.pagesAndResources.teams.group.name.help": "اختر اسمًا فريدًا لهذه المجموعة",
"authoring.pagesAndResources.teams.group.name.error.empty": "أدخل اسمًا فريدًا لهذه المجموعة",
"authoring.pagesAndResources.teams.group.name.error.exists": "يبدو أن هذا الاسم مستخدم من قبل",
"authoring.pagesAndResources.teams.group.description.label": "الوصف",
"authoring.pagesAndResources.teams.group.description.help": "أدخل تفاصيل عن هذه المجموعة",
"authoring.pagesAndResources.teams.group.description.error": "أدخل وصفا لهذه المجموعة",
"authoring.pagesAndResources.teams.group.type.label": "النوع",
"authoring.pagesAndResources.teams.group.type.help": "تحكم في من يستطيع رؤية و إنشاء الفرق و الانضمام إليها.",
"authoring.pagesAndResources.teams.group.types.open": "مفتوحة",
"authoring.pagesAndResources.teams.group.types.open.description": "يستطيع المتعلمون إنشاء فرق، الانضمام للفرق و مغادرتها، و أيضا مشاهدة الفرق الأخرى",
"authoring.pagesAndResources.teams.group.types.public_managed": "عامة خاضعة للإدارة",
"authoring.pagesAndResources.teams.group.types.public_managed.description": "وحده طاقم المساق مخول بالتحكم في الفرق و العضويات. يمكن للمتعلمين رؤية الفرق الأخرى.",
"authoring.pagesAndResources.teams.group.types.private_managed": "خاصة خاضعة للإدارة",
"authoring.pagesAndResources.teams.group.types.private_managed.description": "وحده طاقم المساق مخول بالتحكم في الفرق و العضويات و كذا رؤية الفرق الأخرى.",
"authoring.pagesAndResources.teams.group.maxSize.label": "الحجم الأقصى للفريق (اختياري)",
"authoring.pagesAndResources.teams.group.maxSize.help": "تجاوز الحجم الأقصى العام للفريق",
"authoring.pagesAndResources.teams.addGroup.button": "إضافة مجموعة",
"authoring.pagesAndResources.teams.group.delete": "حذف",
"authoring.pagesAndResources.teams.group.expand": "توسيع محرر المجموعة",
"authoring.pagesAndResources.teams.group.collapse": "إغلاق محرر المجموعة",
"authoring.pagesAndResources.teams.deleteGroup.initiateDelete": "حذف",
"authoring.pagesAndResources.teams.deleteGroup.cancel-delete.button": "إلغاء",
"authoring.pagesAndResources.teams.deleteGroup.heading": "هل تريد حذف هذه المجموعة؟",
"authoring.pagesAndResources.teams.deleteGroup.body": "ننصح في edX بعدم حذف المجموعات بعد انطلاق مساقك. حينها لن تكون مجموعتك مرئية في LMS و لن يستطيع المتعلمون مغادرة الفرق المرتبطة بها. يرجى حذف المتعلمين من الفرق قبل حذف المجموعة المرتبطة بها.",
"authoring.pagesAndResources.teams.enableGroups.error.noGroupsFound.title": "لا توجد مجموعات",
"authoring.pagesAndResources.teams.enableGroups.error.noGroupsFound.message": "أضف مجموعة واحدة أو أكثر لتمكين الفرق.",
"course-authoring.pages-resources.wiki.heading": "تهيئة الويكي",
"course-authoring.pages-resources.wiki.enable-wiki.label": "الويكي",
"course-authoring.pages-resources.wiki.enable-wiki.help": "يمكن تهيئة الويكي بناءً على احتياجات المساق. من بين الاستخدامات الشائعة: مشاركة اجوبة الأسئلة الشائعة في المساق، مشاركة معلومات المساق القابلة للتعديل، و إتاحة الوصول إلى الموارد التي ينشئها المتعلمون.",
"course-authoring.pages-resources.wiki.enable-wiki.link": "معرفة المزيد عن الويكي",
"course-authoring.pages-resources.wiki.enable-public-wiki.label": "تفعيل الوصول العام",
"course-authoring.pages-resources.wiki.enable-public-wiki.help": "إن تم التفعيل، فسيستطيع مستخدمو edX عرض ويكي المساق حتى وإن كانوا غير مسجلين في المساق.",
"authoring.examsettings.enableproctoredexams.help": "في حال التأشير، يتم تفعيل الامتحانات المراقبة في مساقك.",
"authoring.examsettings.allowoptout.label": "السماح بالخروج من الامتحانات المُراقبة",
"authoring.examsettings.allowoptout.help": "\n If this value is \"Yes\", learners can choose to take proctored exams without proctoring.\n If this value is \"No\", all learners must take the exam with proctoring.\n ",
"authoring.examsettings.allowoptout.help": "\nإن كانت هذه القيمة \"نعم\"، فيمكن للمتعلمبن أن يختاروا اجتياز الامتحان المراقب من غير مراقبة.\nإن كانت هذه القيمة \"لا\"، فسيكون عن جميع المتعلمين اجتياز الامتحان بالمراقبة.",
"authoring.examsettings.provider.label": "موفر المراقبة",
"authoring.examsettings.escalationemail.label": "البريد الإلكتروني لتصعيد مسار المراقبة",
"authoring.examsettings.escalationemail.help": "\n Required if \"proctortrack\" is selected as your proctoring provider. Enter an email address to be\n contacted by the support team whenever there are escalations (e.g. appeals, delayed reviews, etc.).\n ",
"authoring.examsettings.escalationemail.help": "\nمطلوب إن تم تحديد \"Proctortrack\" كمزود المراقبة الخاص بك. أدخل عنوان بريد إلكتروني كي يتصل بك فريق الدعم حينما يكون هنالك تصعيد (مثلا: طلبات الاستئناف و المراجعات المتأخرة، و غيرها) من فريق الدعم.",
"authoring.examsettings.createzendesk.label": "أنشئ تذاكر Zendesk لمحاولات الاختبار المشبوهة",
"authoring.examsettings.createzendesk.help": "إذا كانت هذه القيمة \"نعم\"، فسيتم إنشاء تذكرة Zendesk لمحاولات الاختبار المراقبة المشتبه فيها.",
"authoring.examsettings.submit": "إرسال",
"authoring.examsettings.alert.success": "\nتم حفظ إعدادات الاختبار Proctored بنجاح.\nيمكنك العودة إلى مقرّرك التعليمي في الاستوديو {studioCourseRunURL}",
"authoring.examsettings.alert.success": "\nتم حفظ إعدادات الاختبار Proctored بنجاح.\nيمكنك الرجوع إلى مساقك في الاستوديو {studioCourseRunURL}.",
"authoring.examsettings.allowoptout.no": "لا",
"authoring.examsettings.allowoptout.yes": "نعم",
"authoring.examsettings.createzendesk.no": "لا",
"authoring.examsettings.createzendesk.yes": "نعم",
"authoring.examsettings.support.text": "Support Page",
"authoring.examsettings.support.text": "صفحة الدعم",
"authoring.examsettings.escalationemail.enableproctoredexams.label": "تفعيل الامتحانات المراقَبة",
"authoring.examsettings.escalationemail.error.blank": "لا يمكن أن يكون حقل البريد الإلكتروني الخاص بتصعيد ProctorTrack فارغًا إذا كانمسار المراقبة هو المزود المحدد.",
"authoring.examsettings.escalationemail.error.invalid": "إن حقل البريد الإلكتروني للتصعيد في مسار المراقبة بتنسيق غير صحيح وغير صالح.",
"authoring.examsettings.error.single": "يوجد خطأ واحد في هذا النموذج.",
"authoring.examsettings.escalationemail.error.multiple": "يوجد {numOfErrors} خطأ في هذا النموذج.",
"authoring.examsettings.provider.help": "حدد مزود المراقبة الذي تريد استخدامه لتشغيل هذا المساق.",
"authoring.examsettings.escalationemail.error.multiple": "{numOfErrors, plural,\nzero {لا يوجد خطأ}\none {يوجد خطأ واحد}\ntwo {يوجد خطآن}\nfew {توجد # أخطا}\nmany {يوجد # خطأً}\nother {يوجد # خطإٍ}\n} في هذا النموذج.",
"authoring.examsettings.provider.help": "حدد مزود المراقبة الذي تريد استخدامه لطبعة المساق هذه.",
"authoring.examsettings.provider.help.aftercoursestart": "لا يمكن تعديل مزود المعالجة بعد تاريخ بدء المساق.",
"header.links.content": "المحتوى",
"header.links.settings": "إعدادات الحساب",
"header.links.settings": "الإعدادات",
"header.links.content.tools": "الأدوات",
"header.links.outline": "المخطّط الكلّي",
"header.links.outline": "المخطط الكلّي",
"header.links.updates": "التحديثات",
"header.links.pages": "Pages & Resources",
"header.links.filesAndUploads": "الملفات والتحميل ",
"header.links.pages": "الصفحات و الموارد",
"header.links.filesAndUploads": "الملفات و التحميل ",
"header.links.textbooks": "الكتب",
"header.links.videoUploads": "تحميلات الفيديو",
"header.links.scheduleAndDetails": "وضع جدول لوحدة",
"header.links.grading": "التقييم",
"header.links.videoUploads": "الفيديوهات المرفوعة",
"header.links.scheduleAndDetails": "المواعيد و التفاصيل",
"header.links.grading": "التنقيط",
"header.links.courseTeam": "فريق المساق",
"header.links.groupConfigurations": "إعدادات المجموعة",
"header.links.proctoredExamSettings": "إعدادات الامتحان المراقب",
"header.links.groupConfigurations": "تهيئة المجموعات",
"header.links.proctoredExamSettings": "إعدادات الامتحانات المراقبة",
"header.links.advancedSettings": "الإعدادات المتقدمة",
"header.links.certificates": "الشهادات",
"header.links.publisher": "ناشر",
"header.links.import": "استيراد",
"header.links.export": "تصدير",
"header.links.checklists": "قوائم المراجعة",
"header.user.menu.studio": "صفحة استوديو الرئيسية",
"header.user.menu.maintenance": "صيانة",
"header.user.menu.logout": "خروج",
"header.links.publisher": "الناشر",
"header.links.import": "الاستيراد",
"header.links.export": "التصدير",
"header.links.checklists": "قوائم التدقيق",
"header.user.menu.studio": "صفحة الاستوديو الرئيسية",
"header.user.menu.maintenance": "الصيانة",
"header.user.menu.logout": "تسجيل الخروج",
"header.label.account.menu": "قائمة الحساب",
"header.label.account.menu.for": "قائمة الحساب للمستخدم {username}",
"header.label.main.nav": "الرئيسية",
"header.label.main.menu": "القائمة الرئيسية",
"header.label.main.header": "الرئيسية",
"header.label.secondary.nav": "ثانوي",
"header.label.courseOutline": "العودة إلى مخطط المساق في استوديو"
"header.label.secondary.nav": "الثانوية",
"header.label.courseOutline": "الرجوع إلى مخطط المساق الكلّي في الاستوديو"
}

View File

@@ -62,14 +62,10 @@
"authoring.discussions.builtIn.divideGeneralTopic.label": "General",
"authoring.discussions.builtIn.divideQuestionsForTAsTopic.label": "Preguntas para las herramientas asistidas",
"authoring.discussions.builtIn.visibilityInContext": "visualización de las discusioness en contexto",
"authoring.discussions.builtIn.inContextDiscussion.label": "discusiones en contexto",
"authoring.discussions.builtIn.inContextDiscussion.help": "Los estudiantes podrán ver u ocultar el panel de discusiones para participar en la discusión de la unidad de página del curso.",
"authoring.discussions.builtIn.gradedUnitPages.label": "Unidades de página calificadas",
"authoring.discussions.builtIn.gradedUnitPages.label": "Habilitar discusiones en unidades de subsecciones calificables",
"authoring.discussions.builtIn.gradedUnitPages.help": "Permítele a los estudiantes participar con discusiones en todas las unidades de página calificables con excepción de los exámenes cronometrados.",
"authoring.discussions.builtIn.groupInContextSubsection.label": "Grupo en la discusión de contexto en el nivel de subseción",
"authoring.discussions.builtIn.groupInContextSubsection.help": "Los estudiantes podrán ver cualquier publicación en la sub-sección sin importar en que unidad de página se encuentren visualizando. Mientras esto no sea recomendado, si tu curso cuenta con pocas secuencias de aprendizaje o bajo número de inscripciones, hacer grupos puede incrementar la participación.",
"authoring.discussions.builtIn.allowUnitLevelVisibility.label": "Permite la visibilidad de la configuración para cada unidad de curso",
"authoring.discussions.builtIn.allowUnitLevelVisibility.help": "Con esta configuración avanzada habilitada podrás anular la visibilidad global de la configuración y encender o apagar las discusiones para cada unidad desde la vista de esquema de curso.",
"authoring.discussions.builtIn.anonymousPosting": "Publicación anonima",
"authoring.discussions.builtIn.allowAnonymous.label": "Habilita publicaciones anonimas en las discusiones",
"authoring.discussions.builtIn.allowAnonymous.help": "Si es permitido, los estudiantes puedrán crear publicaciones anonimas para todos los usuarios.",
@@ -183,22 +179,23 @@
"course-authoring.pages-resources.notes.enable-notes.label": "Notas",
"course-authoring.pages-resources.notes.enable-notes.help": "Los estudiantes pueden acceder a las notas ya sea en el cuerpo del \n curso en una página de notas. En la página de notas, el estudiante puede ver todas las\n notas realizadas durante el curso. La página también contiene enlaces a la ubicación\n de las notas en el cuerpo del curso.",
"course-authoring.pages-resources.notes.enable-notes.link": "Aprende más acerca de las notas",
"authoring.live.bbb.selectPlan.label": "Seleccione un plan",
"authoring.pagesAndResources.live.enableLive.heading": "Configurar en vivo",
"authoring.pagesAndResources.live.enableLive.label": "Live",
"authoring.pagesAndResources.live.enableLive.label": "En vivo",
"authoring.pagesAndResources.live.enableLive.help": "Programe reuniones y realice sesiones de cursos en vivo con los alumnos.",
"authoring.pagesAndResources.live.enableLive.link": "Learn more about live",
"authoring.pagesAndResources.live.enableLive.link": "Más información sobre el vivo",
"authoring.live.selectProvider": "Seleccione una herramienta de videoconferencia",
"authoring.live.formInstructions": "Complete los campos a continuación para configurar su herramienta de videoconferencia.",
"authoring.live.consumerKey": "Consumidor Clave",
"authoring.live.consumerKey.required": "Consumidor clave es requerido en este campo",
"authoring.live.consumerSecret": "Consumer Secret",
"authoring.live.consumerSecret": "Consumidor Secreto",
"authoring.live.consumerSecret.required": "Consumidor secreto es requerido en este campo",
"authoring.live.launchUrl": "Lanzamiento URL",
"authoring.live.launchUrl.required": "Lanzamiento URL es requerida para este campo",
"authoring.live.launchEmail": "Correo electrónico de lanzamiento",
"authoring.live.launchEmail.required": "El correo electrónico de lanzamiento es un campo obligatorio",
"authoring.live.provider.helpText": "This configuration will require sharing username and emails of learners and the course team with {providerName}.",
"authoring.live.requestPiiSharingEnable": "This configuration will require sharing usernames and emails of learners and the course team with {provider}. To access the LTI configuration for {provider}, please request your edX project coordinator to get PII sharing enabled for this course.",
"authoring.live.provider.helpText": "Esta configuración requerirá compartir el nombre de usuario y los correos electrónicos de los alumnos, además del equipo del curso con {providerName}.",
"authoring.live.requestPiiSharingEnable": "Esta configuración requerirá compartir los nombres de usuario y los correos electrónicos de los alumnos, además del equipo del curso con {provider}. Para acceder a la configuración de LTI para {provider}, solicite a su coordinador de proyectos de edX que habilite el uso compartido de PII para este curso.",
"authoring.live.appDocInstructions.documentationLink": "Documentación general ",
"authoring.live.appDocInstructions.accessibilityDocumentationLink": "Documentación de accesibilidad ",
"authoring.live.appDocInstructions.configurationLink": "Documentación de configuración",
@@ -208,6 +205,10 @@
"authoring.live.appName-yellowdig": "Zoom",
"authoring.live.appName-googleMeet": "Reunión de Google",
"authoring.live.appName-microsoftTeams": "Equipos de Microsoft",
"authoring.live.appName-bigBlueButton": "GranBotónAzul",
"authoring.live.requestPiiSharingEnableForBbb": "Esta configuración requerirá compartir los nombres de usuario de los alumnos y el equipo del curso con {provider}.",
"authoring.live.piiSharingEnableHelpText": "Para habilitar esta función, comuníquese con el equipo de soporte de edX para habilitar el uso compartido de PII para este curso.",
"authoring.live.freePlanMessage": "El plan gratuito está preconfigurado y no se requieren configuraciones adicionales.",
"course-authoring.pages-resources.heading": "Páginas & Recursos",
"course-authoring.pages-resources.resources.settings.button": "configuraciones",
"course-authoring.pages-resources.viewLive.button": "Ver en vivo",

View File

@@ -62,14 +62,10 @@
"authoring.discussions.builtIn.divideGeneralTopic.label": "Général",
"authoring.discussions.builtIn.divideQuestionsForTAsTopic.label": "Questions pour les assistants d'enseignement",
"authoring.discussions.builtIn.visibilityInContext": "Visibilité des discussions en contexte",
"authoring.discussions.builtIn.inContextDiscussion.label": "Discussion en contexte",
"authoring.discussions.builtIn.inContextDiscussion.help": "Les apprenants pourront afficher ou masquer un panneau latéral de discussion pour participer à la discussion sur la page de l'unité de cours.",
"authoring.discussions.builtIn.gradedUnitPages.label": "Pages d'unités notées",
"authoring.discussions.builtIn.gradedUnitPages.label": "Enable discussions on units in graded subsections",
"authoring.discussions.builtIn.gradedUnitPages.help": "Permettez aux apprenants de participer à la discussion sur toutes les pages d'unités notées, à l'exception des examens chronométrés.",
"authoring.discussions.builtIn.groupInContextSubsection.label": "Discussion de groupe en contexte au niveau des sous-sections",
"authoring.discussions.builtIn.groupInContextSubsection.help": "Les apprenants pourront voir n'importe quel article de la sous-section, quelle que soit la page d'unité qu'ils consultent. Bien que cela ne soit pas recommandé, si votre cours comporte de courtes séquences d'apprentissage ou un faible regroupement d'inscription cela peut augmenter l'engagement.",
"authoring.discussions.builtIn.allowUnitLevelVisibility.label": "Autoriser la configuration de la visibilité pour chaque unité de cours",
"authoring.discussions.builtIn.allowUnitLevelVisibility.help": "Avec ce paramètre avancé activé, vous pourrez remplacer le paramètre de visibilité globale et activer ou désactiver les discussions pour chaque unité à partir de la vue d'ensemble du cours.",
"authoring.discussions.builtIn.anonymousPosting": "Publication anonyme",
"authoring.discussions.builtIn.allowAnonymous.label": "Autoriser les posts de discussion anonymes",
"authoring.discussions.builtIn.allowAnonymous.help": "Si activé, les apprenants pourront créer des publications qui resteront anonymes à tous les utilisateurs.",
@@ -183,6 +179,7 @@
"course-authoring.pages-resources.notes.enable-notes.label": "Notes",
"course-authoring.pages-resources.notes.enable-notes.help": "Les apprenants peuvent accéder à leurs notes à partir du\n corps du cours ou une page de notes. Sur la page de notes, un apprenant peut voir toutes les\n notes conçues durant le cours. La page contient également les liens vers la location\n des notes dans le corps du cours.",
"course-authoring.pages-resources.notes.enable-notes.link": "Apprenez en plus sur notes",
"authoring.live.bbb.selectPlan.label": "Select a plan",
"authoring.pagesAndResources.live.enableLive.heading": "Configurer en direct",
"authoring.pagesAndResources.live.enableLive.label": "Live",
"authoring.pagesAndResources.live.enableLive.help": "Planifiez des réunions et organisez des sessions de cours en direct avec les apprenants.",
@@ -208,6 +205,10 @@
"authoring.live.appName-yellowdig": "Zoom",
"authoring.live.appName-googleMeet": "Google Meet",
"authoring.live.appName-microsoftTeams": "Microsoft Teams",
"authoring.live.appName-bigBlueButton": "BigBlueButton",
"authoring.live.requestPiiSharingEnableForBbb": "This configuration will require sharing usernames of learners and the course team with {provider}.",
"authoring.live.piiSharingEnableHelpText": "To enable this feature, contact your edX support team to enable PII sharing for this course.",
"authoring.live.freePlanMessage": "The free plan is pre-configured, and no additional configurations are required.",
"course-authoring.pages-resources.heading": "Pages et ressources",
"course-authoring.pages-resources.resources.settings.button": "paramètres",
"course-authoring.pages-resources.viewLive.button": "Aperçu temps réel",

View File

@@ -62,14 +62,10 @@
"authoring.discussions.builtIn.divideGeneralTopic.label": "General",
"authoring.discussions.builtIn.divideQuestionsForTAsTopic.label": "Questions for the TAs",
"authoring.discussions.builtIn.visibilityInContext": "Visibility of in-context discussions",
"authoring.discussions.builtIn.inContextDiscussion.label": "In-context discussion",
"authoring.discussions.builtIn.inContextDiscussion.help": "Learners will be able to view or hide a discussion side panel to engage with discussion on the course unit page.",
"authoring.discussions.builtIn.gradedUnitPages.label": "Graded unit pages",
"authoring.discussions.builtIn.gradedUnitPages.label": "Enable discussions on units in graded subsections",
"authoring.discussions.builtIn.gradedUnitPages.help": "Allow learners to engage with discussion on all graded unit pages except timed exams.",
"authoring.discussions.builtIn.groupInContextSubsection.label": "Group in context discussion at the subsection level",
"authoring.discussions.builtIn.groupInContextSubsection.help": "Learners will be able to view any post in the sub-section no matter which unit page they are viewing. While this is not recommended, if your course has short learning sequences or low enrollment grouping may increase engagement.",
"authoring.discussions.builtIn.allowUnitLevelVisibility.label": "Allow visibility configuration for each course unit",
"authoring.discussions.builtIn.allowUnitLevelVisibility.help": "With this advanced setting enabled you will be able to override the global visibility setting and turn discussions on or off for each unit from the course outline view..",
"authoring.discussions.builtIn.anonymousPosting": "Anonymous posting",
"authoring.discussions.builtIn.allowAnonymous.label": "Allow anonymous discussion posts",
"authoring.discussions.builtIn.allowAnonymous.help": "If enabled, learners can create posts that are anonymous to all users.",
@@ -183,6 +179,7 @@
"course-authoring.pages-resources.notes.enable-notes.label": "Notes",
"course-authoring.pages-resources.notes.enable-notes.help": "Learners can access their notes either in the body of the\n course of on a notes page. On the notes page, a learner can see all the\n notes made during the course. The page also contains links to the location\n of the notes in the course body.",
"course-authoring.pages-resources.notes.enable-notes.link": "Learn more about notes",
"authoring.live.bbb.selectPlan.label": "Select a plan",
"authoring.pagesAndResources.live.enableLive.heading": "Configure Live",
"authoring.pagesAndResources.live.enableLive.label": "Live",
"authoring.pagesAndResources.live.enableLive.help": "Schedule meetings and conduct live course sessions with learners.",
@@ -208,6 +205,10 @@
"authoring.live.appName-yellowdig": "Zoom",
"authoring.live.appName-googleMeet": "Google Meet",
"authoring.live.appName-microsoftTeams": "Microsoft Teams",
"authoring.live.appName-bigBlueButton": "BigBlueButton",
"authoring.live.requestPiiSharingEnableForBbb": "This configuration will require sharing usernames of learners and the course team with {provider}.",
"authoring.live.piiSharingEnableHelpText": "To enable this feature, contact your edX support team to enable PII sharing for this course.",
"authoring.live.freePlanMessage": "The free plan is pre-configured, and no additional configurations are required.",
"course-authoring.pages-resources.heading": "Pages & Resources",
"course-authoring.pages-resources.resources.settings.button": "settings",
"course-authoring.pages-resources.viewLive.button": "View live",

View File

@@ -16,10 +16,12 @@ import appMessages from './i18n';
import initializeStore from './store';
import './index.scss';
import CourseAuthoringRoutes from './CourseAuthoringRoutes';
import Head from './head/Head';
subscribe(APP_READY, () => {
ReactDOM.render(
<AppProvider store={initializeStore()}>
<Head />
<Switch>
<Route
path="/course/:courseId"
@@ -47,6 +49,7 @@ initialize({
SUPPORT_URL: process.env.SUPPORT_URL || null,
SUPPORT_EMAIL: process.env.SUPPORT_EMAIL || null,
LEARNING_BASE_URL: process.env.LEARNING_BASE_URL,
EXAMS_BASE_URL: process.env.EXAMS_BASE_URL || null,
CALCULATOR_HELP_URL: process.env.CALCULATOR_HELP_URL || null,
ENABLE_PROGRESS_GRAPH_SETTINGS: process.env.ENABLE_PROGRESS_GRAPH_SETTINGS || 'false',
ENABLE_TEAM_TYPE_SETTING: process.env.ENABLE_TEAM_TYPE_SETTING === 'true',

View File

@@ -8,10 +8,9 @@ ensureConfig([
'STUDIO_BASE_URL',
], 'Course Apps API service');
const apiBaseUrl = getConfig().STUDIO_BASE_URL;
const courseAppsApiUrl = `${apiBaseUrl}/api/course_apps/v1/apps`;
const courseAdvancedSettingsApiUrl = `${apiBaseUrl}/api/contentstore/v0/advanced_settings`;
const getApiBaseUrl = () => getConfig().STUDIO_BASE_URL;
const getCourseAppsApiUrl = () => `${getApiBaseUrl()}/api/course_apps/v1/apps`;
const getCourseAdvancedSettingsApiUrl = () => `${getApiBaseUrl()}/api/contentstore/v0/advanced_settings`;
/**
* Fetches the course apps installed for provided course
@@ -20,7 +19,7 @@ const courseAdvancedSettingsApiUrl = `${apiBaseUrl}/api/contentstore/v0/advanced
*/
export async function getCourseApps(courseId) {
const { data } = await getAuthenticatedHttpClient()
.get(`${courseAppsApiUrl}/${courseId}`);
.get(`${getCourseAppsApiUrl()}/${courseId}`);
return camelCaseObject(data);
}
@@ -33,7 +32,7 @@ export async function getCourseApps(courseId) {
export async function updateCourseApp(courseId, appId, state) {
await getAuthenticatedHttpClient()
.patch(
`${courseAppsApiUrl}/${courseId}`,
`${getCourseAppsApiUrl()}/${courseId}`,
{
id: appId,
enabled: state,
@@ -49,7 +48,7 @@ export async function updateCourseApp(courseId, appId, state) {
*/
export async function getCourseAdvancedSettings(courseId, settings) {
const { data } = await getAuthenticatedHttpClient()
.get(`${courseAdvancedSettingsApiUrl}/${courseId}`, { filter_fields: settings.map(snakeCase).join(',') });
.get(`${getCourseAdvancedSettingsApiUrl()}/${courseId}`, { filter_fields: settings.map(snakeCase).join(',') });
return camelCaseObject(data);
}
@@ -62,6 +61,6 @@ export async function getCourseAdvancedSettings(courseId, settings) {
*/
export async function updateCourseAdvancedSettings(courseId, setting, value) {
const { data } = await getAuthenticatedHttpClient()
.patch(`${courseAdvancedSettingsApiUrl}/${courseId}`, { [snakeCase(setting)]: { value } });
.patch(`${getCourseAdvancedSettingsApiUrl()}/${courseId}`, { [snakeCase(setting)]: { value } });
return camelCaseObject(data);
}

View File

@@ -1,3 +1,4 @@
import ReactDOM from 'react-dom';
import {
getConfig, history, initializeMockApp, setConfig,
} from '@edx/frontend-platform';
@@ -42,6 +43,9 @@ let axiosMock;
let store;
let container;
// Modal creates a portal. Overriding ReactDOM.createPortal allows portals to be tested in jest.
ReactDOM.createPortal = jest.fn(node => node);
function renderComponent() {
const wrapper = render(
<AppProvider store={store}>

View File

@@ -25,16 +25,16 @@ function OpenedXConfigForm({
onSubmit, formRef, intl, legacy,
}) {
const {
selectedAppId, enableInContext, enableGradedUnits, unitLevelVisibility, discussionTopicIds, divideDiscussionIds,
selectedAppId, enableGradedUnits, discussionTopicIds, divideDiscussionIds,
} = useSelector(state => state.discussions);
const appConfigObj = useModel('appConfigs', selectedAppId);
const discussionTopicsModel = useModels('discussionTopics', discussionTopicIds);
const legacyAppConfig = {
...(appConfigObj || {}),
divideDiscussionIds,
enableInContext,
enableInContext: true,
enableGradedUnits,
unitLevelVisibility,
unitLevelVisibility: true,
allowAnonymousPostsPeers: appConfigObj?.allowAnonymousPostsPeers || false,
reportedContentEmailNotifications: appConfigObj?.reportedContentEmailNotifications || false,
enableReportedContentEmailNotifications: appConfigObj?.enableReportedContentEmailNotifications || false,
@@ -48,10 +48,8 @@ function OpenedXConfigForm({
const [validDiscussionTopics, setValidDiscussionTopics] = useState(discussionTopicsModel);
// These fields are only used for the new provider and aren't supported for legacy.
const additionalFields = legacy ? {} : {
enableInContext: Yup.bool().default(true),
enabledGradedUnits: Yup.bool().default(false),
groupAtSubsection: Yup.bool().default(false),
unitLevelVisibility: Yup.bool().default(false),
};
const validationSchema = Yup.object().shape({
blackoutDates: Yup.array(

View File

@@ -41,9 +41,9 @@ const defaultAppConfig = (divideDiscussionIds = []) => ({
],
divideDiscussionIds,
enableGradedUnits: undefined,
enableInContext: undefined,
enableInContext: true,
groupAtSubsection: false,
unitLevelVisibility: undefined,
unitLevelVisibility: true,
allowAnonymousPosts: false,
allowAnonymousPostsPeers: false,
reportedContentEmailNotifications: false,
@@ -109,7 +109,6 @@ describe('OpenedXConfigForm', () => {
expect(queryByText(container, messages.visibilityInContext.defaultMessage)).toBeInTheDocument();
expect(queryByText(container, messages.gradedUnitPagesLabel.defaultMessage)).toBeInTheDocument();
expect(queryByText(container, messages.groupInContextSubsectionLabel.defaultMessage)).toBeInTheDocument();
expect(queryByText(container, messages.allowUnitLevelVisibilityLabel.defaultMessage)).toBeInTheDocument();
});
test('calls onSubmit when the formRef is submitted', async () => {
@@ -139,7 +138,7 @@ describe('OpenedXConfigForm', () => {
await mockStore({
...legacyApiResponse,
plugin_configuration: {
...legacyApiResponse.plugin_configuration,
...legacyApiResponse.plugin_configuration,
reported_content_email_notifications_flag: true,
divided_course_wide_discussions: [],
},

View File

@@ -1,7 +1,6 @@
import React from 'react';
import PropTypes from 'prop-types';
import { injectIntl, intlShape } from '@edx/frontend-platform/i18n';
import { TransitionReplace } from '@edx/paragon';
import FormSwitchGroup from '../../../../../generic/FormSwitchGroup';
import messages from '../../messages';
import AppConfigFormDivider from './AppConfigFormDivider';
@@ -18,48 +17,20 @@ function InContextDiscussionFields({
<FormSwitchGroup
onChange={onChange}
onBlur={onBlur}
id="enableInContext"
checked={values.enableInContext}
label={intl.formatMessage(messages.inContextDiscussionLabel)}
helpText={intl.formatMessage(messages.inContextDiscussionHelp)}
id="enableGradedUnits"
checked={values.enableGradedUnits}
label={intl.formatMessage(messages.gradedUnitPagesLabel)}
helpText={intl.formatMessage(messages.gradedUnitPagesHelp)}
/>
<AppConfigFormDivider />
<FormSwitchGroup
onChange={onChange}
onBlur={onBlur}
id="groupAtSubsection"
checked={values.groupAtSubsection}
label={intl.formatMessage(messages.groupInContextSubsectionLabel)}
helpText={intl.formatMessage(messages.groupInContextSubsectionHelp)}
/>
<TransitionReplace>
{values.enableInContext ? (
<React.Fragment key="open">
<AppConfigFormDivider />
<FormSwitchGroup
onChange={onChange}
onBlur={onBlur}
className="ml-4"
id="enableGradedUnits"
checked={values.enableGradedUnits}
label={intl.formatMessage(messages.gradedUnitPagesLabel)}
helpText={intl.formatMessage(messages.gradedUnitPagesHelp)}
/>
<AppConfigFormDivider />
<FormSwitchGroup
onChange={onChange}
onBlur={onBlur}
className="ml-4"
id="groupAtSubsection"
checked={values.groupAtSubsection}
label={intl.formatMessage(messages.groupInContextSubsectionLabel)}
helpText={intl.formatMessage(messages.groupInContextSubsectionHelp)}
/>
<AppConfigFormDivider />
<FormSwitchGroup
onChange={onChange}
onBlur={onBlur}
className="ml-4"
id="unitLevelVisibility"
checked={values.unitLevelVisibility}
label={intl.formatMessage(messages.allowUnitLevelVisibilityLabel)}
helpText={intl.formatMessage(messages.allowUnitLevelVisibilityHelp)}
/>
</React.Fragment>
) : <React.Fragment key="closed" />}
</TransitionReplace>
</>
);
}
@@ -69,10 +40,8 @@ InContextDiscussionFields.propTypes = {
onChange: PropTypes.func.isRequired,
intl: intlShape.isRequired,
values: PropTypes.shape({
enableInContext: PropTypes.bool,
enableGradedUnits: PropTypes.bool,
groupAtSubsection: PropTypes.bool,
unitLevelVisibility: PropTypes.bool,
}).isRequired,
};

View File

@@ -130,17 +130,9 @@ const messages = defineMessages({
id: 'authoring.discussions.builtIn.visibilityInContext',
defaultMessage: 'Visibility of in-context discussions',
},
inContextDiscussionLabel: {
id: 'authoring.discussions.builtIn.inContextDiscussion.label',
defaultMessage: 'In-context discussion',
},
inContextDiscussionHelp: {
id: 'authoring.discussions.builtIn.inContextDiscussion.help',
defaultMessage: 'Learners will be able to view or hide a discussion side panel to engage with discussion on the course unit page.',
},
gradedUnitPagesLabel: {
id: 'authoring.discussions.builtIn.gradedUnitPages.label',
defaultMessage: 'Graded unit pages',
defaultMessage: 'Enable discussions on units in graded subsections',
},
gradedUnitPagesHelp: {
id: 'authoring.discussions.builtIn.gradedUnitPages.help',
@@ -154,14 +146,6 @@ const messages = defineMessages({
id: 'authoring.discussions.builtIn.groupInContextSubsection.help',
defaultMessage: 'Learners will be able to view any post in the sub-section no matter which unit page they are viewing. While this is not recommended, if your course has short learning sequences or low enrollment grouping may increase engagement.',
},
allowUnitLevelVisibilityLabel: {
id: 'authoring.discussions.builtIn.allowUnitLevelVisibility.label',
defaultMessage: 'Allow visibility configuration for each course unit',
},
allowUnitLevelVisibilityHelp: {
id: 'authoring.discussions.builtIn.allowUnitLevelVisibility.help',
defaultMessage: 'With this advanced setting enabled you will be able to override the global visibility setting and turn discussions on or off for each unit from the course outline view..',
},
// Anonymous posting fields
anonymousPosting: {

View File

@@ -32,7 +32,7 @@ function normalizeLtiConfig(data) {
}
export function normalizeBlackoutDates(data) {
if (!data.length) {
if (!data || Object.keys(data).length < 1) {
return [];
}

View File

@@ -0,0 +1,121 @@
import React, { useEffect, useState } from 'react';
import { FormattedMessage, injectIntl, intlShape } from '@edx/frontend-platform/i18n';
import { Form, Hyperlink } from '@edx/paragon';
import PropTypes from 'prop-types';
import messages from './messages';
import { providerNames, bbbPlanTypes } from './constants';
import AppConfigFormDivider from '../discussions/app-config-form/apps/shared/AppConfigFormDivider';
import LiveCommonFields from './LiveCommonFields';
import { useModel } from '../../generic/model-store';
function BbbSettings({
intl,
values,
setFieldValue,
}) {
const [bbbPlan, setBbbPlan] = useState(values.tierType);
useEffect(() => {
setBbbPlan(values.tierType);
}, [values.tierType]);
const appInfo = useModel('courseApps', 'live');
const app = useModel('liveApps', 'big_blue_button');
const isPiiDisabled = !values.piiSharingEnable;
function getBbbPlanOptions() {
const options = ['Select', bbbPlanTypes.commercial];
if (app.hasFreeTier) { options.push(bbbPlanTypes.free); }
return options.map(option => (
<option
key={option}
value={option}
data-testid={option}
>
{option}
</option>
));
}
const handlePlanChange = (e) => {
setBbbPlan(e.target.value);
setFieldValue('tierType', e.target.value);
};
return (
<>
{isPiiDisabled ? (
<p data-testid="request-pii-sharing">
{intl.formatMessage(messages.requestPiiSharingEnableForBbb, { provider: providerNames[values.provider] })}
</p>
) : (
<p data-testid="helper-text">
{intl.formatMessage(messages.providerHelperText, { providerName: providerNames[values.provider] })}
</p>
)}
<Form.Group controlId="bbs-settings" data-testid="plansDropDown">
<Form.Label as="planSelector" className="h6">
<FormattedMessage
id="authoring.live.bbb.selectPlan.label"
defaultMessage="Select a plan"
description="Label for bbb plan selection"
/>
</Form.Label>
<Form.Control
as="select"
name="tierType"
value={bbbPlan}
onChange={handlePlanChange}
disabled={isPiiDisabled}
>
{getBbbPlanOptions()}
</Form.Control>
</Form.Group>
<Hyperlink
destination={appInfo.documentationLinks.learnMoreConfiguration}
target="_blank"
rel="noopener noreferrer"
showLaunchIcon
className="text-primary-500 pt-2"
>
{ intl.formatMessage(messages.learnMore, { providerName: 'plans' }) }
</Hyperlink>
<>
<AppConfigFormDivider thick marginAdj={{ default: 0, sm: 2 }} />
{isPiiDisabled ? (
<p data-testid="help-request-pii-sharing">
{intl.formatMessage(messages.piiSharingEnableHelpTextBbb)}
</p>
) : (
<>
{bbbPlan === bbbPlanTypes.commercial && <LiveCommonFields values={values} />}
{bbbPlan === bbbPlanTypes.free
&& (
<p data-testid="free-plan-message">
{intl.formatMessage(messages.freePlanMessage)}
</p>
)}
</>
)}
</>
</>
);
}
BbbSettings.propTypes = {
intl: intlShape.isRequired,
values: PropTypes.shape({
consumerKey: PropTypes.string,
consumerSecret: PropTypes.string,
launchUrl: PropTypes.string,
launchEmail: PropTypes.string,
provider: PropTypes.string,
piiSharingEmail: PropTypes.bool,
piiSharingUsername: PropTypes.bool,
piiSharingEnable: PropTypes.bool,
tierType: PropTypes.string,
}).isRequired,
setFieldValue: PropTypes.func.isRequired,
};
export default injectIntl(BbbSettings);

View File

@@ -0,0 +1,138 @@
import {
render,
queryByTestId,
getByRole,
getAllByRole,
waitForElementToBeRemoved,
} from '@testing-library/react';
import ReactDOM from 'react-dom';
import { Switch } from 'react-router-dom';
import { initializeMockApp, history } from '@edx/frontend-platform';
import MockAdapter from 'axios-mock-adapter';
import { getAuthenticatedHttpClient } from '@edx/frontend-platform/auth';
import { AppProvider, PageRoute } from '@edx/frontend-platform/react';
import { IntlProvider } from '@edx/frontend-platform/i18n';
import userEvent from '@testing-library/user-event';
import initializeStore from '../../store';
import { executeThunk } from '../../utils';
import LiveSettings from './Settings';
import {
generateLiveConfigurationApiResponse,
courseId,
initialState,
configurationProviders,
} from './factories/mockApiResponses';
import { fetchLiveConfiguration, fetchLiveProviders } from './data/thunks';
import { providerConfigurationApiUrl, providersApiUrl } from './data/api';
import messages from './messages';
import PagesAndResourcesProvider from '../PagesAndResourcesProvider';
let axiosMock;
let container;
let store;
const liveSettingsUrl = `/course/${courseId}/pages-and-resources/live/settings`;
// Modal creates a portal. Overriding ReactDOM.createPortal allows portals to be tested in jest.
ReactDOM.createPortal = jest.fn(node => node);
const renderComponent = () => {
const wrapper = render(
<IntlProvider locale="en">
<AppProvider store={store}>
<PagesAndResourcesProvider courseId={courseId}>
<Switch>
<PageRoute path={liveSettingsUrl}>
<LiveSettings onClose={() => {}} />
</PageRoute>
</Switch>
</PagesAndResourcesProvider>
</AppProvider>
</IntlProvider>,
);
container = wrapper.container;
};
const mockStore = async ({
usernameSharing = false,
emailSharing = false,
enabled = true,
piiSharingAllowed = true,
isFreeTier = false,
}) => {
const fetchProviderConfigUrl = `${providersApiUrl}/${courseId}/`;
const fetchLiveConfigUrl = `${providerConfigurationApiUrl}/${courseId}/`;
axiosMock.onGet(fetchProviderConfigUrl).reply(200, configurationProviders(emailSharing, usernameSharing, 'big_blue_button', isFreeTier));
axiosMock.onGet(fetchLiveConfigUrl).reply(200, generateLiveConfigurationApiResponse(enabled, piiSharingAllowed, 'bigBlueButton', isFreeTier));
await executeThunk(fetchLiveProviders(courseId), store.dispatch);
await executeThunk(fetchLiveConfiguration(courseId), store.dispatch);
};
describe('BBB Settings', () => {
beforeEach(async () => {
initializeMockApp({
authenticatedUser: {
userId: 3,
username: 'abc123',
administrator: false,
roles: [],
},
});
store = initializeStore(initialState);
axiosMock = new MockAdapter(getAuthenticatedHttpClient());
history.push(liveSettingsUrl);
});
test('Plan dropdown to be visible and enabled in UI', async () => {
await mockStore({ emailSharing: true });
renderComponent();
const spinner = getByRole(container, 'status');
await waitForElementToBeRemoved(spinner);
expect(queryByTestId(container, 'plansDropDown')).toBeInTheDocument();
expect(container.querySelector('select[name="tierType"]')).not.toBeDisabled();
});
test.each([[true, 3], [false, 2]])('Plan dropdown should display correct number of options', async (isFreeTier, noOfOptions) => {
await mockStore({ emailSharing: true, isFreeTier });
renderComponent();
const spinner = getByRole(container, 'status');
await waitForElementToBeRemoved(spinner);
const dropDown = queryByTestId(container, 'plansDropDown');
expect(getAllByRole(dropDown, 'option').length).toBe(noOfOptions);
});
test('Connect to support and PII sharing message is visible and plans selection is disabled, When pii sharing is disabled, ',
async () => {
await mockStore({ piiSharingAllowed: false });
renderComponent();
const spinner = getByRole(container, 'status');
await waitForElementToBeRemoved(spinner);
const requestPiiText = queryByTestId(container, 'request-pii-sharing');
const helpRequestPiiText = queryByTestId(container, 'help-request-pii-sharing');
expect(requestPiiText).toHaveTextContent(
messages.requestPiiSharingEnableForBbb.defaultMessage.replaceAll('{provider}', 'BigBlueButton'),
);
expect(helpRequestPiiText).toHaveTextContent(messages.piiSharingEnableHelpTextBbb.defaultMessage);
expect(container.querySelector('select[name="tierType"]')).toBeDisabled();
});
test('free plans message is visible when free plan is selected', async () => {
await mockStore({ emailSharing: true, isFreeTier: true });
renderComponent();
const spinner = getByRole(container, 'status');
await waitForElementToBeRemoved(spinner);
const dropDown = container.querySelector('select[name="tierType"]');
userEvent.selectOptions(
dropDown,
getByRole(dropDown, 'option', { name: 'Free' }),
);
expect(queryByTestId(container, 'free-plan-message')).toBeInTheDocument();
expect(queryByTestId(container, 'free-plan-message')).toHaveTextContent(messages.freePlanMessage.defaultMessage);
});
});

View File

@@ -0,0 +1,49 @@
import React from 'react';
import { injectIntl, intlShape } from '@edx/frontend-platform/i18n';
import PropTypes from 'prop-types';
import messages from './messages';
import FormikControl from '../../generic/FormikControl';
function LiveCommonFields({
intl,
values,
}) {
return (
<>
<p className="pb-2">{intl.formatMessage(messages.formInstructions)}</p>
<FormikControl
name="consumerKey"
value={values.consumerKey}
floatingLabel={intl.formatMessage(messages.consumerKey)}
className="pb-1"
type="input"
/>
<FormikControl
name="consumerSecret"
value={values.consumerSecret}
floatingLabel={intl.formatMessage(messages.consumerSecret)}
className="pb-1"
type="password"
/>
<FormikControl
name="launchUrl"
value={values.launchUrl}
floatingLabel={intl.formatMessage(messages.launchUrl)}
className="pb-1"
type="input"
/>
</>
);
}
LiveCommonFields.propTypes = {
intl: intlShape.isRequired,
values: PropTypes.shape({
consumerKey: PropTypes.string,
consumerSecret: PropTypes.string,
launchUrl: PropTypes.string,
launchEmail: PropTypes.string,
}).isRequired,
};
export default injectIntl(LiveCommonFields);

View File

@@ -5,16 +5,16 @@ import { SelectableBox, Icon } from '@edx/paragon';
import { injectIntl, intlShape } from '@edx/frontend-platform/i18n';
import PropTypes from 'prop-types';
import * as Yup from 'yup';
import { fetchLiveData, saveLiveConfiguration, saveLiveConfigurationAsDraft } from './data/thunks';
import { selectApp } from './data/slice';
import FormikControl from '../../generic/FormikControl';
import AppSettingsModal from '../app-settings-modal/AppSettingsModal';
import { useModel } from '../../generic/model-store';
import Loading from '../../generic/Loading';
import iconsSrc from './constants';
import { iconsSrc, bbbPlanTypes } from './constants';
import { RequestStatus } from '../../data/constants';
import messages from './messages';
import ZoomSettings from './ZoomSettings';
import BBBSettings from './BBBSettings';
function LiveSettings({
intl,
@@ -38,16 +38,29 @@ function LiveSettings({
launchEmail: appConfig?.launchEmail || '',
provider: selectedAppId || 'zoom',
piiSharingEnable: piiSharingAllowed || false,
piiSharingUsername: app?.piiSharing.username || false,
piiSharingEmail: app?.piiSharing.email || false,
piiSharingUsername: app?.piiSharing?.username || false,
piiSharingEmail: app?.piiSharing?.email || false,
tierType: appConfig?.tierType || '',
};
const validationSchema = {
enabled: Yup.boolean(),
consumerKey: Yup.string().required(intl.formatMessage(messages.consumerKeyRequired)),
consumerSecret: Yup.string().required(intl.formatMessage(messages.consumerSecretRequired)),
launchUrl: Yup.string().required(intl.formatMessage(messages.launchUrlRequired)),
launchEmail: Yup.string().required(intl.formatMessage(messages.launchEmailRequired)),
consumerKey: Yup.string().when(['provider', 'tierType'], {
is: (provider, tier) => provider === 'zoom' || (provider === 'big_blue_button' && tier === bbbPlanTypes.commercial),
then: Yup.string().required(intl.formatMessage(messages.consumerKeyRequired)),
}),
consumerSecret: Yup.string().when(['provider', 'tierType'], {
is: (provider, tier) => provider === 'zoom' || (provider === 'big_blue_button' && tier === bbbPlanTypes.commercial),
then: Yup.string().notRequired(intl.formatMessage(messages.consumerSecretRequired)),
}),
launchUrl: Yup.string().when(['provider', 'tierType'], {
is: (provider, tier) => provider === 'zoom' || (provider === 'big_blue_button' && tier === bbbPlanTypes.commercial),
then: Yup.string().required(intl.formatMessage(messages.launchUrlRequired)),
}),
launchEmail: Yup.string().when('provider', {
is: 'zoom',
then: Yup.string().required(intl.formatMessage(messages.launchEmailRequired)),
}),
};
const handleProviderChange = (providerId, setFieldValue, values) => {
@@ -103,48 +116,13 @@ function LiveSettings({
</SelectableBox>
))}
</SelectableBox.Set>
{(!values.piiSharingEnable && (values.piiSharingEmail || values.piiSharingUsername)) ? (
<p data-testid="request-pii-sharing">
{intl.formatMessage(messages.requestPiiSharingEnable, { provider: values.provider })}
</p>
) : (
<>
{(values.piiSharingEmail || values.piiSharingUsername)
&& (
<p data-testid="helper-text">
{intl.formatMessage(messages.providerHelperText, { providerName: values.provider })}
</p>
)}
<p className="pb-2">{intl.formatMessage(messages.formInstructions)}</p>
<FormikControl
name="consumerKey"
value={values.consumerKey}
floatingLabel={intl.formatMessage(messages.consumerKey)}
className="pb-1"
type="input"
{values.provider === 'zoom' ? <ZoomSettings values={values} />
: (
<BBBSettings
values={values}
setFieldValue={setFieldValue}
/>
<FormikControl
name="consumerSecret"
value={values.consumerSecret}
floatingLabel={intl.formatMessage(messages.consumerSecret)}
className="pb-1"
type="input"
/>
<FormikControl
name="launchUrl"
value={values.launchUrl}
floatingLabel={intl.formatMessage(messages.launchUrl)}
className="pb-1"
type="input"
/>
<FormikControl
name="launchEmail"
value={values.launchEmail}
floatingLabel={intl.formatMessage(messages.launchEmail)}
type="input"
/>
</>
)}
)}
</>
)}
</>

View File

@@ -10,6 +10,7 @@ import {
waitForElementToBeRemoved,
} from '@testing-library/react';
import ReactDOM from 'react-dom';
import { Switch } from 'react-router-dom';
import { initializeMockApp, history } from '@edx/frontend-platform';
import MockAdapter from 'axios-mock-adapter';
@@ -37,6 +38,9 @@ let container;
let store;
const liveSettingsUrl = `/course/${courseId}/pages-and-resources/live/settings`;
// Modal creates a portal. Overriding ReactDOM.createPortal allows portals to be tested in jest.
ReactDOM.createPortal = jest.fn(node => node);
const renderComponent = () => {
const wrapper = render(
<IntlProvider locale="en">
@@ -87,15 +91,12 @@ describe('LiveSettings', () => {
test('Live Configuration modal is visible', async () => {
renderComponent();
expect(queryByRole(container, 'dialog')).toBeVisible();
});
test('Displays "Configure Live" heading', async () => {
renderComponent();
const headingElement = queryByTestId(container, 'modal-title');
expect(headingElement).toHaveTextContent(messages.heading.defaultMessage);
});
@@ -140,76 +141,9 @@ describe('LiveSettings', () => {
expect(providers.childElementCount).toBe(2);
expect(providers).toHaveTextContent('Zoom');
expect(providers).toHaveTextContent('BigBlueButton');
expect(helperText).toHaveTextContent(
messages.providerHelperText.defaultMessage.replace('{providerName}', 'zoom'),
);
});
test('LTI fields are visible when pii sharing is enabled and email or username sharing required', async () => {
await mockStore({ emailSharing: true });
renderComponent();
const spinner = getByRole(container, 'status');
await waitForElementToBeRemoved(spinner);
const consumerKey = container.querySelector('input[name="consumerKey"]').parentElement;
const consumerSecret = container.querySelector('input[name="consumerSecret"]').parentElement;
const launchUrl = container.querySelector('input[name="launchUrl"]').parentElement;
const launchEmail = container.querySelector('input[name="launchEmail"]').parentElement;
expect(consumerKey.firstChild).toBeVisible();
expect(consumerKey.lastChild).toHaveTextContent(messages.consumerKey.defaultMessage);
expect(consumerSecret.firstChild).toBeVisible();
expect(consumerSecret.lastChild).toHaveTextContent(messages.consumerSecret.defaultMessage);
expect(launchUrl.firstChild).toBeVisible();
expect(launchUrl.lastChild).toHaveTextContent(messages.launchUrl.defaultMessage);
expect(launchEmail.firstChild).toBeVisible();
expect(launchEmail.lastChild).toHaveTextContent(messages.launchEmail.defaultMessage);
});
test(
'Only connect to support message is visible when pii sharing is disabled and email or username sharing is required',
async () => {
await mockStore({ emailSharing: true, piiSharingAllowed: false });
renderComponent();
const spinner = getByRole(container, 'status');
await waitForElementToBeRemoved(spinner);
const requestPiiText = queryByTestId(container, 'request-pii-sharing');
const consumerKey = container.querySelector('input[name="consumerKey"]');
const consumerSecret = container.querySelector('input[name="consumerSecret"]');
const launchUrl = container.querySelector('input[name="launchUrl"]');
const launchEmail = container.querySelector('input[name="launchEmail"]');
expect(requestPiiText).toHaveTextContent(
messages.requestPiiSharingEnable.defaultMessage.replaceAll('{provider}', 'zoom'),
);
expect(consumerKey).not.toBeInTheDocument();
expect(consumerSecret).not.toBeInTheDocument();
expect(launchUrl).not.toBeInTheDocument();
expect(launchEmail).not.toBeInTheDocument();
},
);
test('Provider Configuration should be displayed correctly', async () => {
const apiDefaultResponse = generateLiveConfigurationApiResponse(true, true);
await mockStore({ emailSharing: false, piiSharingAllowed: false });
renderComponent();
const spinner = getByRole(container, 'status');
await waitForElementToBeRemoved(spinner);
const consumerKey = container.querySelector('input[name="consumerKey"]');
const consumerSecret = container.querySelector('input[name="consumerSecret"]');
const launchUrl = container.querySelector('input[name="launchUrl"]');
const launchEmail = container.querySelector('input[name="launchEmail"]');
expect(consumerKey.value).toBe(apiDefaultResponse.lti_configuration.lti_1p1_client_key);
expect(consumerSecret.value).toBe(apiDefaultResponse.lti_configuration.lti_1p1_client_secret);
expect(launchUrl.value).toBe(apiDefaultResponse.lti_configuration.lti_1p1_launch_url);
expect(launchEmail.value).toBe(
apiDefaultResponse.lti_configuration.lti_config.additional_parameters.custom_instructor_email,
messages.providerHelperText.defaultMessage.replace('{providerName}', 'Zoom'),
);
});

View File

@@ -0,0 +1,54 @@
import React from 'react';
import { injectIntl, intlShape } from '@edx/frontend-platform/i18n';
import PropTypes from 'prop-types';
import messages from './messages';
import { providerNames } from './constants';
import LiveCommonFields from './LiveCommonFields';
import FormikControl from '../../generic/FormikControl';
function ZoomsSettings({
intl,
values,
}) {
return (
<>
{(!values.piiSharingEnable && (values.piiSharingEmail || values.piiSharingUsername)) ? (
<p data-testid="request-pii-sharing">
{intl.formatMessage(messages.requestPiiSharingEnable, { provider: providerNames[values.provider] })}
</p>
) : (
<>
{(values.piiSharingEmail || values.piiSharingUsername)
&& (
<p data-testid="helper-text">
{intl.formatMessage(messages.providerHelperText, { providerName: providerNames[values.provider] })}
</p>
)}
<LiveCommonFields values={values} />
<FormikControl
name="launchEmail"
value={values.launchEmail}
floatingLabel={intl.formatMessage(messages.launchEmail)}
type="input"
/>
</>
)}
</>
);
}
ZoomsSettings.propTypes = {
intl: intlShape.isRequired,
values: PropTypes.shape({
consumerKey: PropTypes.string,
consumerSecret: PropTypes.string,
launchUrl: PropTypes.string,
launchEmail: PropTypes.string,
provider: PropTypes.string,
piiSharingEmail: PropTypes.bool,
piiSharingUsername: PropTypes.bool,
piiSharingEnable: PropTypes.bool,
}).isRequired,
};
export default injectIntl(ZoomsSettings);

View File

@@ -0,0 +1,154 @@
import {
render,
queryByTestId,
getByRole,
waitForElementToBeRemoved,
} from '@testing-library/react';
import ReactDOM from 'react-dom';
import { Switch } from 'react-router-dom';
import { initializeMockApp, history } from '@edx/frontend-platform';
import MockAdapter from 'axios-mock-adapter';
import { getAuthenticatedHttpClient } from '@edx/frontend-platform/auth';
import { AppProvider, PageRoute } from '@edx/frontend-platform/react';
import { IntlProvider } from '@edx/frontend-platform/i18n';
import initializeStore from '../../store';
import { executeThunk } from '../../utils';
import LiveSettings from './Settings';
import {
generateLiveConfigurationApiResponse,
courseId,
initialState,
configurationProviders,
} from './factories/mockApiResponses';
import { fetchLiveConfiguration, fetchLiveProviders } from './data/thunks';
import { providerConfigurationApiUrl, providersApiUrl } from './data/api';
import messages from './messages';
import PagesAndResourcesProvider from '../PagesAndResourcesProvider';
let axiosMock;
let container;
let store;
const liveSettingsUrl = `/course/${courseId}/pages-and-resources/live/settings`;
// Modal creates a portal. Overriding ReactDOM.createPortal allows portals to be tested in jest.
ReactDOM.createPortal = jest.fn(node => node);
const renderComponent = () => {
const wrapper = render(
<IntlProvider locale="en">
<AppProvider store={store}>
<PagesAndResourcesProvider courseId={courseId}>
<Switch>
<PageRoute path={liveSettingsUrl}>
<LiveSettings onClose={() => {}} />
</PageRoute>
</Switch>
</PagesAndResourcesProvider>
</AppProvider>
</IntlProvider>,
);
container = wrapper.container;
};
const mockStore = async ({
usernameSharing = false,
emailSharing = false,
enabled = true,
piiSharingAllowed = true,
}) => {
const fetchProviderConfigUrl = `${providersApiUrl}/${courseId}/`;
const fetchLiveConfigUrl = `${providerConfigurationApiUrl}/${courseId}/`;
axiosMock.onGet(fetchProviderConfigUrl).reply(200, configurationProviders(emailSharing, usernameSharing));
axiosMock.onGet(fetchLiveConfigUrl).reply(200, generateLiveConfigurationApiResponse(enabled, piiSharingAllowed));
await executeThunk(fetchLiveProviders(courseId), store.dispatch);
await executeThunk(fetchLiveConfiguration(courseId), store.dispatch);
};
describe('Zoom Settings', () => {
beforeEach(async () => {
initializeMockApp({
authenticatedUser: {
userId: 3,
username: 'abc123',
administrator: false,
roles: [],
},
});
store = initializeStore(initialState);
axiosMock = new MockAdapter(getAuthenticatedHttpClient());
history.push(liveSettingsUrl);
});
test('LTI fields are visible when pii sharing is enabled and email or username sharing required', async () => {
await mockStore({ emailSharing: true });
renderComponent();
const spinner = getByRole(container, 'status');
await waitForElementToBeRemoved(spinner);
const consumerKey = container.querySelector('input[name="consumerKey"]').parentElement;
const consumerSecret = container.querySelector('input[name="consumerSecret"]').parentElement;
const launchUrl = container.querySelector('input[name="launchUrl"]').parentElement;
const launchEmail = container.querySelector('input[name="launchEmail"]').parentElement;
expect(consumerKey.firstChild).toBeVisible();
expect(consumerKey.lastChild).toHaveTextContent(messages.consumerKey.defaultMessage);
expect(consumerSecret.firstChild).toBeVisible();
expect(consumerSecret.lastChild).toHaveTextContent(messages.consumerSecret.defaultMessage);
expect(launchUrl.firstChild).toBeVisible();
expect(launchUrl.lastChild).toHaveTextContent(messages.launchUrl.defaultMessage);
expect(launchEmail.firstChild).toBeVisible();
expect(launchEmail.lastChild).toHaveTextContent(messages.launchEmail.defaultMessage);
});
test(
'Only connect to support message is visible when pii sharing is disabled and email or username sharing is required',
async () => {
await mockStore({ emailSharing: true, piiSharingAllowed: false });
renderComponent();
const spinner = getByRole(container, 'status');
await waitForElementToBeRemoved(spinner);
const requestPiiText = queryByTestId(container, 'request-pii-sharing');
const consumerKey = container.querySelector('input[name="consumerKey"]');
const consumerSecret = container.querySelector('input[name="consumerSecret"]');
const launchUrl = container.querySelector('input[name="launchUrl"]');
const launchEmail = container.querySelector('input[name="launchEmail"]');
expect(requestPiiText).toHaveTextContent(
messages.requestPiiSharingEnable.defaultMessage.replaceAll('{provider}', 'Zoom'),
);
expect(consumerKey).not.toBeInTheDocument();
expect(consumerSecret).not.toBeInTheDocument();
expect(launchUrl).not.toBeInTheDocument();
expect(launchEmail).not.toBeInTheDocument();
},
);
test('Provider Configuration should be displayed correctly', async () => {
const apiDefaultResponse = generateLiveConfigurationApiResponse(true, true);
await mockStore({ emailSharing: false, piiSharingAllowed: false });
renderComponent();
const spinner = getByRole(container, 'status');
await waitForElementToBeRemoved(spinner);
const consumerKey = container.querySelector('input[name="consumerKey"]');
const consumerSecret = container.querySelector('input[name="consumerSecret"]');
const launchUrl = container.querySelector('input[name="launchUrl"]');
const launchEmail = container.querySelector('input[name="launchEmail"]');
expect(consumerKey.value).toBe(apiDefaultResponse.lti_configuration.lti_1p1_client_key);
expect(consumerSecret.value).toBe(apiDefaultResponse.lti_configuration.lti_1p1_client_secret);
expect(launchUrl.value).toBe(apiDefaultResponse.lti_configuration.lti_1p1_launch_url);
expect(launchEmail.value).toBe(
apiDefaultResponse.lti_configuration.lti_config.additional_parameters.custom_instructor_email,
);
});
});

View File

@@ -1,9 +1,20 @@
import { GoogleMeet, MicrosoftTeams, Zoom } from '@edx/paragon/icons';
import {
GoogleMeet, MicrosoftTeams, Zoom, Bbb,
} from '@edx/paragon/icons';
const iconsSrc = {
export const iconsSrc = {
googleMeet: GoogleMeet,
microsoftTeams: MicrosoftTeams,
zoom: Zoom,
bigBlueButton: Bbb,
};
export { iconsSrc as default };
export const providerNames = {
zoom: 'Zoom',
big_blue_button: 'BigBlueButton',
};
export const bbbPlanTypes = {
free: 'Free',
commercial: 'Commercial/self-hosted',
};

View File

@@ -1,6 +1,7 @@
/* eslint-disable import/prefer-default-export */
import { ensureConfig, getConfig } from '@edx/frontend-platform';
import { getAuthenticatedHttpClient } from '@edx/frontend-platform/auth';
import { bbbPlanTypes } from '../constants';
ensureConfig([
'STUDIO_BASE_URL',
@@ -17,6 +18,7 @@ function normalizeProviders(data) {
featureIds: app.features,
name: app.name,
piiSharing: app.pii_sharing,
hasFreeTier: app.has_free_tier,
}));
return {
@@ -35,24 +37,28 @@ function normalizeLtiConfig(data) {
consumerKey: data.lti_1p1_client_key,
consumerSecret: data.lti_1p1_client_secret,
launchUrl: data.lti_1p1_launch_url,
launchEmail: data.lti_config.additional_parameters.custom_instructor_email,
launchEmail: data.lti_config?.additional_parameters?.custom_instructor_email,
tierType: data.tierType,
};
}
export function normalizeSettings(data) {
let tier;
if (data.provider_type === 'big_blue_button') {
tier = data.free_tier === true ? bbbPlanTypes.free : bbbPlanTypes.commercial;
}
return {
enabled: data.enabled,
piiSharingAllowed: data.pii_sharing_allowed,
appConfig: {
id: data.provider_type,
...normalizeLtiConfig(data.lti_configuration),
...normalizeLtiConfig({ ...data.lti_configuration, tierType: tier }),
},
};
}
export function deNormalizeSettings(data) {
const ltiConfiguration = {};
if (data.consumerKey) {
ltiConfiguration.lti_1p1_client_key = data.consumerKey;
}
@@ -62,14 +68,14 @@ export function deNormalizeSettings(data) {
if (data.launchUrl) {
ltiConfiguration.lti_1p1_launch_url = data.launchUrl;
}
if (data.launchEmail) {
ltiConfiguration.lti_config = {
additional_parameters: {
if (data?.provider === 'zoom' || data.tierType !== 'Free') {
ltiConfiguration.lti_config = {};
if (data.launchEmail) {
ltiConfiguration.lti_config.additional_parameters = {
custom_instructor_email: data.launchEmail,
},
};
};
}
}
if (Object.keys(ltiConfiguration).length > 0) {
// Only add this in if we're sending LTI fields.
// TODO: Eventually support LTI v1.3 here.
@@ -78,9 +84,10 @@ export function deNormalizeSettings(data) {
const apiData = {
enabled: data?.enabled || false,
lti_configuration: ltiConfiguration,
lti_configuration: Object.keys(ltiConfiguration).length ? ltiConfiguration : undefined,
provider_type: data?.provider || 'zoom',
pii_sharing_allowed: data?.piiSharingEnable || false,
free_tier: data?.provider === 'zoom' ? false : Boolean(data.tierType === 'Free'),
};
return apiData;
}
@@ -105,7 +112,6 @@ export async function getLiveProviders(courseId) {
export async function getLiveConfiguration(courseId) {
const { data } = await getAuthenticatedHttpClient()
.get(`${providerConfigurationApiUrl}/${courseId}/`);
return normalizeSettings(data);
}
@@ -114,6 +120,5 @@ export async function postLiveConfiguration(courseId, config) {
`${providerConfigurationApiUrl}/${courseId}/`,
deNormalizeSettings(config),
);
return normalizeSettings(data);
}

View File

@@ -36,9 +36,11 @@ export const initialState = {
export const configurationProviders = (
emailSharing,
usernameSharing,
activeProvider = 'zoom',
hasFreeTier,
) => ({
providers: {
active: 'zoom',
active: activeProvider,
available: {
zoom: {
features: [],
@@ -48,13 +50,12 @@ export const configurationProviders = (
username: usernameSharing,
},
},
googleMeet: {
big_blue_button: {
additional_parameters: [],
features: [],
name: 'Google Meet',
pii_sharing: {
email: true,
username: true,
},
has_free_tier: hasFreeTier,
name: 'Big Blue Button',
},
},
},
@@ -63,6 +64,8 @@ export const configurationProviders = (
export const generateLiveConfigurationApiResponse = (
enabled,
piiSharingAllowed,
providerType = 'zoom',
isFreeTier,
) => ({
course_key: courseId,
enabled,
@@ -78,5 +81,6 @@ export const generateLiveConfigurationApiResponse = (
},
},
pii_sharing_allowed: piiSharingAllowed,
provider_type: 'zoom',
provider_type: providerType,
free_tier: providerType === 'bigBlueButton' ? isFreeTier : undefined,
});

View File

@@ -141,6 +141,28 @@ const messages = defineMessages({
defaultMessage: 'Microsoft Teams',
description: 'The name of the Microsoft Teams app.',
},
'appName-bigBlueButton': {
id: 'authoring.live.appName-bigBlueButton',
defaultMessage: 'BigBlueButton',
description: 'The name of the Big Blue Button Teams app.',
},
requestPiiSharingEnableForBbb: {
id: 'authoring.live.requestPiiSharingEnableForBbb',
defaultMessage: 'This configuration will require sharing usernames of learners and the course team with {provider}.',
description: 'Tells the user that they require sharing usernames with the provider to use this feature',
},
piiSharingEnableHelpTextBbb: {
id: 'authoring.live.piiSharingEnableHelpText',
defaultMessage: 'To enable this feature, contact your edX support team to enable PII sharing for this course.',
description: 'Tells the user that request edx project coordinator to enable the PII sharing to access the LTI configuration for a provider.',
},
freePlanMessage: {
id: 'authoring.live.freePlanMessage',
defaultMessage: 'The free plan is pre-configured, and no additional configurations are required.',
description: 'Tells user that free plans requires no additional configurations',
},
});
export default messages;

View File

@@ -60,7 +60,7 @@ function TeamSettings({
onClose={onClose}
bodyClassName="bg-light-200"
// Topic is supported for backwards compatibility, the new field is team_sets:
// ref: https://github.com/edx/edx-platform/blob/15461d3b6e6c0a724a7b8ed09241d970f201e5e7/openedx/core/lib/teams_config.py#L104-L108
// ref: https://github.com/openedx/edx-platform/blob/15461d3b6e6c0a724a7b8ed09241d970f201e5e7/openedx/core/lib/teams_config.py#L104-L108
initialValues={{
maxTeamSize: teamsConfiguration?.maxTeamSize,
groups: teamsConfiguration?.teamSets || teamsConfiguration?.topics,

View File

@@ -15,6 +15,7 @@ import {
import { getConfig } from '@edx/frontend-platform';
import messages from './ProctoredExamSettings.messages';
import ExamsApiService from '../data/services/ExamsApiService';
import StudioApiService from '../data/services/StudioApiService';
import Loading from '../generic/Loading';
import ConnectionErrorAlert from '../generic/ConnectionErrorAlert';
@@ -33,8 +34,10 @@ function ProctoredExamSettings({ courseId, intl }) {
const [loadingPermissionError, setLoadingPermissionError] = useState(false);
const [enableProctoredExams, setEnableProctoredExams] = useState(true);
const [allowOptingOut, setAllowOptingOut] = useState(false);
const [allowLtiProviders, setAllowLtiProviders] = useState(false);
const [proctoringProvider, setProctoringProvider] = useState('');
const [availableProctoringProviders, setAvailableProctoringProviders] = useState([]);
const [ltiProctoringProviders, setLtiProctoringProviders] = useState([]);
const [proctortrackEscalationEmail, setProctortrackEscalationEmail] = useState('');
const [createZendeskTickets, setCreateZendeskTickets] = useState(false);
const [courseStartDate, setCourseStartDate] = useState('');
@@ -89,24 +92,41 @@ function ProctoredExamSettings({ courseId, intl }) {
}
}
function isLtiProvider(provider) {
return ltiProctoringProviders.some(p => p.name === provider);
}
function postSettingsBackToServer() {
const dataToPostBack = {
const providerIsLti = isLtiProvider(proctoringProvider);
const studioDataToPostBack = {
proctored_exam_settings: {
enable_proctored_exams: enableProctoredExams,
proctoring_provider: proctoringProvider,
// lti providers are managed outside edx-platform, lti_external indicates this
proctoring_provider: providerIsLti ? 'lti_external' : proctoringProvider,
create_zendesk_tickets: createZendeskTickets,
},
};
if (isEdxStaff) {
dataToPostBack.proctored_exam_settings.allow_proctoring_opt_out = allowOptingOut;
studioDataToPostBack.proctored_exam_settings.allow_proctoring_opt_out = allowOptingOut;
}
if (proctoringProvider === 'proctortrack') {
dataToPostBack.proctored_exam_settings.proctoring_escalation_email = proctortrackEscalationEmail === '' ? null : proctortrackEscalationEmail;
studioDataToPostBack.proctored_exam_settings.proctoring_escalation_email = proctortrackEscalationEmail === '' ? null : proctortrackEscalationEmail;
}
setSubmissionInProgress(true);
StudioApiService.saveProctoredExamSettingsData(courseId, dataToPostBack).then(() => {
// only save back to exam service if necessary
const saveOperations = [StudioApiService.saveProctoredExamSettingsData(courseId, studioDataToPostBack)];
if (allowLtiProviders && ExamsApiService.isAvailable()) {
saveOperations.push(
ExamsApiService.saveCourseExamConfiguration(
courseId, { provider: providerIsLti ? proctoringProvider : null },
),
);
}
Promise.all(saveOperations)
.then(() => {
setSaveSuccess(true);
setSaveError(false);
setSubmissionInProgress(false);
@@ -172,6 +192,11 @@ function ProctoredExamSettings({ courseId, intl }) {
return markDisabled;
}
function getProviderDisplayLabel(provider) {
// if a display label exists for this provider return it
return ltiProctoringProviders.find(p => p.name === provider)?.verbose_name || provider;
}
function getProctoringProviderOptions(providers) {
return providers.map(provider => (
<option
@@ -180,7 +205,7 @@ function ProctoredExamSettings({ courseId, intl }) {
disabled={isDisabledOption(provider)}
data-testid={provider}
>
{provider}
{getProviderDisplayLabel(provider)}
</option>
));
}
@@ -338,7 +363,7 @@ function ProctoredExamSettings({ courseId, intl }) {
</Form.Group>
)}
{/* CREATE ZENDESK TICKETS */}
{ isEdxStaff && enableProctoredExams && (
{ isEdxStaff && enableProctoredExams && !isLtiProvider(proctoringProvider) && (
<fieldset aria-describedby="createZendeskTicketsText">
<Form.Group controlId="formCreateZendeskTickets">
<Form.Label as="legend">
@@ -470,20 +495,43 @@ function ProctoredExamSettings({ courseId, intl }) {
useEffect(
() => {
dispatch(fetchExamSettingsPending(courseId));
StudioApiService.getProctoredExamSettingsData(courseId)
Promise.all([
StudioApiService.getProctoredExamSettingsData(courseId),
ExamsApiService.isAvailable() ? ExamsApiService.getCourseExamConfiguration(courseId) : Promise.resolve(),
ExamsApiService.isAvailable() ? ExamsApiService.getAvailableProviders() : Promise.resolve(),
])
.then(
response => {
const proctoredExamSettings = response.data.proctored_exam_settings;
([settingsResponse, examConfigResponse, ltiProvidersResponse]) => {
const proctoredExamSettings = settingsResponse.data.proctored_exam_settings;
setLoaded(true);
setLoading(false);
setSubmissionInProgress(false);
setCourseStartDate(response.data.course_start_date);
setCourseStartDate(settingsResponse.data.course_start_date);
setEnableProctoredExams(proctoredExamSettings.enable_proctored_exams);
setAllowOptingOut(proctoredExamSettings.allow_proctoring_opt_out);
setProctoringProvider(proctoredExamSettings.proctoring_provider);
const isProctortrack = proctoredExamSettings.proctoring_provider === 'proctortrack';
setShowProctortrackEscalationEmail(isProctortrack);
setAvailableProctoringProviders(response.data.available_proctoring_providers);
// The list of providers returned by studio settings are the default behavior. If lti_external
// is available as an option display the list of LTI providers returned by the exam service.
// Setting 'lti_external' in studio indicates an LTI provider configured outside of edx-platform.
// This option is not directly selectable.
const proctoringProvidersStudio = settingsResponse.data.available_proctoring_providers;
const proctoringProvidersLti = ltiProvidersResponse?.data || [];
setAllowLtiProviders(proctoringProvidersStudio.includes('lti_external'));
setLtiProctoringProviders(proctoringProvidersLti);
// flatten provider objects and coalesce values to just the provider key
setAvailableProctoringProviders(
proctoringProvidersLti.reduce((result, provider) => [...result, provider.name], []).concat(
proctoringProvidersStudio.filter(value => value !== 'lti_external'),
),
);
if (proctoredExamSettings.proctoring_provider === 'lti_external') {
setProctoringProvider(examConfigResponse.data.provider);
} else {
setProctoringProvider(proctoredExamSettings.proctoring_provider);
}
// The backend API may return null for the proctoringEscalationEmail value, which is the default.
// In order to keep our email input component controlled, we use the empty string as the default
@@ -494,9 +542,10 @@ function ProctoredExamSettings({ courseId, intl }) {
setCreateZendeskTickets(proctoredExamSettings.create_zendesk_tickets);
dispatch(fetchExamSettingsSuccess(courseId));
},
).catch(
)
.catch(
error => {
if (error.response.status === 403) {
if (error.response?.status === 403) {
setLoadingPermissionError(true);
} else {
setLoadingConnectionError(true);

View File

@@ -5,11 +5,12 @@ import {
import { IntlProvider, injectIntl } from '@edx/frontend-platform/i18n';
// import * as auth from '@edx/frontend-platform/auth';
import MockAdapter from 'axios-mock-adapter';
import { initializeMockApp } from '@edx/frontend-platform';
import { initializeMockApp, mergeConfig } from '@edx/frontend-platform';
import { getAuthenticatedHttpClient } from '@edx/frontend-platform/auth';
import { AppProvider } from '@edx/frontend-platform/react';
import ProctoredExamSettings from './ProctoredExamSettings';
import StudioApiService from '../data/services/StudioApiService';
import ExamsApiService from '../data/services/ExamsApiService';
import initializeStore from '../store';
const defaultProps = {
@@ -30,38 +31,61 @@ const intlWrapper = children => (
);
describe('ProctoredExamSettings', () => {
function setupApp(isAdmin = true) {
mergeConfig({
EXAMS_BASE_URL: 'http://exams.testing.co',
}, 'CourseAuthoringConfig');
initializeMockApp({
authenticatedUser: {
userId: 3,
username: 'abc123',
administrator: isAdmin,
roles: [],
},
});
store = initializeStore();
axiosMock = new MockAdapter(getAuthenticatedHttpClient());
axiosMock.onGet(
`${ExamsApiService.getExamsBaseUrl()}/api/v1/providers`,
).reply(200, [
{
name: 'test_lti',
verbose_name: 'LTI Provider',
},
]);
axiosMock.onGet(
`${ExamsApiService.getExamsBaseUrl()}/api/v1/configs/course_id/${defaultProps.courseId}`,
).reply(200, {
provider: null,
});
axiosMock.onGet(
StudioApiService.getProctoredExamSettingsUrl(defaultProps.courseId),
).reply(200, {
proctored_exam_settings: {
enable_proctored_exams: true,
allow_proctoring_opt_out: false,
proctoring_provider: 'mockproc',
proctoring_escalation_email: 'test@example.com',
create_zendesk_tickets: true,
},
available_proctoring_providers: ['software_secure', 'proctortrack', 'mockproc', 'lti_external'],
course_start_date: '2070-01-01T00:00:00Z',
});
}
afterEach(() => {
cleanup();
axiosMock.reset();
});
beforeEach(async () => {
setupApp();
});
describe('Field dependencies', () => {
beforeEach(async () => {
initializeMockApp({
authenticatedUser: {
userId: 3,
username: 'abc123',
administrator: true,
roles: [],
},
});
store = initializeStore();
axiosMock = new MockAdapter(getAuthenticatedHttpClient());
axiosMock.onGet(
StudioApiService.getProctoredExamSettingsUrl(defaultProps.courseId),
).reply(200, {
proctored_exam_settings: {
enable_proctored_exams: true,
allow_proctoring_opt_out: false,
proctoring_provider: 'mockproc',
proctoring_escalation_email: 'test@example.com',
create_zendesk_tickets: true,
},
available_proctoring_providers: ['software_secure', 'proctortrack', 'mockproc'],
course_start_date: '2070-01-01T00:00:00Z',
});
await act(async () => render(intlWrapper(<IntlProctoredExamSettings {...defaultProps} />)));
});
@@ -154,21 +178,23 @@ describe('ProctoredExamSettings', () => {
expect(screen.queryByTestId('createZendeskTicketsYes')).toBeNull();
expect(screen.queryByTestId('createZendeskTicketsNo')).toBeNull();
});
it('Hides unsupported fields when lti provider is selected', async () => {
await waitFor(() => {
screen.getByDisplayValue('mockproc');
});
const selectElement = screen.getByDisplayValue('mockproc');
await act(async () => {
fireEvent.change(selectElement, { target: { value: 'test_lti' } });
});
expect(screen.queryByTestId('escalationEmail')).toBeNull();
expect(screen.queryByTestId('createZendeskTicketsYes')).toBeNull();
expect(screen.queryByTestId('createZendeskTicketsNo')).toBeNull();
});
});
describe('Validation with invalid escalation email', () => {
beforeEach(async () => {
initializeMockApp({
authenticatedUser: {
userId: 3,
username: 'abc123',
administrator: false,
roles: [],
},
});
axiosMock = new MockAdapter(getAuthenticatedHttpClient());
axiosMock.onGet(
StudioApiService.getProctoredExamSettingsUrl(defaultProps.courseId),
).reply(200, {
@@ -183,10 +209,6 @@ describe('ProctoredExamSettings', () => {
course_start_date: '2070-01-01T00:00:00Z',
});
axiosMock.onPost(
StudioApiService.getProctoredExamSettingsUrl(defaultProps.courseId),
).reply(200, {});
await act(async () => render(intlWrapper(<IntlProctoredExamSettings {...defaultProps} />)));
});
@@ -268,9 +290,16 @@ describe('ProctoredExamSettings', () => {
});
it('Has no error when invalid proctoring escalation email is provided with proctoring disabled', async () => {
axiosMock.onPost(
StudioApiService.getProctoredExamSettingsUrl(defaultProps.courseId),
).reply(200, 'success');
axiosMock.onPatch(
`${ExamsApiService.getExamsBaseUrl()}/api/v1/configs/course_id/${defaultProps.courseId}`,
).reply(200, 'success');
await waitFor(() => {
screen.getByDisplayValue('proctortrack');
});
const selectEscalationEmailElement = screen.getByDisplayValue('test@example.com');
await act(async () => {
fireEvent.change(selectEscalationEmailElement, { target: { value: '' } });
@@ -293,6 +322,13 @@ describe('ProctoredExamSettings', () => {
});
it('Has no error when valid proctoring escalation email is provided with proctortrack selected', async () => {
axiosMock.onPost(
StudioApiService.getProctoredExamSettingsUrl(defaultProps.courseId),
).reply(200, 'success');
axiosMock.onPatch(
`${ExamsApiService.getExamsBaseUrl()}/api/v1/configs/course_id/${defaultProps.courseId}`,
).reply(200, 'success');
await waitFor(() => {
screen.getByDisplayValue('proctortrack');
});
@@ -387,83 +423,114 @@ describe('ProctoredExamSettings', () => {
course_start_date: '2013-01-01T00:00:00Z',
};
function setup(data, isAdmin) {
initializeMockApp({
authenticatedUser: {
userId: 3,
username: 'abc123',
administrator: isAdmin,
roles: [],
},
});
axiosMock = new MockAdapter(getAuthenticatedHttpClient());
function mockCourseData(data) {
axiosMock.onGet(StudioApiService.getProctoredExamSettingsUrl(defaultProps.courseId)).reply(200, data);
}
it('Disables irrelevant proctoring provider fields when user is not an administrator and it is after start date', async () => {
setup(mockGetPastCourseData, false);
const isAdmin = false;
setupApp(isAdmin);
mockCourseData(mockGetPastCourseData);
await act(async () => render(intlWrapper(<IntlProctoredExamSettings {...defaultProps} />)));
const providerOption = screen.getByTestId('proctortrack');
expect(providerOption.hasAttribute('disabled')).toEqual(true);
});
it('Enables all proctoring provider options if user is not an administrator and it is before start date', async () => {
setup(mockGetFutureCourseData, false);
const isAdmin = false;
setupApp(isAdmin);
mockCourseData(mockGetFutureCourseData);
await act(async () => render(intlWrapper(<IntlProctoredExamSettings {...defaultProps} />)));
const providerOption = screen.getByTestId('proctortrack');
expect(providerOption.hasAttribute('disabled')).toEqual(false);
});
it('Enables all proctoring provider options if user administrator and it is after start date', async () => {
setup(mockGetPastCourseData, true);
const isAdmin = true;
setupApp(isAdmin);
mockCourseData(mockGetPastCourseData);
await act(async () => render(intlWrapper(<IntlProctoredExamSettings {...defaultProps} />)));
const providerOption = screen.getByTestId('proctortrack');
expect(providerOption.hasAttribute('disabled')).toEqual(false);
});
it('Enables all proctoring provider options if user administrator and it is before start date', async () => {
setup(mockGetFutureCourseData, true);
const isAdmin = true;
setupApp(isAdmin);
mockCourseData(mockGetFutureCourseData);
await act(async () => render(intlWrapper(<IntlProctoredExamSettings {...defaultProps} />)));
const providerOption = screen.getByTestId('proctortrack');
expect(providerOption.hasAttribute('disabled')).toEqual(false);
});
it('Does not include lti_external as a selectable option', async () => {
const courseData = mockGetFutureCourseData;
courseData.available_proctoring_providers = ['lti_external', 'proctortrack', 'mockproc'];
mockCourseData(courseData);
await act(async () => render(intlWrapper(<IntlProctoredExamSettings {...defaultProps} />)));
await waitFor(() => {
screen.getByDisplayValue('mockproc');
});
expect(screen.queryByTestId('lti_external')).toBeNull();
});
it('Includes lti proctoring provider options when lti_external is allowed by studio', async () => {
const courseData = mockGetFutureCourseData;
courseData.available_proctoring_providers = ['lti_external', 'proctortrack', 'mockproc'];
mockCourseData(courseData);
await act(async () => render(intlWrapper(<IntlProctoredExamSettings {...defaultProps} />)));
await waitFor(() => {
screen.getByDisplayValue('mockproc');
});
const providerOption = screen.getByTestId('test_lti');
// as as admin the provider should not be disabled
expect(providerOption.hasAttribute('disabled')).toEqual(false);
});
it('Does not request lti provider options if there is no exam service url configuration', async () => {
mergeConfig({
EXAMS_BASE_URL: null,
}, 'CourseAuthoringConfig');
await act(async () => render(intlWrapper(<IntlProctoredExamSettings {...defaultProps} />)));
await waitFor(() => {
screen.getByDisplayValue('mockproc');
});
// only outgoing request should be for studio settings
expect(axiosMock.history.get.length).toBe(1);
expect(axiosMock.history.get[0].url.includes('proctored_exam_settings')).toEqual(true);
});
it('Selected LTI proctoring provider is shown on page load', async () => {
const courseData = { ...mockGetFutureCourseData };
courseData.available_proctoring_providers = ['lti_external', 'proctortrack', 'mockproc'];
courseData.proctored_exam_settings.proctoring_provider = 'lti_external';
mockCourseData(courseData);
axiosMock.onGet(
`${ExamsApiService.getExamsBaseUrl()}/api/v1/configs/course_id/${defaultProps.courseId}`,
).reply(200, {
provider: 'test_lti',
});
await act(async () => render(intlWrapper(<IntlProctoredExamSettings {...defaultProps} />)));
await waitFor(() => {
screen.getByText('Proctoring Provider');
});
// make sure test_lti is the selected provider
expect(screen.getByDisplayValue('LTI Provider')).toBeInTheDocument();
});
});
describe('Toggles field visibility based on user permissions', () => {
function setup(isAdmin) {
initializeMockApp({
authenticatedUser: {
userId: 3,
username: 'abc123',
administrator: isAdmin,
roles: [],
},
});
axiosMock = new MockAdapter(getAuthenticatedHttpClient());
axiosMock.onGet(StudioApiService.getProctoredExamSettingsUrl(defaultProps.courseId)).reply(200, {
proctored_exam_settings: {
enable_proctored_exams: true,
allow_proctoring_opt_out: false,
proctoring_provider: 'mockproc',
proctoring_escalation_email: 'test@example.com',
create_zendesk_tickets: true,
},
available_proctoring_providers: ['software_secure', 'proctortrack', 'mockproc'],
course_start_date: '2070-01-01T00:00:00Z',
});
}
it('Hides opting out and zendesk tickets for non edX staff', async () => {
setup(false);
setupApp(false);
await act(async () => render(intlWrapper(<IntlProctoredExamSettings {...defaultProps} />)));
expect(screen.queryByTestId('allowOptingOutYes')).toBeNull();
expect(screen.queryByTestId('createZendeskTicketsYes')).toBeNull();
});
it('Shows opting out and zendesk tickets for edX staff', async () => {
setup(true);
setupApp(true);
await act(async () => render(intlWrapper(<IntlProctoredExamSettings {...defaultProps} />)));
expect(screen.queryByTestId('allowOptingOutYes')).not.toBeNull();
expect(screen.queryByTestId('createZendeskTicketsYes')).not.toBeNull();
@@ -471,18 +538,6 @@ describe('ProctoredExamSettings', () => {
});
describe('Connection states', () => {
beforeEach(() => {
initializeMockApp({
authenticatedUser: {
userId: 3,
username: 'abc123',
administrator: true,
roles: [],
},
});
axiosMock = new MockAdapter(getAuthenticatedHttpClient());
});
it('Shows the spinner before the connection is complete', async () => {
await act(async () => {
render(intlWrapper(<IntlProctoredExamSettings {...defaultProps} />));
@@ -492,7 +547,7 @@ describe('ProctoredExamSettings', () => {
});
});
it('Show connection error message when we suffer server side error', async () => {
it('Show connection error message when we suffer studio server side error', async () => {
axiosMock.onGet(
StudioApiService.getProctoredExamSettingsUrl(defaultProps.courseId),
).reply(500);
@@ -504,6 +559,18 @@ describe('ProctoredExamSettings', () => {
);
});
it('Show connection error message when we suffer edx-exams server side error', async () => {
axiosMock.onGet(
`${ExamsApiService.getExamsBaseUrl()}/api/v1/providers`,
).reply(500);
await act(async () => render(intlWrapper(<IntlProctoredExamSettings {...defaultProps} />)));
const connectionError = screen.getByTestId('connectionErrorAlert');
expect(connectionError.textContent).toEqual(
expect.stringContaining('We encountered a technical error when loading this page.'),
);
});
it('Show permission error message when user do not have enough permission', async () => {
axiosMock.onGet(
StudioApiService.getProctoredExamSettingsUrl(defaultProps.courseId),
@@ -518,27 +585,13 @@ describe('ProctoredExamSettings', () => {
});
describe('Save settings', () => {
beforeEach(() => {
initializeMockApp({
authenticatedUser: {
userId: 3,
username: 'abc123',
administrator: true,
roles: [],
},
});
axiosMock = new MockAdapter(getAuthenticatedHttpClient(), { onNoMatch: 'throwException' });
axiosMock.onGet(StudioApiService.getProctoredExamSettingsUrl(defaultProps.courseId)).reply(200, {
proctored_exam_settings: {
enable_proctored_exams: true,
allow_proctoring_opt_out: false,
proctoring_provider: 'mockproc',
proctoring_escalation_email: 'test@example.com',
create_zendesk_tickets: true,
},
available_proctoring_providers: ['software_secure', 'proctortrack', 'mockproc'],
});
beforeEach(async () => {
axiosMock.onPost(
StudioApiService.getProctoredExamSettingsUrl(defaultProps.courseId),
).reply(200, 'success');
axiosMock.onPatch(
`${ExamsApiService.getExamsBaseUrl()}/api/v1/configs/course_id/${defaultProps.courseId}`,
).reply(200, 'success');
});
it('Show spinner while saving', async () => {
@@ -557,16 +610,14 @@ describe('ProctoredExamSettings', () => {
expect(submitSpinner).toBeDefined();
await waitForElementToBeRemoved(submitSpinner);
expect(axiosMock.history.get.length).toBe(1);
expect(axiosMock.history.post.length).toBe(1);
// request studio settings, exam config, and exam service providers
expect(axiosMock.history.get.length).toBe(3);
expect(axiosMock.history.post.length).toBe(1); // studio
expect(axiosMock.history.patch.length).toBe(1); // edx-exams
expect(screen.queryByTestId('saveInProgress')).toBeFalsy();
});
it('Makes API call successfully with proctoring_escalation_email if proctortrack', async () => {
axiosMock.onPost(
StudioApiService.getProctoredExamSettingsUrl(defaultProps.courseId),
).reply(200, 'success');
await act(async () => render(intlWrapper(<IntlProctoredExamSettings {...defaultProps} />)));
// Make a change to the provider to proctortrack and set the email
const selectElement = screen.getByDisplayValue('mockproc');
@@ -602,10 +653,6 @@ describe('ProctoredExamSettings', () => {
});
it('Makes API call successfully without proctoring_escalation_email if not proctortrack', async () => {
axiosMock.onPost(
StudioApiService.getProctoredExamSettingsUrl(defaultProps.courseId),
).reply(200, 'success');
await act(async () => render(intlWrapper(<IntlProctoredExamSettings {...defaultProps} />)));
// make sure we have not selected proctortrack as the proctoring provider
@@ -632,7 +679,112 @@ describe('ProctoredExamSettings', () => {
expect(document.activeElement).toEqual(errorAlert);
});
it('Makes API call generated error', async () => {
it('Successfully updates exam configuration and studio provider is set to "lti_external" for lti providers', async () => {
await act(async () => render(intlWrapper(<IntlProctoredExamSettings {...defaultProps} />)));
// Make a change to the provider to proctortrack and set the email
const selectElement = screen.getByDisplayValue('mockproc');
await act(async () => {
fireEvent.change(selectElement, { target: { value: 'test_lti' } });
});
const submitButton = screen.getByTestId('submissionButton');
await act(async () => {
fireEvent.click(submitButton);
});
// update exam service config
expect(axiosMock.history.patch.length).toBe(1);
expect(JSON.parse(axiosMock.history.patch[0].data)).toEqual({
provider: 'test_lti',
});
// update studio settings
expect(axiosMock.history.post.length).toBe(1);
expect(JSON.parse(axiosMock.history.post[0].data)).toEqual({
proctored_exam_settings: {
enable_proctored_exams: true,
allow_proctoring_opt_out: false,
proctoring_provider: 'lti_external',
create_zendesk_tickets: true,
},
});
const errorAlert = screen.getByTestId('saveSuccess');
expect(errorAlert.textContent).toEqual(
expect.stringContaining('Proctored exam settings saved successfully.'),
);
expect(document.activeElement).toEqual(errorAlert);
});
it('Sets exam service provider to null if a non-lti provider is selected', async () => {
await act(async () => render(intlWrapper(<IntlProctoredExamSettings {...defaultProps} />)));
const submitButton = screen.getByTestId('submissionButton');
await act(async () => {
fireEvent.click(submitButton);
});
// update exam service config
expect(axiosMock.history.patch.length).toBe(1);
expect(JSON.parse(axiosMock.history.patch[0].data)).toEqual({
provider: null,
});
expect(axiosMock.history.patch.length).toBe(1);
expect(axiosMock.history.post.length).toBe(1);
expect(JSON.parse(axiosMock.history.post[0].data)).toEqual({
proctored_exam_settings: {
enable_proctored_exams: true,
allow_proctoring_opt_out: false,
proctoring_provider: 'mockproc',
create_zendesk_tickets: true,
},
});
const errorAlert = screen.getByTestId('saveSuccess');
expect(errorAlert.textContent).toEqual(
expect.stringContaining('Proctored exam settings saved successfully.'),
);
expect(document.activeElement).toEqual(errorAlert);
});
it('Does not update exam service if lti is not enabled in studio', async () => {
axiosMock.onGet(
StudioApiService.getProctoredExamSettingsUrl(defaultProps.courseId),
).reply(200, {
proctored_exam_settings: {
enable_proctored_exams: true,
allow_proctoring_opt_out: false,
proctoring_provider: 'mockproc',
proctoring_escalation_email: 'test@example.com',
create_zendesk_tickets: true,
},
available_proctoring_providers: ['software_secure', 'proctortrack', 'mockproc'],
course_start_date: '2070-01-01T00:00:00Z',
});
await act(async () => render(intlWrapper(<IntlProctoredExamSettings {...defaultProps} />)));
const submitButton = screen.getByTestId('submissionButton');
await act(async () => {
fireEvent.click(submitButton);
});
// does not update exam service config
expect(axiosMock.history.patch.length).toBe(0);
// does update studio
expect(axiosMock.history.post.length).toBe(1);
expect(JSON.parse(axiosMock.history.post[0].data)).toEqual({
proctored_exam_settings: {
enable_proctored_exams: true,
allow_proctoring_opt_out: false,
proctoring_provider: 'mockproc',
create_zendesk_tickets: true,
},
});
const errorAlert = screen.getByTestId('saveSuccess');
expect(errorAlert.textContent).toEqual(
expect.stringContaining('Proctored exam settings saved successfully.'),
);
expect(document.activeElement).toEqual(errorAlert);
});
it('Makes studio API call generated error', async () => {
axiosMock.onPost(
StudioApiService.getProctoredExamSettingsUrl(defaultProps.courseId),
).reply(500);
@@ -650,11 +802,29 @@ describe('ProctoredExamSettings', () => {
expect(document.activeElement).toEqual(errorAlert);
});
it('Makes exams API call generated error', async () => {
axiosMock.onPatch(
`${ExamsApiService.getExamsBaseUrl()}/api/v1/configs/course_id/${defaultProps.courseId}`,
).reply(500, 'error');
await act(async () => render(intlWrapper(<IntlProctoredExamSettings {...defaultProps} />)));
const submitButton = screen.getByTestId('submissionButton');
await act(async () => {
fireEvent.click(submitButton);
});
expect(axiosMock.history.post.length).toBe(1);
const errorAlert = screen.getByTestId('saveError');
expect(errorAlert.textContent).toEqual(
expect.stringContaining('We encountered a technical error while trying to save proctored exam settings'),
);
expect(document.activeElement).toEqual(errorAlert);
});
it('Manages focus correctly after different save statuses', async () => {
// first make a call that will cause a save error
axiosMock.onPost(
StudioApiService.getProctoredExamSettingsUrl(defaultProps.courseId),
).replyOnce(500);
).reply(500);
await act(async () => render(intlWrapper(<IntlProctoredExamSettings {...defaultProps} />)));
const submitButton = screen.getByTestId('submissionButton');
@@ -671,7 +841,7 @@ describe('ProctoredExamSettings', () => {
// now make a call that will allow for a successful save
axiosMock.onPost(
StudioApiService.getProctoredExamSettingsUrl(defaultProps.courseId),
).replyOnce(200, 'success');
).reply(200, 'success');
await act(async () => {
fireEvent.click(submitButton);
});
@@ -686,31 +856,14 @@ describe('ProctoredExamSettings', () => {
it('Include Zendesk ticket in post request if user is not an admin', async () => {
// use non-admin user for test
initializeMockApp({
authenticatedUser: {
userId: 4,
username: 'abc1234',
administrator: false,
roles: [],
},
});
axiosMock = new MockAdapter(getAuthenticatedHttpClient(), { onNoMatch: 'throwException' });
axiosMock.onGet(StudioApiService.getProctoredExamSettingsUrl(defaultProps.courseId)).reply(200, {
proctored_exam_settings: {
enable_proctored_exams: true,
allow_proctoring_opt_out: false,
proctoring_provider: 'mockproc',
proctoring_escalation_email: 'test@example.com',
create_zendesk_tickets: true,
},
available_proctoring_providers: ['software_secure', 'proctortrack', 'mockproc'],
});
axiosMock.onPost(
StudioApiService.getProctoredExamSettingsUrl(defaultProps.courseId),
).reply(200, 'success');
const isAdmin = false;
setupApp(isAdmin);
await act(async () => render(intlWrapper(<IntlProctoredExamSettings {...defaultProps} />)));
// Make a change to the proctoring provider
await waitFor(() => {
screen.getByDisplayValue('mockproc');
});
const selectElement = screen.getByDisplayValue('mockproc');
await act(async () => {
fireEvent.change(selectElement, { target: { value: 'proctortrack' } });

View File

@@ -2,13 +2,14 @@ import 'core-js/stable';
import 'regenerator-runtime/runtime';
import '@testing-library/jest-dom';
import '@testing-library/jest-dom/extend-expect';
import ReactDOM from 'react-dom';
/* eslint-disable import/no-extraneous-dependencies */
import Enzyme from 'enzyme';
import Adapter from 'enzyme-adapter-react-16';
import 'babel-polyfill';
import { mergeConfig } from '@edx/frontend-platform';
Enzyme.configure({ adapter: new Adapter() });
/* need to mock window for tinymce on import, as it is JSDOM incompatible */
@@ -27,9 +28,6 @@ Object.defineProperty(window, 'matchMedia', {
})),
});
// Modal creates a portal. Overriding ReactDOM.createPortal allows portals to be tested in jest.
ReactDOM.createPortal = node => node;
// Mock Intersection Observer which is unavailable in the context of a test.
global.IntersectionObserver = jest.fn(function mockIntersectionObserver() {
this.observe = jest.fn();
@@ -41,3 +39,15 @@ global.IntersectionObserver = jest.fn(function mockIntersectionObserver() {
window.getComputedStyle = jest.fn(() => ({
getPropertyValue: jest.fn(),
}));
// Ensure app-specific configs are loaded during tests since
// initialize() is not called.
mergeConfig({
SUPPORT_URL: process.env.SUPPORT_URL || null,
SUPPORT_EMAIL: process.env.SUPPORT_EMAIL || null,
LEARNING_BASE_URL: process.env.LEARNING_BASE_URL,
EXAMS_BASE_URL: process.env.EXAMS_BASE_URL || null,
CALCULATOR_HELP_URL: process.env.CALCULATOR_HELP_URL || null,
ENABLE_PROGRESS_GRAPH_SETTINGS: process.env.ENABLE_PROGRESS_GRAPH_SETTINGS || 'false',
ENABLE_TEAM_TYPE_SETTING: process.env.ENABLE_TEAM_TYPE_SETTING === 'true',
}, 'CourseAuthoringConfig');

View File

@@ -15,12 +15,11 @@ import DesktopHeader from './DesktopHeader';
import MobileHeader from './MobileHeader';
import messages from './Header.messages';
import StudioLogoSVG from './assets/studio-logo.svg';
ensureConfig([
'STUDIO_BASE_URL',
'LOGOUT_URL',
'LOGIN_URL',
'LOGO_URL',
], 'Header component');
function Header({
@@ -154,7 +153,7 @@ function Header({
);
const props = {
logo: StudioLogoSVG,
logo: config.LOGO_URL,
logoAltText: 'Studio edX',
siteName: 'edX',
logoDestination: config.STUDIO_BASE_URL,

View File

@@ -1,77 +0,0 @@
<?xml version="1.0" encoding="utf-8"?>
<!-- Generator: Adobe Illustrator 24.1.2, SVG Export Plug-In . SVG Version: 6.00 Build 0) -->
<svg version="1.1" id="Layer_1" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" x="0px" y="0px"
viewBox="0 0 271.93 76.05" style="enable-background:new 0 0 271.93 76.05;" xml:space="preserve">
<style type="text/css">
.st0{fill:#00262B;}
.st1{fill:#FFFFFF;}
</style>
<g>
<g>
<path class="st0" d="M161.07,32.72c-3.8,0-6.52,1.93-6.94,4.57c-0.51,2.96,2.54,4.11,4.83,4.71l2.9,0.78
c3.74,0.97,8.35,3.13,7.43,8.7c-0.9,5.24-5.67,9.06-12.93,9.06c-6.93,0-10.72-3.46-10.09-9.06h4.7c-0.31,3.37,2.43,4.97,6.02,4.97
c3.97,0,7.19-2.01,7.67-5.01c0.48-2.73-1.9-3.84-5.09-4.71l-3.49-1c-4.73-1.36-7.38-3.87-6.69-8.17
c0.92-5.35,6.24-8.88,12.44-8.88c6.28,0,9.98,3.6,9.34,8.73h-4.5C166.73,34.37,164.63,32.72,161.07,32.72z"/>
<path class="st0" d="M186.76,40.47h-4.79l-2.13,12.76c-0.42,2.61,0.83,3.09,2.3,3.09c0.72,0,1.28-0.14,1.6-0.19l0.25,3.73
c-0.6,0.2-1.62,0.47-3.07,0.48c-3.58,0.06-6.42-1.98-5.71-6.18l2.26-13.69h-3.38l0.59-3.62h3.38l0.92-5.56h4.51l-0.92,5.56h4.77
L186.76,40.47z"/>
<path class="st0" d="M206.59,36.84h4.53l-3.87,23.19h-4.44l0.68-4.02h-0.24c-1.45,2.48-4.19,4.32-7.67,4.32
c-4.46,0-7.1-2.99-6.16-8.74l2.48-14.75h4.51l-2.37,14.21c-0.5,3.16,1.09,5.18,3.88,5.18c2.54,0,5.75-1.87,6.4-5.82L206.59,36.84z
"/>
<path class="st0" d="M213.82,48.48c1.25-7.52,6.11-11.94,11.73-11.94c4.3,0,5.42,2.63,5.98,4.06h0.27l1.92-11.49h4.51l-5.13,30.93
h-4.41l0.6-3.61h-0.36c-1.07,1.48-3.16,4.06-7.37,4.06C215.92,60.49,212.6,55.99,213.82,48.48z M230.6,48.44
c0.8-4.85-0.88-8.06-4.83-8.06c-4.06,0-6.6,3.46-7.35,8.06c-0.77,4.65,0.63,8.2,4.68,8.2C226.97,56.64,229.8,53.32,230.6,48.44z"
/>
<path class="st0" d="M242.97,36.84h4.51l-3.87,23.19h-4.51L242.97,36.84z M243.44,30.53c0.06-1.5,1.41-2.7,2.98-2.7
c1.55,0,2.79,1.21,2.72,2.7c-0.06,1.48-1.41,2.69-2.98,2.69C244.6,33.22,243.37,32.01,243.44,30.53z"/>
<path class="st0" d="M250.19,48.35c1.13-7.13,6.1-11.81,12.56-11.81c6.61,0,10.13,4.88,8.96,12.2
c-1.17,7.08-6.13,11.76-12.57,11.76C252.49,60.5,248.99,55.63,250.19,48.35z M267.25,48.35c0.68-4.36-0.62-8.03-4.76-8.03
c-4.36,0-7.13,3.87-7.85,8.41c-0.71,4.35,0.59,7.99,4.76,7.99C263.73,56.71,266.5,52.89,267.25,48.35z"/>
</g>
<g>
<g>
<polygon class="st0" points="86.45,12.49 89.06,0.02 12.55,0.02 0,59.95 63.97,59.95 60.45,76.03 121.72,76.03 135.24,12.49
"/>
<path class="st1" d="M26.07,52.3c-1.73,0-3.37-0.28-4.91-0.85c-1.54-0.57-2.89-1.41-4.03-2.51c-1.15-1.11-2.05-2.47-2.72-4.09
c-0.67-1.62-1-3.48-1-5.58c0-2.87,0.4-5.49,1.2-7.85c0.8-2.36,1.91-4.38,3.34-6.07c1.43-1.69,3.14-2.99,5.13-3.92
c1.99-0.92,4.18-1.39,6.55-1.39c1.6,0,3.12,0.28,4.55,0.84c1.44,0.56,2.69,1.37,3.77,2.44c1.08,1.07,1.94,2.38,2.57,3.95
c0.64,1.56,0.95,3.35,0.95,5.38c0,0.31-0.01,0.67-0.03,1.08c-0.02,0.42-0.05,0.84-0.09,1.27c-0.04,0.43-0.08,0.86-0.11,1.27
c-0.04,0.41-0.09,0.77-0.14,1.05H18.64c-0.02,0.27-0.03,0.53-0.04,0.78c-0.01,0.25-0.01,0.51-0.01,0.78
c0,1.6,0.23,2.96,0.69,4.09c0.46,1.13,1.06,2.05,1.81,2.76c0.74,0.71,1.58,1.23,2.5,1.55s1.86,0.48,2.8,0.48
c2.06,0,3.72-0.36,4.97-1.07c1.25-0.71,2.21-1.68,2.86-2.89h5.29c-0.33,1.2-0.87,2.31-1.62,3.35c-0.75,1.04-1.7,1.94-2.85,2.7
c-1.15,0.76-2.48,1.36-3.99,1.79C29.55,52.09,27.88,52.3,26.07,52.3z M36.22,33.31c0.02-0.1,0.03-0.27,0.04-0.54
c0.01-0.26,0.01-0.52,0.01-0.77c0-1.02-0.15-1.99-0.45-2.91c-0.3-0.91-0.75-1.72-1.35-2.41c-0.6-0.69-1.34-1.24-2.23-1.65
c-0.89-0.4-1.92-0.61-3.09-0.61c-1.2,0-2.31,0.21-3.35,0.64c-1.04,0.42-1.99,1.03-2.83,1.81c-0.85,0.78-1.58,1.71-2.2,2.8
c-0.62,1.09-1.11,2.3-1.47,3.63L36.22,33.31L36.22,33.31z"/>
<path class="st1" d="M55.67,52.3c-1.56,0-3.03-0.29-4.4-0.88c-1.37-0.59-2.57-1.43-3.6-2.53c-1.03-1.1-1.85-2.43-2.44-3.99
c-0.6-1.56-0.9-3.3-0.9-5.23c0-1.87,0.19-3.66,0.56-5.36c0.38-1.7,0.91-3.29,1.6-4.74c0.69-1.46,1.53-2.77,2.5-3.95
c0.97-1.18,2.05-2.18,3.24-3.01s2.47-1.47,3.85-1.91c1.38-0.44,2.82-0.67,4.32-0.67c1.12,0,2.18,0.15,3.19,0.46
c1.01,0.31,1.93,0.74,2.75,1.29c0.82,0.55,1.52,1.21,2.11,1.99c0.59,0.78,1.02,1.63,1.29,2.56h0.46l3.85-18.13h5.06l-9.25,43.54
h-4.8l0.9-4.25H65.5c-1.14,1.48-2.56,2.65-4.28,3.51C59.5,51.87,57.65,52.3,55.67,52.3z M57.26,47.82c1.62,0,3.12-0.38,4.5-1.14
c1.38-0.76,2.58-1.8,3.6-3.12c1.02-1.32,1.82-2.87,2.4-4.65c0.58-1.78,0.87-3.71,0.87-5.77c0-1.33-0.18-2.52-0.55-3.59
c-0.37-1.06-0.89-1.96-1.56-2.7c-0.68-0.74-1.49-1.32-2.46-1.72c-0.96-0.41-2.05-0.61-3.27-0.61c-1.6,0-3.08,0.36-4.45,1.07
c-1.37,0.71-2.55,1.7-3.56,2.98c-1,1.27-1.79,2.79-2.37,4.55c-0.58,1.76-0.87,3.7-0.87,5.8c0,1.31,0.19,2.51,0.57,3.61
s0.9,2.04,1.58,2.82c0.68,0.78,1.48,1.39,2.43,1.82C55.05,47.6,56.1,47.82,57.26,47.82z"/>
<g>
<polygon class="st1" points="124.49,20.68 113.2,20.68 100.9,35.92 100.29,35.92 93.78,20.68 82.37,20.68 92.15,42.82
71.03,67.84 82.16,67.84 95.72,51.76 96.63,51.76 103.96,67.84 115.16,67.84 104.33,43.92 "/>
</g>
</g>
<g>
<g>
<path class="st0" d="M141.76,20.4c-0.55,0-1.07-0.1-1.55-0.31c-0.48-0.21-0.91-0.49-1.27-0.86c-0.37-0.37-0.65-0.79-0.86-1.27
c-0.21-0.48-0.31-1-0.31-1.55c0-0.55,0.1-1.07,0.31-1.55c0.21-0.48,0.49-0.91,0.86-1.27c0.37-0.37,0.79-0.65,1.27-0.86
c0.48-0.21,1-0.31,1.55-0.31c0.55,0,1.07,0.1,1.55,0.31c0.48,0.21,0.91,0.49,1.27,0.86c0.37,0.37,0.65,0.79,0.86,1.27
c0.21,0.48,0.31,1,0.31,1.55c0,0.55-0.1,1.07-0.31,1.55c-0.21,0.48-0.49,0.91-0.86,1.27s-0.79,0.65-1.27,0.86
C142.83,20.3,142.31,20.4,141.76,20.4z M141.76,19.66c0.6,0,1.15-0.15,1.64-0.44c0.49-0.29,0.88-0.69,1.18-1.18
c0.29-0.49,0.44-1.04,0.44-1.64c0-0.6-0.15-1.15-0.44-1.64c-0.29-0.49-0.69-0.88-1.18-1.18c-0.49-0.29-1.04-0.44-1.64-0.44
s-1.15,0.15-1.64,0.44c-0.49,0.29-0.88,0.69-1.18,1.18c-0.29,0.49-0.44,1.04-0.44,1.64c0,0.6,0.15,1.15,0.44,1.64
c0.29,0.49,0.69,0.88,1.18,1.18C140.61,19.51,141.16,19.66,141.76,19.66z M140.4,18.2v-3.69h1.77c0.19,0,0.37,0.04,0.54,0.13
c0.18,0.09,0.32,0.22,0.43,0.39c0.12,0.17,0.17,0.38,0.17,0.63c0,0.25-0.06,0.47-0.18,0.65c-0.12,0.18-0.27,0.32-0.45,0.42
c-0.18,0.1-0.37,0.14-0.56,0.14h-1.37v-0.5h1.2c0.17,0,0.32-0.06,0.46-0.18c0.13-0.12,0.2-0.3,0.2-0.52c0-0.23-0.07-0.4-0.2-0.5
s-0.28-0.15-0.44-0.15h-0.93v3.18H140.4z M142.55,16.49l0.92,1.71h-0.72l-0.89-1.71H142.55z"/>
</g>
</g>
</g>
</g>
</svg>

Before

Width:  |  Height:  |  Size: 6.2 KiB