Compare commits

..

5 Commits

Author SHA1 Message Date
Ghassan Maslamani
1d09a8d099 chore: update translation to olive (#454) 2023-05-18 15:55:01 -04:00
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
97 changed files with 28365 additions and 6646 deletions

2
.env
View File

@@ -27,5 +27,3 @@ USER_INFO_COOKIE_NAME=''
ENABLE_PROGRESS_GRAPH_SETTINGS=false
ENABLE_TEAM_TYPE_SETTING=false
ENABLE_NEW_EDITOR_PAGES=true
BBB_LEARN_MORE_URL=''
ENABLE_VIDEO_UPLOAD_PAGE_LINK_IN_CONTENT_DROPDOWN=false

View File

@@ -29,5 +29,3 @@ USER_INFO_COOKIE_NAME='edx-user-info'
ENABLE_PROGRESS_GRAPH_SETTINGS=false
ENABLE_TEAM_TYPE_SETTING=false
ENABLE_NEW_EDITOR_PAGES=true
BBB_LEARN_MORE_URL=''
ENABLE_VIDEO_UPLOAD_PAGE_LINK_IN_CONTENT_DROPDOWN=false

View File

@@ -28,5 +28,3 @@ USER_INFO_COOKIE_NAME='edx-user-info'
ENABLE_PROGRESS_GRAPH_SETTINGS=false
ENABLE_TEAM_TYPE_SETTING=false
ENABLE_NEW_EDITOR_PAGES=true
BBB_LEARN_MORE_URL=''
ENABLE_VIDEO_UPLOAD_PAGE_LINK_IN_CONTENT_DROPDOWN=true

View File

@@ -1,8 +1,6 @@
// eslint-disable-next-line import/no-extraneous-dependencies
const { createConfig } = require('@edx/frontend-build');
module.exports = createConfig(
'eslint',
module.exports = createConfig('eslint',
{
rules: {
'jsx-a11y/label-has-associated-control': [2, {
@@ -11,7 +9,5 @@ module.exports = createConfig(
'template-curly-spacing': 'off',
'react-hooks/exhaustive-deps': 'off',
indent: 'off',
'no-restricted-exports': 'off',
},
},
);
});

View File

@@ -16,4 +16,4 @@ jobs:
secrets:
GITHUB_APP_ID: ${{ secrets.GRAPHQL_AUTH_APP_ID }}
GITHUB_APP_PRIVATE_KEY: ${{ secrets.GRAPHQL_AUTH_APP_PEM }}
SLACK_BOT_TOKEN: ${{ secrets.SLACK_ISSUE_BOT_TOKEN }}
SLACK_BOT_TOKEN: ${{ secrets.SLACK_ISSUE_BOT_TOKEN }}

View File

@@ -1,20 +0,0 @@
# This workflow runs when a comment is made on the ticket
# If the comment starts with "label: " it tries to apply
# the label indicated in rest of comment.
# If the comment starts with "remove label: ", it tries
# to remove the indicated label.
# Note: Labels are allowed to have spaces and this script does
# not parse spaces (as often a space is legitimate), so the command
# "label: really long lots of words label" will apply the
# label "really long lots of words label"
name: Allows for the adding and removing of labels via comment
on:
issue_comment:
types: [created]
jobs:
add_remove_labels:
uses: openedx/.github/.github/workflows/add-remove-label-on-comment.yml@master

View File

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

View File

@@ -1,12 +0,0 @@
# This workflow runs when a comment is made on the ticket
# If the comment starts with "assign me" it assigns the author to the
# ticket (case insensitive)
name: Assign comment author to ticket if they say "assign me"
on:
issue_comment:
types: [created]
jobs:
self_assign_by_comment:
uses: openedx/.github/.github/workflows/self-assign-issue.yml@master

View File

@@ -1,12 +0,0 @@
name: Update Browserslist DB
on:
schedule:
- cron: '0 0 * * 1'
workflow_dispatch:
jobs:
update-browserslist:
uses: openedx/.github/.github/workflows/update-browserslist-db.yml@master
secrets:
requirements_bot_github_token: ${{ secrets.requirements_bot_github_token }}

View File

@@ -9,13 +9,14 @@ on:
jobs:
tests:
runs-on: ubuntu-latest
strategy:
matrix:
node: [16]
steps:
- uses: actions/checkout@v3
- name: Setup Nodejs Env
run: echo "NODE_VER=`cat .nvmrc`" >> $GITHUB_ENV
- uses: actions/setup-node@v3
- uses: actions/checkout@v2
- uses: actions/setup-node@v2
with:
node-version: ${{ env.NODE_VER }}
node-version: ${{ matrix.node }}
- run: make validate.ci
- name: Upload coverage
uses: codecov/codecov-action@v3

2
.nvmrc
View File

@@ -1 +1 @@
18
v16

6
Makefile Normal file → Executable file
View File

@@ -1,10 +1,12 @@
transifex_resource = frontend-app-course-authoring
export TRANSIFEX_RESOURCE = ${transifex_resource}
transifex_langs = "ar,fr,es_419,zh_CN,pt,it,de,uk,ru,hi,fr_CA"
transifex_langs = "ar,fr,es_419,zh_CN"
transifex_utils = ./node_modules/.bin/transifex-utils.js
i18n = ./src/i18n
transifex_input = $(i18n)/transifex_input.json
tx_url1 = https://www.transifex.com/api/2/project/edx-platform/resource/$(transifex_resource)/translation/en/strings/
tx_url2 = https://www.transifex.com/api/2/project/edx-platform/resource/$(transifex_resource)/source/
# This directory must match .babelrc .
transifex_temp = ./temp/babel-plugin-react-intl
@@ -45,7 +47,7 @@ push_translations:
# Pulls translations from Transifex.
pull_translations:
tx pull -t -f --mode reviewed --languages=$(transifex_langs)
tx pull -f --mode reviewed --languages=$(transifex_langs)
# This target is used by Travis.
validate-no-uncommitted-package-lock-changes:

View File

@@ -1,158 +1,17 @@
|Build Status| |Codecov| |license|
#############################
frontend-app-course-authoring
#############################
=============================
Please tag `@edx/teaching-and-learning <https://github.com/orgs/edx/teams/teaching-and-learning>`_ on any PRs or issues. Thanks.
************
Introduction
************
This is the Course Authoring micro-frontend, currently under development by `2U <https://2u.com>`_.
Its purpose is to provide both a framework and UI for new or replacement React-based authoring features outside ``edx-platform``. You can find the current set described below.
********
Features
********
Feature: Pages and Resources Studio Tab
=======================================
Enables a "Pages & Resources" menu item in Studio, under the "Content" menu.
Requirements
Prerequisite
------------
The following are external requirements for this feature to function correctly:
* ``edx-platform`` Django settings:
* ``COURSE_AUTHORING_MICROFRONTEND_URL``: must be set in the CMS environment and point to this MFE's deployment URL.
* ``edx-platform`` Waffle flags:
* ``discussions.pages_and_resources_mfe``: must be enabled for the set of users meant to access this feature.
* `frontend-app-learning <https://github.com/openedx/frontend-app-learning>`_: This MFE expects it to be the LMS frontend.
* `frontend-app-discussions <https://github.com/openedx/frontend-app-discussions/>`_: This is what the "Discussions" configuration provided by this feature actually configures. Without it, discussion settings are ignored.
Configuration
-------------
In additional to the standard settings, the following local configuration items are required:
* ``LEARNING_BASE_URL``: points to Learning MFE; necessary so that the `View Live` button works
* ``ENABLE_PROGRESS_GRAPH_SETTINGS``: allow enabling or disabling the learner progress graph course-wide
Feature Description
-------------------
Clicking on the "Pages & Resources" menu item takes the user to the course's ``pages-and-resources`` standalone page in this MFE. (In a devstack, for instance: http://localhost:2001/course/course-v1:edX+DemoX+Demo_Course/pages-and-resources.)
UX-wise, **Pages & Resources** is meant to look like a Studio tab, so reproduces Studio's header.
For a particular course, this page allows one to:
* Configure the new Discussions MFE (making this a requirement for it). This includes:
* Enabling/disabling the feature entirely
* Picking a different discussion provider, while showing a comparison matrix between them:
* edX
* Ed Discussion
* InScribe
* Piazza
* Yellowdig
* Allowing to configure the selected provider
* Enable/Disable learner progress
* Enable/Disable learner notes
* Enable/Disable the learner wiki
* Enable/Disable the LMS calculator
* Go to the textbook management page in Studio (in a devstack: http://localhost:18010/textbooks/course-v1:edX+DemoX+Demo_Course)
* Go to the custom page management page in Studio(in a devstack http://localhost:18010/tabs/course-v1:edX+DemoX+Demo_Course)
Feature: New React XBlock Editors
=================================
This allows an operator to enable the use of new React editors for the HTML, Video, and Problem XBlocks, all of which are provided here.
Requirements
------------
* ``edx-platform`` Django settings:
* ``COURSE_AUTHORING_MICROFRONTEND_URL``: must be set in the CMS environment and point to this MFE's deployment URL.
* ``edx-platform`` Waffle flags:
* ``new_core_editors.use_new_text_editor``: must be enabled for the new HTML Xblock editor to be used in Studio
* ``new_core_editors.use_new_video_editor``: must be enabled for the new Video Xblock editor to be used in Studio
* ``new_core_editors.use_new_problem_editor``: must be enabled for the new Problem Xblock editor to be used in Studio
Configuration
-------------
In additional to the standard settings, the following local configuration item is required:
* ``ENABLE_NEW_EDITOR_PAGES``: must be enabled in order to actually present the new XBlock editors
Feature Description
-------------------
When a corresponding waffle flag is set, upon editing a block in Studio, the view is rendered by this MFE instead of by the XBlock's authoring view. The user remains in Studio.
.. note::
The new editors themselves are currently implemented in a repository outside ``openedx``: `frontend-lib-content-components <https://github.com/edx/frontend-lib-content-components/>`_, a dependency of this MFE. This repository is slated to be moved to the ``openedx`` org, however.
Feature: New Proctoring Exams View
==================================
Requirements
------------
* ``edx-platform`` Django settings:
* ``COURSE_AUTHORING_MICROFRONTEND_URL``: must be set in the CMS environment and point to this MFE's deployment URL.
* ``ZENDESK_*``: necessary if automatic ZenDesk ticket creation is desired
* ``edx-platform`` Feature flags:
* ``ENABLE_EXAM_SETTINGS_HTML_VIEW``: this feature flag must be enabled for the link to the settings view to be shown
* `edx-exams <https://github.com/edx/edx-exams>`_: for this feature to work, the ``edx-exams`` IDA must be deployed and its API accessible by the browser
Configuration
-------------
In additional to the standard settings, the following local configuration item is required:
* ``EXAMS_BASE_URL``: URL to the ``edx-exams`` deployment
Feature Description
-------------------
In Studio, a new item ("Proctored Exam Settings") is added to "Other Course Settings" in the course's "Certificates" settings page. When clicked, this takes the author to the corresponding page in the Course Authoring MFE, where one can:
* Enable proctored exams for the course
* Allow opting out of proctored exams
* Select a proctoring provider
* Enable automatic creation of Zendesk tickets for "suspicious" proctored exam attempts
**********
Developing
**********
`Devstack <https://edx.readthedocs.io/projects/edx-installing-configuring-and-running/en/latest/installation/index.html>`_. If you start Devstack with ``make dev.up.studio`` that should give you everything you need as a companion to this frontend.
Installation and Startup
========================
------------------------
1. Clone the repo:
@@ -173,21 +32,8 @@ If your devstack includes the default Demo course, you can visit the following U
- `Proctored Exam Settings <http://localhost:2001/course/course-v1:edX+DemoX+Demo_Course/proctored-exam-settings>`_
- `Pages and Resources <http://localhost:2001/course/course-v1:edX+DemoX+Demo_Course/pages-and-resources>`_ (work in progress)
Troubleshooting
========================
* ``npm ERR! gyp ERR! build error`` while running npm install on Macs with M1 processors: Probably due to a compatibility issue of node-canvas with M1.
Run ``brew install pkg-config pixman cairo pango libpng jpeg giflib librsvg`` before ``npm install`` to get the correct versions of the dependencies.
If there is still an error, look for "no package [...] found" in the error message and install missing package via brew.
(https://github.com/Automattic/node-canvas/issues/1733)
*********
Deploying
*********
Production Build
================
----------------
The production build is created with ``npm run build``.

32911
package-lock.json generated

File diff suppressed because it is too large Load Diff

View File

@@ -34,10 +34,11 @@
},
"dependencies": {
"@edx/brand": "npm:@edx/brand-openedx@1.1.0",
"@edx/frontend-build": "^11.0.0",
"@edx/frontend-component-footer": "11.1.1",
"@edx/frontend-lib-content-components": "^1.131.0",
"@edx/frontend-lib-content-components": "^1.43.0",
"@edx/frontend-platform": "2.5.1",
"@edx/paragon": "^20.38.0",
"@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",
@@ -66,7 +67,7 @@
},
"devDependencies": {
"@edx/browserslist-config": "1.0.0",
"@edx/frontend-build": "12.8.38",
"@edx/frontend-build": "^11.0.0",
"@edx/reactifex": "^1.0.3",
"@testing-library/jest-dom": "5.16.4",
"@testing-library/react": "12.1.1",

View File

@@ -14,36 +14,7 @@ import { getCourseAppsApiStatus, getLoadingStatus } from './pages-and-resources/
import { RequestStatus } from './data/constants';
import Loading from './generic/Loading';
const AppHeader = ({
courseNumber, courseOrg, courseTitle, courseId,
}) => (
<Header
courseNumber={courseNumber}
courseOrg={courseOrg}
courseTitle={courseTitle}
courseId={courseId}
/>
);
AppHeader.propTypes = {
courseId: PropTypes.string.isRequired,
courseNumber: PropTypes.string,
courseOrg: PropTypes.string,
courseTitle: PropTypes.string.isRequired,
};
AppHeader.defaultProps = {
courseNumber: null,
courseOrg: null,
};
const AppFooter = () => (
<div className="mt-6">
<Footer />
</div>
);
const CourseAuthoringPage = ({ courseId, children }) => {
export default function CourseAuthoringPage({ courseId, children }) {
const dispatch = useDispatch();
useEffect(() => {
@@ -64,26 +35,33 @@ const CourseAuthoringPage = ({ courseId, children }) => {
);
}
const AppHeader = () => (
<Header
courseNumber={courseNumber}
courseOrg={courseOrg}
courseTitle={courseTitle}
courseId={courseId}
/>
);
const AppFooter = () => (
<div className="mt-6">
<Footer />
</div>
);
return (
<div className={pathname.includes('/editor/') ? '' : 'bg-light-200'}>
<div className="bg-light-200">
{/* While V2 Editors are tempoarily served from thier own pages
using url pattern containing /editor/,
we shouldn't have the header and footer on these pages.
This functionality will be removed in TNL-9591 */}
{inProgress ? !pathname.includes('/editor/') && <Loading />
: (
<AppHeader
courseNumber={courseNumber}
courseOrg={courseOrg}
courseTitle={courseTitle}
courseId={courseId}
/>
)}
{inProgress ? !pathname.includes('/editor/') && <Loading /> : <AppHeader />}
{children}
{!inProgress && <AppFooter />}
</div>
);
};
}
CourseAuthoringPage.propTypes = {
children: PropTypes.node,
@@ -93,5 +71,3 @@ CourseAuthoringPage.propTypes = {
CourseAuthoringPage.defaultProps = {
children: null,
};
export default CourseAuthoringPage;

View File

@@ -23,7 +23,7 @@ import EditorContainer from './editors/EditorContainer';
* can move the Header/Footer rendering to this component and likely pull the course detail loading
* in as well, and it'd feel a bit better-factored and the roles would feel more clear.
*/
const CourseAuthoringRoutes = ({ courseId }) => {
export default function CourseAuthoringRoutes({ courseId }) {
const { path } = useRouteMatch();
return (
<CourseAuthoringPage courseId={courseId}>
@@ -45,10 +45,8 @@ const CourseAuthoringRoutes = ({ courseId }) => {
</Switch>
</CourseAuthoringPage>
);
};
}
CourseAuthoringRoutes.propTypes = {
courseId: PropTypes.string.isRequired,
};
export default CourseAuthoringRoutes;

View File

@@ -56,7 +56,7 @@ const CollapsableEditor = ({
</Collapsible.Trigger>
<Collapsible.Body className="collapsible-body rounded px-0">{children}</Collapsible.Body>
</Collapsible.Advanced>
);
);
CollapsableEditor.propTypes = {
open: PropTypes.bool,

View File

@@ -5,21 +5,23 @@ import { getConfig } from '@edx/frontend-platform';
import messages from '../messages';
const ConnectionErrorAlert = ({ intl }) => (
<Alert variant="danger" data-testid="connectionErrorAlert">
<FormattedMessage
id="authoring.alert.error.connection"
defaultMessage="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."
values={{
function ConnectionErrorAlert({ intl }) {
return (
<Alert variant="danger" data-testid="connectionErrorAlert">
<FormattedMessage
id="authoring.alert.error.connection"
defaultMessage="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."
values={{
supportLink: (
<Alert.Link href={getConfig().SUPPORT_URL}>
{intl.formatMessage(messages.supportText)}
</Alert.Link>
),
}}
/>
</Alert>
/>
</Alert>
);
}
ConnectionErrorAlert.propTypes = {
intl: intlShape.isRequired,

View File

@@ -2,11 +2,11 @@ import React from 'react';
import PropTypes from 'prop-types';
import { Button, Card } from '@edx/paragon';
const ConfirmationPopup = ({
const DeletePopup = ({
label,
bodyText,
onConfirm,
confirmLabel,
onDelete,
deleteLabel,
onCancel,
cancelLabel,
}) => (
@@ -22,21 +22,21 @@ const ConfirmationPopup = ({
<Button variant="tertiary" onClick={onCancel}>
{cancelLabel}
</Button>
<Button variant="outline-brand" className="ml-2" onClick={onConfirm}>
{confirmLabel}
<Button variant="outline-brand" className="ml-2" onClick={onDelete}>
{deleteLabel}
</Button>
</Card.Footer>
</Card.Body>
</Card>
);
);
ConfirmationPopup.propTypes = {
DeletePopup.propTypes = {
label: PropTypes.string.isRequired,
bodyText: PropTypes.string.isRequired,
onConfirm: PropTypes.func.isRequired,
onDelete: PropTypes.func.isRequired,
onCancel: PropTypes.func.isRequired,
confirmLabel: PropTypes.string.isRequired,
deleteLabel: PropTypes.string.isRequired,
cancelLabel: PropTypes.string.isRequired,
};
export default ConfirmationPopup;
export default DeletePopup;

View File

@@ -2,36 +2,38 @@ import React from 'react';
import PropTypes from 'prop-types';
import { Form, TransitionReplace } from '@edx/paragon';
const FieldFeedback = ({
function FieldFeedback({
feedbackClasses,
transitionClasses,
errorCondition,
feedbackCondition,
feedbackMessage,
errorMessage,
}) => (
<>
<TransitionReplace className={transitionClasses}>
{feedbackCondition ? (
<React.Fragment key="open1">
<Form.Control.Feedback type="default" hasIcon={false} key={`${feedbackMessage}-feedback`}>
<div className={`small ${feedbackClasses}`}>{feedbackMessage}</div>
</Form.Control.Feedback>
</React.Fragment>
}) {
return (
<>
<TransitionReplace className={transitionClasses}>
{feedbackCondition ? (
<React.Fragment key="open1">
<Form.Control.Feedback type="default" hasIcon={false} key={`${feedbackMessage}-feedback`}>
<div className={`small ${feedbackClasses}`}>{feedbackMessage}</div>
</Form.Control.Feedback>
</React.Fragment>
) : <React.Fragment key="close1" />}
</TransitionReplace>
</TransitionReplace>
<TransitionReplace>
{errorCondition ? (
<React.Fragment key="open">
<Form.Control.Feedback type="invalid" hasIcon={false} key={`${errorMessage}-feedback`}>
<div className={`small ${feedbackClasses}`}>{errorMessage}</div>
</Form.Control.Feedback>
</React.Fragment>
<TransitionReplace>
{errorCondition ? (
<React.Fragment key="open">
<Form.Control.Feedback type="invalid" hasIcon={false} key={`${errorMessage}-feedback`}>
<div className={`small ${feedbackClasses}`}>{errorMessage}</div>
</Form.Control.Feedback>
</React.Fragment>
) : <React.Fragment key="close" />}
</TransitionReplace>
</>
</TransitionReplace>
</>
);
}
FieldFeedback.propTypes = {
errorCondition: PropTypes.bool.isRequired,

View File

@@ -4,7 +4,7 @@ import { Form, SwitchControl } from '@edx/paragon';
import './FormSwitchGroup.scss';
const FormSwitchGroup = ({
export default function FormSwitchGroup({
id,
name,
label,
@@ -13,8 +13,7 @@ const FormSwitchGroup = ({
onChange,
onBlur,
checked,
disabled,
}) => {
}) {
const helpTextId = `${id}HelpText`;
// Note that we use controlId here _and_ set some IDs and aria-describedby attributes manually.
@@ -37,7 +36,6 @@ const FormSwitchGroup = ({
onChange={onChange}
onBlur={onBlur}
checked={checked}
disabled={disabled}
/>
</div>
<Form.Text
@@ -49,7 +47,7 @@ const FormSwitchGroup = ({
</div>
</Form.Group>
);
};
}
FormSwitchGroup.propTypes = {
id: PropTypes.string.isRequired,
label: PropTypes.node.isRequired,
@@ -59,13 +57,9 @@ FormSwitchGroup.propTypes = {
onChange: PropTypes.func.isRequired,
onBlur: PropTypes.func,
checked: PropTypes.bool.isRequired,
disabled: PropTypes.bool,
};
FormSwitchGroup.defaultProps = {
className: null,
onBlur: null,
name: null,
disabled: false,
};
export default FormSwitchGroup;

View File

@@ -1,17 +1,16 @@
/* eslint-disable react/jsx-no-useless-fragment */
import { Form } from '@edx/paragon';
import { getIn, useFormikContext } from 'formik';
import PropTypes from 'prop-types';
import React from 'react';
import FormikErrorFeedback from './FormikErrorFeedback';
const FormikControl = ({
function FormikControl({
name,
label,
help,
className,
...params
}) => {
}) {
const {
touched, errors, handleChange, handleBlur, setFieldError,
} = useFormikContext();
@@ -36,7 +35,7 @@ const FormikControl = ({
</FormikErrorFeedback>
</Form.Group>
);
};
}
FormikControl.propTypes = {
name: PropTypes.element.isRequired,

View File

@@ -3,7 +3,7 @@ import { getIn, useFormikContext } from 'formik';
import PropTypes from 'prop-types';
import React from 'react';
const FormikErrorFeedback = ({ name, children }) => {
function FormikErrorFeedback({ name, children }) {
const { touched, errors } = useFormikContext();
const fieldTouched = getIn(touched, name);
const fieldError = getIn(errors, name);
@@ -23,7 +23,7 @@ const FormikErrorFeedback = ({ name, children }) => {
)}
</TransitionReplace>
);
};
}
FormikErrorFeedback.propTypes = {
name: PropTypes.string.isRequired,

View File

@@ -2,28 +2,28 @@ import React from 'react';
import { Spinner } from '@edx/paragon';
import { FormattedMessage } from '@edx/frontend-platform/i18n';
const Loading = () => (
<div
className="d-flex justify-content-center align-items-center flex-column"
style={{
export default function Loading() {
return (
<div
className="d-flex justify-content-center align-items-center flex-column"
style={{
height: '50vh',
}}
>
<Spinner
animation="border"
role="status"
variant="primary"
screenReaderText={(
<span className="sr-only">
<FormattedMessage
id="authoring.loading"
defaultMessage="Loading..."
description="Screen-reader message for when a page is loading."
/>
</span>
>
<Spinner
animation="border"
role="status"
variant="primary"
screenReaderText={(
<span className="sr-only">
<FormattedMessage
id="authoring.loading"
defaultMessage="Loading..."
description="Screen-reader message for when a page is loading."
/>
</span>
)}
/>
</div>
/>
</div>
);
export default Loading;
}

View File

@@ -2,13 +2,15 @@ import React from 'react';
import { FormattedMessage } from '@edx/frontend-platform/i18n';
import { Alert } from '@edx/paragon';
const PermissionDeniedAlert = () => (
<Alert variant="danger" data-testid="permissionDeniedAlert">
<FormattedMessage
id="authoring.alert.error.permission"
defaultMessage="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."
/>
</Alert>
function PermissionDeniedAlert() {
return (
<Alert variant="danger" data-testid="permissionDeniedAlert">
<FormattedMessage
id="authoring.alert.error.permission"
defaultMessage="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."
/>
</Alert>
);
}
export default PermissionDeniedAlert;

View File

@@ -5,21 +5,23 @@ import { getConfig } from '@edx/frontend-platform';
import messages from '../messages';
const SaveFormConnectionErrorAlert = ({ intl }) => (
<Alert variant="danger" data-testid="connectionErrorAlert">
<FormattedMessage
id="authoring.alert.save.error.connection"
defaultMessage="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."
values={{
function SaveFormConnectionErrorAlert({ intl }) {
return (
<Alert variant="danger" data-testid="connectionErrorAlert">
<FormattedMessage
id="authoring.alert.save.error.connection"
defaultMessage="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."
values={{
supportLink: (
<Alert.Link href={getConfig().SUPPORT_URL}>
{intl.formatMessage(messages.supportText)}
</Alert.Link>
),
}}
/>
</Alert>
/>
</Alert>
);
}
SaveFormConnectionErrorAlert.propTypes = {
intl: intlShape.isRequired,

View File

@@ -3,7 +3,6 @@
"authoring.loading": "التحميل جارٍ...",
"authoring.alert.error.permission": "لست مخوّلا بمشاهدة هذه الصفحة. إن كنت ترى أن لديك حقًا في ذلك، فيرجى التواصل مع مدير فريق مساقك ليمنحك حق الوصول.",
"authoring.alert.save.error.connection": "واجهنا خطأ تقنيًا أثناء تطبيق التغييرات. قد تكون هذه مشكلة عارضة، لذا يرجى المحاولة مجددًا خلال بضع دقائق. إن استمرت المشكلة فيرجى الذهاب إلى {support_link} للحصول على المساعدة.",
"course-authoring.page.title": "Course Authoring | {siteName}",
"authoring.alert.support.text": "صفحة الدعم",
"course-authoring.pages-resources.app-settings-modal.button.cancel": "إلغاء",
"course-authoring.pages-resources.app-settings-modal.button.save": "حفظ",

View File

@@ -3,7 +3,6 @@
"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.",
"course-authoring.page.title": "Course Authoring | {siteName}",
"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",

View File

@@ -3,7 +3,6 @@
"authoring.loading": "Cargando...",
"authoring.alert.error.permission": "No te encuentras autorizado para ingresar a esta página. Si crees que deberías tener acceso, por favor contacta al equipo administrativo del curso para solicitar acceso.",
"authoring.alert.save.error.connection": "Hemos detectado un error técnico al cargar esta página. Esto puede ser un problema temporal, así que por favor intente nuevamente en unos minutos. Si el problema persiste, por favor solicite ayuda en el siguiente enlace {supportLink}",
"course-authoring.page.title": "Course Authoring | {siteName}",
"authoring.alert.support.text": "Página de soporte",
"course-authoring.pages-resources.app-settings-modal.button.cancel": "Cancelar",
"course-authoring.pages-resources.app-settings-modal.button.save": "Guardar",

View File

@@ -3,7 +3,6 @@
"authoring.loading": "Chargement...",
"authoring.alert.error.permission": "Vous n'êtes pas autorisé à afficher cette page. Si vous croyez que vous devriez avoir accès à cette page, veuillez contacter l'équipe administrative du cours pour obtenir la permission.",
"authoring.alert.save.error.connection": "Nous avons rencontré une erreur technique lors de l'application des modifications. Cela peut être un problème temporaire, veuillez donc réessayer dans quelques minutes. Si le problème persiste, accédez à {support_link} pour obtenir de l'aide.",
"course-authoring.page.title": "Course Authoring | {siteName}",
"authoring.alert.support.text": "Page de support",
"course-authoring.pages-resources.app-settings-modal.button.cancel": "Annuler",
"course-authoring.pages-resources.app-settings-modal.button.save": "Enregistrer",

View File

@@ -3,7 +3,6 @@
"authoring.loading": "Chargement...",
"authoring.alert.error.permission": "Vous n'êtes pas autorisé à afficher cette page. Si vous croyez que vous devriez avoir accès à cette page, veuillez contacter l'équipe administrative du cours pour obtenir la permission.",
"authoring.alert.save.error.connection": "Nous avons rencontré une erreur technique lors de l'application des modifications. Cela peut être un problème temporaire, veuillez donc réessayer dans quelques minutes. Si le problème persiste, accédez à {support_link} pour obtenir de l'aide.",
"course-authoring.page.title": "Création de cours | {siteName}",
"authoring.alert.support.text": "Page de support",
"course-authoring.pages-resources.app-settings-modal.button.cancel": "Annuler",
"course-authoring.pages-resources.app-settings-modal.button.save": "Sauvegarder",

View File

@@ -3,7 +3,6 @@
"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.",
"course-authoring.page.title": "Course Authoring | {siteName}",
"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",

View File

@@ -3,7 +3,6 @@
"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.",
"course-authoring.page.title": "Course Authoring | {siteName}",
"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",

View File

@@ -3,7 +3,6 @@
"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.",
"course-authoring.page.title": "Course Authoring | {siteName}",
"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",

View File

@@ -3,7 +3,6 @@
"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.",
"course-authoring.page.title": "Course Authoring | {siteName}",
"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",

View File

@@ -3,7 +3,6 @@
"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.",
"course-authoring.page.title": "Course Authoring | {siteName}",
"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",

View File

@@ -3,7 +3,6 @@
"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.",
"course-authoring.page.title": "Course Authoring | {siteName}",
"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",

View File

@@ -53,7 +53,6 @@ initialize({
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',
BBB_LEARN_MORE_URL: process.env.BBB_LEARN_MORE_URL || '',
}, 'CourseAuthoringConfig');
},
},

View File

@@ -17,7 +17,7 @@ import { getLoadingStatus } from './data/selectors';
import PagesAndResourcesProvider from './PagesAndResourcesProvider';
import { RequestStatus } from '../data/constants';
const PagesAndResources = ({ courseId, intl }) => {
function PagesAndResources({ courseId, intl }) {
const { path, url } = useRouteMatch();
const dispatch = useDispatch();
@@ -34,7 +34,6 @@ const PagesAndResources = ({ courseId, intl }) => {
// Each page here is driven by a course app
const pages = useModels('courseApps', courseAppIds);
if (loadingStatus === RequestStatus.IN_PROGRESS) {
// eslint-disable-next-line react/jsx-no-useless-fragment
return <></>;
}
@@ -90,7 +89,7 @@ const PagesAndResources = ({ courseId, intl }) => {
</main>
</PagesAndResourcesProvider>
);
};
}
PagesAndResources.propTypes = {
courseId: PropTypes.string.isRequired,

View File

@@ -1,25 +1,22 @@
import React, { useMemo } from 'react';
import React from 'react';
import PropTypes from 'prop-types';
export const PagesAndResourcesContext = React.createContext({});
const PagesAndResourcesProvider = ({ courseId, children }) => {
const contextValue = useMemo(() => ({
courseId,
path: `/course/${courseId}/pages-and-resources`,
}), []);
export default function PagesAndResourcesProvider({ courseId, children }) {
return (
<PagesAndResourcesContext.Provider
value={contextValue}
value={{
courseId,
path: `/course/${courseId}/pages-and-resources`,
}}
>
{children}
</PagesAndResourcesContext.Provider>
);
};
}
PagesAndResourcesProvider.propTypes = {
courseId: PropTypes.string.isRequired,
children: PropTypes.node.isRequired,
};
export default PagesAndResourcesProvider;

View File

@@ -33,19 +33,21 @@ import AppConfigFormDivider from '../discussions/app-config-form/apps/shared/App
import { PagesAndResourcesContext } from '../PagesAndResourcesProvider';
import messages from './messages';
const AppSettingsForm = ({
function AppSettingsForm({
formikProps, children, showForm,
}) => children && (
<TransitionReplace>
{showForm ? (
<React.Fragment key="app-enabled">
{children(formikProps)}
</React.Fragment>
}) {
return children && (
<TransitionReplace>
{showForm ? (
<React.Fragment key="app-enabled">
{children(formikProps)}
</React.Fragment>
) : (
<React.Fragment key="app-disabled" />
)}
</TransitionReplace>
</TransitionReplace>
);
}
AppSettingsForm.propTypes = {
// Ignore the warning here since we're just passing along the props as-is and the child component should validate
@@ -59,36 +61,38 @@ AppSettingsForm.defaultProps = {
children: null,
};
const AppSettingsModalBase = ({
function AppSettingsModalBase({
intl, title, onClose, variant, isMobile, children, footer,
}) => (
<ModalDialog
title={title}
isOpen
onClose={onClose}
size="lg"
variant={variant}
hasCloseButton={isMobile}
isFullscreenOnMobile
>
<ModalDialog.Header>
<ModalDialog.Title data-testid="modal-title">
{title}
</ModalDialog.Title>
</ModalDialog.Header>
<ModalDialog.Body>
{children}
</ModalDialog.Body>
<ModalDialog.Footer className="p-4">
<ActionRow>
<ModalDialog.CloseButton variant="tertiary">
{intl.formatMessage(messages.cancel)}
</ModalDialog.CloseButton>
{footer}
</ActionRow>
</ModalDialog.Footer>
</ModalDialog>
}) {
return (
<ModalDialog
title={title}
isOpen
onClose={onClose}
size="lg"
variant={variant}
hasCloseButton={isMobile}
isFullscreenOnMobile
>
<ModalDialog.Header>
<ModalDialog.Title data-testid="modal-title">
{title}
</ModalDialog.Title>
</ModalDialog.Header>
<ModalDialog.Body>
{children}
</ModalDialog.Body>
<ModalDialog.Footer className="p-4">
<ActionRow>
<ModalDialog.CloseButton variant="tertiary">
{intl.formatMessage(messages.cancel)}
</ModalDialog.CloseButton>
{footer}
</ActionRow>
</ModalDialog.Footer>
</ModalDialog>
);
}
AppSettingsModalBase.propTypes = {
intl: intlShape.isRequired,
@@ -104,7 +108,7 @@ AppSettingsModalBase.defaultProps = {
footer: null,
};
const AppSettingsModal = ({
function AppSettingsModal({
intl,
appId,
title,
@@ -118,7 +122,7 @@ const AppSettingsModal = ({
enableAppHelp,
learnMoreText,
enableReinitialize,
}) => {
}) {
const { courseId } = useContext(PagesAndResourcesContext);
const loadingStatus = useSelector(getLoadingStatus);
const updateSettingsRequestStatus = useSelector(getSavingStatus);
@@ -267,7 +271,7 @@ const AppSettingsModal = ({
{loadingStatus === RequestStatus.DENIED && <PermissionDeniedAlert />}
</AppSettingsModalBase>
);
};
}
AppSettingsModal.propTypes = {
intl: intlShape.isRequired,
@@ -275,8 +279,8 @@ AppSettingsModal.propTypes = {
appId: PropTypes.string.isRequired,
children: PropTypes.func,
onSettingsSave: PropTypes.func,
initialValues: PropTypes.shape({}),
validationSchema: PropTypes.shape({}),
initialValues: PropTypes.objectOf(PropTypes.any),
validationSchema: PropTypes.objectOf(PropTypes.any),
onClose: PropTypes.func.isRequired,
enableAppLabel: PropTypes.string.isRequired,
enableAppHelp: PropTypes.string.isRequired,

View File

@@ -6,16 +6,18 @@ import { injectIntl, intlShape } from '@edx/frontend-platform/i18n';
import AppSettingsModal from '../app-settings-modal/AppSettingsModal';
import messages from './messages';
const CalculatorSettings = ({ intl, onClose }) => (
<AppSettingsModal
appId="calculator"
title={intl.formatMessage(messages.heading)}
enableAppHelp={intl.formatMessage(messages.enableCalculatorHelp)}
enableAppLabel={intl.formatMessage(messages.enableCalculatorLabel)}
learnMoreText={intl.formatMessage(messages.enableCalculatorLink)}
onClose={onClose}
/>
function CalculatorSettings({ intl, onClose }) {
return (
<AppSettingsModal
appId="calculator"
title={intl.formatMessage(messages.heading)}
enableAppHelp={intl.formatMessage(messages.enableCalculatorHelp)}
enableAppLabel={intl.formatMessage(messages.enableCalculatorLabel)}
learnMoreText={intl.formatMessage(messages.enableCalculatorLink)}
onClose={onClose}
/>
);
}
CalculatorSettings.propTypes = {
intl: intlShape.isRequired,

View File

@@ -1,22 +1,21 @@
import React, { useMemo } from 'react';
import React from 'react';
import PropTypes from 'prop-types';
export const DiscussionsContext = React.createContext({});
const DiscussionsProvider = ({ children, path }) => {
const contextValue = useMemo(() => ({ path }), []);
export default function DiscussionsProvider({ children, path }) {
return (
<DiscussionsContext.Provider
value={contextValue}
value={{
path,
}}
>
{children}
</DiscussionsContext.Provider>
);
};
}
DiscussionsProvider.propTypes = {
children: PropTypes.node.isRequired,
path: PropTypes.string.isRequired,
};
export default DiscussionsProvider;

View File

@@ -28,7 +28,7 @@ import Loading from '../../generic/Loading';
const SELECTION_STEP = 'selection';
const SETTINGS_STEP = 'settings';
const DiscussionsSettings = ({ courseId, intl }) => {
function DiscussionsSettings({ courseId, intl }) {
const dispatch = useDispatch();
const { path: pagesAndResourcesPath } = useContext(PagesAndResourcesContext);
const { status, hasValidationError } = useSelector(state => state.discussions);
@@ -145,7 +145,7 @@ const DiscussionsSettings = ({ courseId, intl }) => {
</AppConfigForm.Provider>
</DiscussionsProvider>
);
};
}
DiscussionsSettings.propTypes = {
courseId: PropTypes.string.isRequired,

View File

@@ -28,9 +28,9 @@ import AppConfigFormProvider, { AppConfigFormContext } from './AppConfigFormProv
import AppConfigFormSaveButton from './AppConfigFormSaveButton';
import messages from './messages';
const AppConfigForm = ({
function AppConfigForm({
courseId, intl,
}) => {
}) {
const dispatch = useDispatch();
const { formRef } = useContext(AppConfigFormContext);
@@ -141,7 +141,7 @@ const AppConfigForm = ({
</ModalDialog>
</Container>
);
};
}
AppConfigForm.propTypes = {
courseId: PropTypes.string.isRequired,

View File

@@ -1,23 +1,22 @@
import React, { useMemo } from 'react';
import React from 'react';
import PropTypes from 'prop-types';
export const AppConfigFormContext = React.createContext({});
const AppConfigFormProvider = ({ children }) => {
export default function AppConfigFormProvider({ children }) {
const formRef = React.createRef();
const contextValue = useMemo(() => ({ formRef }), []);
return (
<AppConfigFormContext.Provider
value={contextValue}
value={{
formRef,
}}
>
{children}
</AppConfigFormContext.Provider>
);
};
}
AppConfigFormProvider.propTypes = {
children: PropTypes.node.isRequired,
};
export default AppConfigFormProvider;

View File

@@ -10,7 +10,7 @@ import { SAVING } from '../data/slice';
import { AppConfigFormContext } from './AppConfigFormProvider';
import { useModel } from '../../../generic/model-store';
const AppConfigFormSaveButton = ({ intl, labelText }) => {
function AppConfigFormSaveButton({ intl, labelText }) {
const saveStatus = useSelector(state => state.discussions.saveStatus);
const { selectedAppId } = useSelector((state) => state.discussions);
@@ -45,7 +45,7 @@ const AppConfigFormSaveButton = ({ intl, labelText }) => {
/>
)
);
};
}
AppConfigFormSaveButton.propTypes = {
intl: intlShape.isRequired,

View File

@@ -18,7 +18,7 @@ import { useModel } from '../../../../../generic/model-store';
ensureConfig(['SITE_NAME', 'SUPPORT_EMAIL'], 'LTI Config Form');
const LtiConfigForm = ({ onSubmit, intl, formRef }) => {
function LtiConfigForm({ onSubmit, intl, formRef }) {
const dispatch = useDispatch();
const { selectedAppId, piiConfig } = useSelector((state) => state.discussions);
@@ -177,7 +177,7 @@ const LtiConfigForm = ({ onSubmit, intl, formRef }) => {
/>
</Card>
);
};
}
LtiConfigForm.propTypes = {
intl: intlShape.isRequired,

View File

@@ -21,9 +21,9 @@ import OpenedXConfigFormProvider from './OpenedXConfigFormProvider';
setupYupExtensions();
const OpenedXConfigForm = ({
function OpenedXConfigForm({
onSubmit, formRef, intl, legacy,
}) => {
}) {
const {
selectedAppId, enableGradedUnits, discussionTopicIds, divideDiscussionIds,
} = useSelector(state => state.discussions);
@@ -37,7 +37,7 @@ const OpenedXConfigForm = ({
unitLevelVisibility: true,
allowAnonymousPostsPeers: appConfigObj?.allowAnonymousPostsPeers || false,
reportedContentEmailNotifications: appConfigObj?.reportedContentEmailNotifications || false,
enableReportedContentEmailNotifications: Boolean(appConfigObj?.enableReportedContentEmailNotifications) || false,
enableReportedContentEmailNotifications: appConfigObj?.enableReportedContentEmailNotifications || false,
blackoutDates: appConfigObj?.blackoutDates || [],
discussionTopics: discussionTopicsModel || [],
divideByCohorts: appConfigObj?.divideByCohorts || false,
@@ -52,7 +52,6 @@ const OpenedXConfigForm = ({
groupAtSubsection: Yup.bool().default(false),
};
const validationSchema = Yup.object().shape({
// eslint-disable-next-line react/forbid-prop-types
blackoutDates: Yup.array(
Yup.object().shape({
startDate: Yup.string()
@@ -77,7 +76,6 @@ const OpenedXConfigForm = ({
}),
}),
),
// eslint-disable-next-line react/forbid-prop-types
discussionTopics: Yup.array(
Yup.object({
name: Yup.string().required(intl.formatMessage(messages.discussionTopicRequired)),
@@ -147,7 +145,7 @@ const OpenedXConfigForm = ({
}}
</Formik>
);
};
}
OpenedXConfigForm.propTypes = {
legacy: PropTypes.bool.isRequired,

View File

@@ -50,7 +50,6 @@ const defaultAppConfig = (divideDiscussionIds = []) => ({
enableReportedContentEmailNotifications: false,
allowDivisionByUnit: false,
blackoutDates: [],
cohortsEnabled: false,
});
describe('OpenedXConfigForm', () => {
let axiosMock;
@@ -142,17 +141,14 @@ describe('OpenedXConfigForm', () => {
...legacyApiResponse.plugin_configuration,
reported_content_email_notifications_flag: true,
divided_course_wide_discussions: [],
available_division_schemes: [],
},
});
createComponent();
const { divideDiscussionIds } = defaultAppConfig(['13f106c6-6735-4e84-b097-0456cff55960', 'course']);
// DivisionByGroupFields
expect(container.querySelector('#alert')).toBeInTheDocument();
expect(container.querySelector('#divideByCohorts')).toBeInTheDocument();
expect(container.querySelector('#divideByCohorts')).toBeDisabled();
expect(container.querySelector('#divideByCohorts')).not.toBeChecked();
expect(container.querySelector('#divideCourseTopicsByCohorts')).not.toBeInTheDocument();
divideDiscussionIds.forEach(id => expect(
@@ -183,7 +179,6 @@ describe('OpenedXConfigForm', () => {
reported_content_email_notifications_flag: true,
always_divide_inline_discussions: true,
divided_course_wide_discussions: [],
available_division_schemes: ['cohorts'],
},
});
createComponent();
@@ -191,10 +186,14 @@ describe('OpenedXConfigForm', () => {
// DivisionByGroupFields
expect(container.querySelector('#divideByCohorts')).toBeInTheDocument();
expect(container.querySelector('#divideByCohorts')).not.toBeChecked();
expect(container.querySelector('#divideByCohorts')).toBeChecked();
expect(
container.querySelector('#divideCourseTopicsByCohorts'),
).not.toBeInTheDocument();
).toBeInTheDocument();
expect(
container.querySelector('#divideCourseTopicsByCohorts'),
).not.toBeChecked();
divideDiscussionIds.forEach(id => expect(
container.querySelector(`#checkbox-${id}`),
).not.toBeInTheDocument());
@@ -212,8 +211,7 @@ describe('OpenedXConfigForm', () => {
expect(container.querySelector('#reportedContentEmailNotifications')).toBeChecked();
});
test(
'folded discussion topics are in the DOM when divideByCohorts and divideCourseWideTopics are enabled',
test('folded discussion topics are in the DOM when divideByCohorts and divideCourseWideTopics are enabled',
async () => {
await mockStore({
...legacyApiResponse,
@@ -231,13 +229,15 @@ describe('OpenedXConfigForm', () => {
// DivisionByGroupFields
expect(container.querySelector('#divideByCohorts')).toBeInTheDocument();
expect(container.querySelector('#divideByCohorts')).not.toBeChecked();
expect(container.querySelector('#divideCourseTopicsByCohorts')).not.toBeInTheDocument();
expect(container.querySelector('#divideByCohorts')).toBeChecked();
expect(container.querySelector('#divideCourseTopicsByCohorts')).toBeInTheDocument();
expect(container.querySelector('#divideCourseTopicsByCohorts')).toBeChecked();
divideDiscussionIds.forEach(id => {
expect(container.querySelector(`#checkbox-${id}`)).not.toBeInTheDocument();
expect(container.querySelector(`#checkbox-${id}`)).toBeInTheDocument();
expect(container.querySelector(`#checkbox-${id}`)).toBeChecked();
});
},
);
});
const updateTopicName = async (topicId, topicName) => {
const topicCard = queryByTestId(container, topicId);
@@ -265,8 +265,7 @@ describe('OpenedXConfigForm', () => {
expect(store.getState().discussions.hasValidationError).toBe(expectExists);
};
test(
'show required error on field when leaving empty topic name',
test('show required error on field when leaving empty topic name',
async () => {
await mockStore(legacyApiResponse);
createComponent();
@@ -275,8 +274,7 @@ describe('OpenedXConfigForm', () => {
await waitForElementToBeRemoved(queryByText(topicCard, messages.addTopicHelpText.defaultMessage));
assertTopicNameRequiredValidation(topicCard);
assertHasErrorValidation();
},
);
});
test('check field is not collapsible in case of error', async () => {
await mockStore(legacyApiResponse);

View File

@@ -5,7 +5,7 @@ import { updateValidationStatus } from '../../../data/slice';
export const OpenedXConfigFormContext = createContext({});
const OpenedXConfigFormProvider = ({ children, value }) => {
export default function OpenedXConfigFormProvider({ children, value }) {
const dispatch = useDispatch();
useEffect(() => {
@@ -17,7 +17,7 @@ const OpenedXConfigFormProvider = ({ children, value }) => {
{children}
</OpenedXConfigFormContext.Provider>
);
};
}
OpenedXConfigFormProvider.propTypes = {
children: PropTypes.node.isRequired,
@@ -33,5 +33,3 @@ OpenedXConfigFormProvider.propTypes = {
isFormInvalid: PropTypes.bool,
}).isRequired,
};
export default OpenedXConfigFormProvider;

View File

@@ -5,25 +5,27 @@ import FormSwitchGroup from '../../../../../generic/FormSwitchGroup';
import messages from '../../messages';
import AppConfigFormDivider from './AppConfigFormDivider';
const AnonymousPostingFields = ({
function AnonymousPostingFields({
onBlur,
onChange,
intl,
values,
}) => (
<>
<h5 className="mt-4 text-gray-500">{intl.formatMessage(messages.anonymousPosting)}</h5>
<AppConfigFormDivider />
<FormSwitchGroup
onChange={onChange}
onBlur={onBlur}
id="allowAnonymousPostsPeers"
checked={values.allowAnonymousPostsPeers}
label={intl.formatMessage(messages.allowAnonymousPostsPeersLabel)}
helpText={intl.formatMessage(messages.allowAnonymousPostsPeersHelp)}
/>
</>
}) {
return (
<>
<h5 className="mt-4 text-gray-500">{intl.formatMessage(messages.anonymousPosting)}</h5>
<AppConfigFormDivider />
<FormSwitchGroup
onChange={onChange}
onBlur={onBlur}
id="allowAnonymousPostsPeers"
checked={values.allowAnonymousPostsPeers}
label={intl.formatMessage(messages.allowAnonymousPostsPeersLabel)}
helpText={intl.formatMessage(messages.allowAnonymousPostsPeersHelp)}
/>
</>
);
}
AnonymousPostingFields.propTypes = {
onBlur: PropTypes.func.isRequired,

View File

@@ -2,20 +2,22 @@ import classNames from 'classnames';
import PropTypes from 'prop-types';
import React from 'react';
const AppConfigFormDivider = ({ thick, marginAdj }) => (
<hr
className={classNames(
export default function AppConfigFormDivider({ thick, marginAdj }) {
return (
<hr
className={classNames(
'my-2 mx-n4 border-light-300',
{
[`mx-sm-n${marginAdj.sm}`]: marginAdj.sm !== null,
[`mx-n${marginAdj.default}`]: marginAdj.default !== null,
},
)}
style={{
style={{
borderTopWidth: thick ? '3px' : '1px',
}}
/>
/>
);
}
AppConfigFormDivider.propTypes = {
thick: PropTypes.bool,
@@ -32,5 +34,3 @@ AppConfigFormDivider.defaultProps = {
sm: 5,
},
};
export default AppConfigFormDivider;

View File

@@ -9,13 +9,13 @@ import AppConfigFormDivider from './AppConfigFormDivider';
import messages from '../lti/messages';
const AppExternalLinks = ({
function AppExternalLinks({
externalLinks,
intl,
providerName,
showLaunchIcon,
customClasses,
}) => {
}) {
const { contactEmail, ...links } = externalLinks;
const linkTypes = Object.keys(links).filter(key => links[key]);
return (
@@ -60,7 +60,7 @@ const AppExternalLinks = ({
)}
</div>
);
};
}
AppExternalLinks.propTypes = {
externalLinks: PropTypes.shape({

View File

@@ -1,12 +1,8 @@
import React, { useEffect, useContext } from 'react';
import { injectIntl, intlShape } from '@edx/frontend-platform/i18n';
import {
Form, TransitionReplace, Hyperlink, Alert,
} from '@edx/paragon';
import { AppContext } from '@edx/frontend-platform/react';
import { Form, TransitionReplace } from '@edx/paragon';
import { FieldArray, useFormikContext } from 'formik';
import _ from 'lodash';
import { useParams } from 'react-router-dom';
import FormSwitchGroup from '../../../../../generic/FormSwitchGroup';
import messages from '../../messages';
import AppConfigFormDivider from './AppConfigFormDivider';
@@ -25,13 +21,8 @@ const DivisionByGroupFields = ({ intl }) => {
discussionTopics,
divideByCohorts,
divideCourseTopicsByCohorts,
cohortsEnabled,
} = appConfig;
const { courseId } = useParams();
const { config } = useContext(AppContext);
const learningCourseURL = `${config.LMS_BASE_URL}/courses/${courseId}/instructor`;
useEffect(() => {
if (divideByCohorts) {
if (!divideCourseTopicsByCohorts && _.size(discussionTopics) !== _.size(divideDiscussionIds)) {
@@ -65,30 +56,20 @@ const DivisionByGroupFields = ({ intl }) => {
return (
<>
<h5 className="text-gray-500 mb-4 mt-4">
<h5 className="text-gray-500 mb-2 mt-4">
{intl.formatMessage(messages.divisionByGroup)}
</h5>
{!cohortsEnabled
&& (
<Alert className="bg-light-200 font-weight-normal h5" id="alert">
{intl.formatMessage(messages.cohortsEnabled)}
<Hyperlink destination={learningCourseURL} target="_blank">
{intl.formatMessage(messages.instructorDashboard)}
</Hyperlink>
</Alert>
)}
<FormSwitchGroup
onChange={handleChange}
className="mt-2"
onBlur={handleBlur}
id="divideByCohorts"
checked={cohortsEnabled === false ? cohortsEnabled : divideByCohorts}
checked={divideByCohorts}
label={intl.formatMessage(messages.divideByCohortsLabel)}
helpText={intl.formatMessage(messages.divideByCohortsHelp)}
disabled={!cohortsEnabled}
/>
<TransitionReplace>
{(divideByCohorts && cohortsEnabled) ? (
{divideByCohorts ? (
<React.Fragment key="open">
<AppConfigFormDivider />
<FormSwitchGroup

View File

@@ -1,57 +1,27 @@
import React, { useState } from 'react';
import React from 'react';
import PropTypes from 'prop-types';
import { injectIntl, intlShape } from '@edx/frontend-platform/i18n';
import { useFormikContext } from 'formik';
import FormSwitchGroup from '../../../../../generic/FormSwitchGroup';
import messages from '../../messages';
import AppConfigFormDivider from './AppConfigFormDivider';
import ConfirmationPopup from '../../../../../generic/ConfirmationPopup';
const InContextDiscussionFields = ({
function InContextDiscussionFields({
onBlur,
onChange,
intl,
values,
}) => {
const {
setFieldValue,
} = useFormikContext();
const [showPopup, setShowPopup] = useState(false);
const handleConfirmation = () => {
setFieldValue('enableGradedUnits', !values.enableGradedUnits);
setShowPopup(false);
};
}) {
return (
<>
<h5 className="text-gray-500 mt-4">{intl.formatMessage(messages.visibilityInContext)}</h5>
{showPopup
? (
<ConfirmationPopup
label={values.enableGradedUnits
? intl.formatMessage(messages.cancelEnableDiscussionsLabel)
: intl.formatMessage(messages.confirmEnableDiscussionsLabel)}
bodyText={values.enableGradedUnits
? intl.formatMessage(messages.cancelEnableDiscussions)
: intl.formatMessage(messages.confirmEnableDiscussions)}
onConfirm={handleConfirmation}
confirmLabel={intl.formatMessage(messages.confirm)}
onCancel={() => setShowPopup(false)}
cancelLabel={intl.formatMessage(messages.cancelButton)}
/>
)
: (
<FormSwitchGroup
onChange={() => setShowPopup(true)}
onBlur={onBlur}
id="enableGradedUnits"
checked={values.enableGradedUnits}
label={intl.formatMessage(messages.gradedUnitPagesLabel)}
helpText={intl.formatMessage(messages.gradedUnitPagesHelp)}
/>
)}
<FormSwitchGroup
onChange={onChange}
onBlur={onBlur}
id="enableGradedUnits"
checked={values.enableGradedUnits}
label={intl.formatMessage(messages.gradedUnitPagesLabel)}
helpText={intl.formatMessage(messages.gradedUnitPagesHelp)}
/>
<AppConfigFormDivider />
<FormSwitchGroup
onChange={onChange}
@@ -63,7 +33,7 @@ const InContextDiscussionFields = ({
/>
</>
);
};
}
InContextDiscussionFields.propTypes = {
onBlur: PropTypes.func.isRequired,

View File

@@ -5,7 +5,7 @@ import FormSwitchGroup from '../../../../../generic/FormSwitchGroup';
import AppConfigFormDivider from './AppConfigFormDivider';
import messages from '../../messages';
const ReportedContentEmailNotifications = ({ intl }) => {
function ReportedContentEmailNotifications({ intl }) {
const {
handleChange,
handleBlur,
@@ -13,7 +13,6 @@ const ReportedContentEmailNotifications = ({ intl }) => {
} = useFormikContext();
return (
// eslint-disable-next-line react/jsx-no-useless-fragment
<>
{values.enableReportedContentEmailNotifications && (
<div>
@@ -32,7 +31,7 @@ const ReportedContentEmailNotifications = ({ intl }) => {
)}
</>
);
};
}
ReportedContentEmailNotifications.propTypes = {
intl: intlShape.isRequired,

View File

@@ -14,7 +14,7 @@ import {
badgeVariant,
} from '../../../../data/constants';
import CollapsableEditor from '../../../../../../generic/CollapsableEditor';
import ConfirmationPopup from '../../../../../../generic/ConfirmationPopup';
import DeletePopup from '../../../../../../generic/DeletePopup';
import CollapseCardHeading from './CollapseCardHeading';
const BlackoutDatesItem = ({
@@ -51,13 +51,13 @@ const BlackoutDatesItem = ({
if (showDeletePopup) {
return (
<ConfirmationPopup
<DeletePopup
label={blackoutDate.status === constants.ACTIVE
? intl.formatMessage(messages.activeBlackoutDatesDeletionLabel)
: intl.formatMessage(messages.blackoutDatesDeletionLabel)}
bodyText={intl.formatMessage(deleteHelperText[blackoutDate.status])}
onConfirm={onDelete}
confirmLabel={intl.formatMessage(messages.deleteButton)}
onDelete={onDelete}
deleteLabel={intl.formatMessage(messages.deleteButton)}
onCancel={() => setShowDeletePopup(false)}
cancelLabel={intl.formatMessage(messages.cancelButton)}
/>

View File

@@ -28,36 +28,11 @@ const messages = defineMessages({
defaultMessage: 'Cancel',
description: 'Button allowing the user to return to discussion provider configurations.',
},
confirm: {
id: 'authoring.discussions.confirm',
defaultMessage: 'Confirm',
description: 'Button allowing the user to confirm Confirmation.',
},
confirmConfigurationChange: {
id: 'authoring.discussions.confirmConfigurationChange',
defaultMessage: 'Are you sure you want to change the discussion settings?',
description: 'Asks the user whether he/she really wants to change settings.',
},
confirmEnableDiscussionsLabel: {
id: 'authoring.discussions.confirmEnableDiscussionsLabel',
defaultMessage: 'Enable discussions on units in graded subsections?',
description: 'Asks the user whether he/she really wants to enable discussions on units in graded subsections.',
},
cancelEnableDiscussionsLabel: {
id: 'authoring.discussions.cancelEnableDiscussionsLabel',
defaultMessage: 'Disable discussions on units in graded subsections?',
description: 'Asks the user whether he/she really wants to disable discussions on units in graded subsections.',
},
confirmEnableDiscussions: {
id: 'authoring.discussions.confirmEnableDiscussions',
defaultMessage: 'Enabling this toggle will automatically enable discussion on all units in graded subsections, that are not timed exams.',
description: 'Asks the user whether he/she really wants to enable discussions on units in graded subsections.',
},
cancelEnableDiscussions: {
id: 'authoring.discussions.cancelEnableDiscussions',
defaultMessage: 'Disabling this toggle will automatically disable discussion on all units in graded subsections. Discussion topics containing at least 1 thread will be listed and accessible under “Archived” in Topics tab on the Discussions page.',
description: 'Asks the user whether he/she really wants to disable discussions on units in graded subsections.',
},
backButton: {
id: 'authoring.discussions.backButton',
defaultMessage: 'Back',
@@ -149,16 +124,7 @@ const messages = defineMessages({
defaultMessage: 'Questions for the TAs',
description: 'Label for a checkbox allowing a user to divide the Questions for the TAs (TA stands for "teaching assistant") course wide topic by cohorts.',
},
cohortsEnabled: {
id: 'authoring.discussions.builtIn.cohortsEnabled.label',
defaultMessage: 'To adjust these settings, enable cohorts on the ',
description: 'Label text informing the user to enable cohort',
},
instructorDashboard: {
id: 'authoring.discussions.builtIn.instructorDashboard.label',
defaultMessage: 'instructor dashboard',
description: 'Label text for instructor dashboard',
},
// In-context discussion fields
visibilityInContext: {
id: 'authoring.discussions.builtIn.visibilityInContext',

View File

@@ -38,7 +38,9 @@ export const hasValidTimeFormat = (time) => time && moment(time, validTimeFormat
export const startOfDayTime = (time) => time || moment().startOf('day').format('HH:mm');
export const endOfDayTime = (time) => time || moment().endOf('day').format('HH:mm');
export const normalizeTime = (time) => time && moment(time, validTimeFormats, true).format('HH:mm');
export const normalizeDate = (date) => moment(date, ['MM/DD/YYYY', 'YYYY-MM-DDTHH:mm', 'YYYY-MM-DD'], true).format('YYYY-MM-DD');
export const normalizeDate = (date) => moment(
date, ['MM/DD/YYYY', 'YYYY-MM-DDTHH:mm', 'YYYY-MM-DD'], true,
).format('YYYY-MM-DD');
export const decodeDateTime = (date, time) => {
const nDate = normalizeDate(date);
@@ -48,11 +50,8 @@ export const decodeDateTime = (date, time) => {
};
export const sortBlackoutDatesByStatus = (data, status, order) => (
_.orderBy(
data.filter(date => date.status === status),
[(obj) => decodeDateTime(obj.startDate, startOfDayTime(obj.startTime))],
[order],
)
_.orderBy(data.filter(date => date.status === status),
[(obj) => decodeDateTime(obj.startDate, startOfDayTime(obj.startTime))], [order])
);
export const formatBlackoutDates = ({

View File

@@ -11,9 +11,9 @@ import messages from './messages';
import appMessages from '../app-config-form/messages';
import FeaturesList from './FeaturesList';
const AppCard = ({
function AppCard({
app, onClick, intl, selected, features,
}) => {
}) {
const { canChangeProviders } = useSelector(state => state.courseDetail);
const supportText = app.hasFullSupport
? intl.formatMessage(messages.appFullSupport)
@@ -62,7 +62,7 @@ const AppCard = ({
</Card.Body>
</Card>
);
};
}
AppCard.propTypes = {
app: PropTypes.shape({
@@ -73,7 +73,7 @@ AppCard.propTypes = {
onClick: PropTypes.func.isRequired,
selected: PropTypes.bool.isRequired,
intl: intlShape.isRequired,
features: PropTypes.arrayOf(PropTypes.shape({})).isRequired,
features: PropTypes.arrayOf(PropTypes.object).isRequired,
};
export default injectIntl(AppCard);

View File

@@ -14,7 +14,7 @@ import FeaturesTable from './FeaturesTable';
import AppListNextButton from './AppListNextButton';
import Loading from '../../../generic/Loading';
const AppList = ({ intl }) => {
function AppList({ intl }) {
const dispatch = useDispatch();
const {
@@ -90,7 +90,7 @@ const AppList = ({ intl }) => {
</Responsive>
</div>
);
};
}
AppList.propTypes = {
intl: intlShape.isRequired,

View File

@@ -1,4 +1,3 @@
/* eslint-disable react/jsx-no-constructed-context-values */
import React from 'react';
import { initializeMockApp } from '@edx/frontend-platform';

View File

@@ -8,7 +8,7 @@ import { DiscussionsContext } from '../DiscussionsProvider';
import messages from './messages';
const AppListNextButton = ({ intl }) => {
function AppListNextButton({ intl }) {
const { selectedAppId } = useSelector(state => state.discussions);
const { path: discussionsPath } = useContext(DiscussionsContext);
@@ -24,7 +24,7 @@ const AppListNextButton = ({ intl }) => {
{intl.formatMessage(messages.nextButton)}
</Button>
);
};
}
AppListNextButton.propTypes = {
intl: intlShape.isRequired,

View File

@@ -41,7 +41,9 @@ describe('FeaturesList', () => {
const button = getByRole(container, 'button');
userEvent.click(button);
app.featureIds.forEach((id) => {
const featureNodes = queryAllByText(container, messages[`featureName-${id}`].defaultMessage);
const featureNodes = queryAllByText(
container, messages[`featureName-${id}`].defaultMessage,
);
expect(featureNodes.map(node => node.closest('div'))).toHaveLength(1);
});
});

View File

@@ -6,35 +6,37 @@ import { injectIntl, intlShape } from '@edx/frontend-platform/i18n';
import SupportedFeature from './SupportedFeature';
import messages from './messages';
const FeaturesList = ({ app, intl }) => (
<Collapsible
onClick={(event) => event.stopPropagation()}
title={(
<>
<Collapsible.Visible whenClosed>
{intl.formatMessage(messages['supportedFeatureList-mobile-show'])}
</Collapsible.Visible>
<Collapsible.Visible whenOpen>
{intl.formatMessage(messages['supportedFeatureList-mobile-hide'])}
</Collapsible.Visible>
</>
function FeaturesList({ app, intl }) {
return (
<Collapsible
onClick={(event) => event.stopPropagation()}
title={(
<>
<Collapsible.Visible whenClosed>
{intl.formatMessage(messages['supportedFeatureList-mobile-show'])}
</Collapsible.Visible>
<Collapsible.Visible whenOpen>
{intl.formatMessage(messages['supportedFeatureList-mobile-hide'])}
</Collapsible.Visible>
</>
)}
styling="basic"
>
{app.featureIds.map((id) => (
<div key={`collapsible-${app.id}&${id}`} className="d-flex mb-1">
<SupportedFeature name={intl.formatMessage(messages[`featureName-${id}`])} />
</div>
styling="basic"
>
{app.featureIds.map((id) => (
<div key={`collapsible-${app.id}&${id}`} className="d-flex mb-1">
<SupportedFeature name={intl.formatMessage(messages[`featureName-${id}`])} />
</div>
))}
</Collapsible>
</Collapsible>
);
}
export default injectIntl(FeaturesList);
FeaturesList.propTypes = {
app: PropTypes.shape({
id: PropTypes.string.isRequired,
featureIds: PropTypes.shape([]).isRequired,
featureIds: PropTypes.array.isRequired,
}).isRequired,
intl: intlShape.isRequired,
};

View File

@@ -10,7 +10,7 @@ import appMessages from '../app-config-form/messages';
import { FEATURE_TYPES } from '../data/constants';
import './FeaturesTable.scss';
const FeaturesTable = ({ apps, features, intl }) => {
function FeaturesTable({ apps, features, intl }) {
const {
basic, partial, full, common,
} = _.groupBy(features, (feature) => feature.featureSupportType);
@@ -87,12 +87,12 @@ const FeaturesTable = ({ apps, features, intl }) => {
<DataTable.Table />
</DataTable>
);
};
}
export default injectIntl(FeaturesTable);
FeaturesTable.propTypes = {
apps: PropTypes.arrayOf(PropTypes.shape({})).isRequired,
features: PropTypes.arrayOf(PropTypes.shape({})).isRequired,
apps: PropTypes.arrayOf(PropTypes.object).isRequired,
features: PropTypes.arrayOf(PropTypes.object).isRequired,
intl: intlShape.isRequired,
};

View File

@@ -9,7 +9,7 @@ const SupportedFeature = ({ name }) => (
</span>
{name}
</>
);
);
SupportedFeature.propTypes = {
name: PropTypes.string.isRequired,

View File

@@ -64,7 +64,7 @@ const messages = defineMessages({
},
'appDescription-openedx': {
id: 'authoring.discussions.appList.appDescription-openedx',
defaultMessage: 'Enable participation in discussion topics alongside course content.',
defaultMessage: 'Start conversations with other learners, ask questions, and interact with other learners in the course.',
description: 'A description of the new edX Discussions app.',
},
// Piazza

View File

@@ -56,7 +56,6 @@ function normalizePluginConfig(data) {
if (!data || Object.keys(data).length < 1) {
return {};
}
const enableDivideByCohorts = data.always_divide_inline_discussions && data.division_scheme === 'cohort';
const enableDivideCourseTopicsByCohorts = enableDivideByCohorts && data.divided_course_wide_discussions.length > 0;
return {
@@ -70,8 +69,6 @@ function normalizePluginConfig(data) {
allowDivisionByUnit: false,
divideByCohorts: enableDivideByCohorts,
divideCourseTopicsByCohorts: enableDivideCourseTopicsByCohorts,
cohortsEnabled: data.available_division_schemes?.includes('cohort') || false,
groupAtSubsection: data.group_at_subsection,
};
}

View File

@@ -252,7 +252,6 @@ describe('Data layer integration tests', () => {
alwaysDivideInlineDiscussions: false,
allowDivisionByUnit: false,
divideCourseTopicsByCohorts: false,
cohortsEnabled: false,
});
});
});
@@ -456,7 +455,6 @@ describe('Data layer integration tests', () => {
allowDivisionsByUnit: true,
alwaysDivideInlineDiscussions: true,
divideCourseTopicsByCohorts: true,
divisionScheme: DivisionSchemes.COHORT,
divideDiscussionIds,
discussionTopics: [
{ name: 'Edx', id: '13f106c6-6735-4e84-b097-0456cff55960' },
@@ -465,6 +463,7 @@ describe('Data layer integration tests', () => {
},
pagesAndResourcesPath,
), store.dispatch);
expect(window.location.pathname).toEqual(pagesAndResourcesPath);
expect(store.getState().discussions).toEqual(
expect.objectContaining({
@@ -491,7 +490,6 @@ describe('Data layer integration tests', () => {
// happens, but NOT what we want to have happen!
divideByCohorts: true,
divisionScheme: DivisionSchemes.COHORT,
cohortsEnabled: false,
allowDivisionByUnit: false,
divideCourseTopicsByCohorts: true,
});

View File

@@ -6,16 +6,18 @@ import { injectIntl, intlShape } from '@edx/frontend-platform/i18n';
import AppSettingsModal from '../app-settings-modal/AppSettingsModal';
import messages from './messages';
const NotesSettings = ({ intl, onClose }) => (
<AppSettingsModal
appId="edxnotes"
title={intl.formatMessage(messages.heading)}
enableAppHelp={intl.formatMessage(messages.enableNotesHelp)}
enableAppLabel={intl.formatMessage(messages.enableNotesLabel)}
learnMoreText={intl.formatMessage(messages.enableNotesLink)}
onClose={onClose}
/>
function NotesSettings({ intl, onClose }) {
return (
<AppSettingsModal
appId="edxnotes"
title={intl.formatMessage(messages.heading)}
enableAppHelp={intl.formatMessage(messages.enableNotesHelp)}
enableAppLabel={intl.formatMessage(messages.enableNotesLabel)}
learnMoreText={intl.formatMessage(messages.enableNotesLink)}
onClose={onClose}
/>
);
}
NotesSettings.propTypes = {
intl: intlShape.isRequired,

View File

@@ -1,5 +1,4 @@
import React, { useEffect, useState } from 'react';
import { getConfig } from '@edx/frontend-platform';
import { FormattedMessage, injectIntl, intlShape } from '@edx/frontend-platform/i18n';
import { Form, Hyperlink } from '@edx/paragon';
import PropTypes from 'prop-types';
@@ -9,17 +8,18 @@ import AppConfigFormDivider from '../discussions/app-config-form/apps/shared/App
import LiveCommonFields from './LiveCommonFields';
import { useModel } from '../../generic/model-store';
const BbbSettings = ({
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() {
@@ -71,7 +71,7 @@ const BbbSettings = ({
</Form.Group>
<Hyperlink
destination={getConfig().BBB_LEARN_MORE_URL}
destination={appInfo.documentationLinks.learnMoreConfiguration}
target="_blank"
rel="noopener noreferrer"
showLaunchIcon
@@ -88,19 +88,11 @@ const BbbSettings = ({
) : (
<>
{bbbPlan === bbbPlanTypes.commercial && <LiveCommonFields values={values} />}
{bbbPlan === bbbPlanTypes.free && (
<span data-testid="free-plan-message">
{bbbPlan === bbbPlanTypes.free
&& (
<p data-testid="free-plan-message">
{intl.formatMessage(messages.freePlanMessage)}
<Hyperlink
destination="https://bigbluebutton.org/privacy-policy/"
target="_blank"
rel="noopener noreferrer"
showLaunchIcon
className="text-gray-700 ml-1"
>
{intl.formatMessage(messages.privacyPolicy)}
</Hyperlink>
</span>
</p>
)}
</>
)}
@@ -108,7 +100,7 @@ const BbbSettings = ({
</>
);
};
}
BbbSettings.propTypes = {
intl: intlShape.isRequired,

View File

@@ -107,8 +107,7 @@ describe('BBB Settings', () => {
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, ',
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();
@@ -121,8 +120,7 @@ describe('BBB Settings', () => {
);
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 });

View File

@@ -4,35 +4,37 @@ import PropTypes from 'prop-types';
import messages from './messages';
import FormikControl from '../../generic/FormikControl';
const LiveCommonFields = ({
function LiveCommonFields({
intl,
values,
}) => (
<>
<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"
/>
</>
}) {
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,

View File

@@ -16,10 +16,10 @@ import messages from './messages';
import ZoomSettings from './ZoomSettings';
import BBBSettings from './BBBSettings';
const LiveSettings = ({
function LiveSettings({
intl,
onClose,
}) => {
}) {
const dispatch = useDispatch();
const courseId = useSelector(state => state.courseDetail.courseId);
const availableProviders = useSelector((state) => state.live.appIds);
@@ -57,7 +57,10 @@ const LiveSettings = ({
is: (provider, tier) => provider === 'zoom' || (provider === 'big_blue_button' && tier === bbbPlanTypes.commercial),
then: Yup.string().required(intl.formatMessage(messages.launchUrlRequired)),
}),
launchEmail: Yup.string(),
launchEmail: Yup.string().when('provider', {
is: 'zoom',
then: Yup.string().required(intl.formatMessage(messages.launchEmailRequired)),
}),
};
const handleProviderChange = (providerId, setFieldValue, values) => {
@@ -75,55 +78,59 @@ const LiveSettings = ({
}, [courseId]);
return (
<AppSettingsModal
appId="live"
title={intl.formatMessage(messages.heading)}
enableAppHelp={intl.formatMessage(messages.enableLiveHelp)}
enableAppLabel={intl.formatMessage(messages.enableLiveLabel)}
learnMoreText={intl.formatMessage(messages.enableLiveLink)}
onClose={onClose}
initialValues={liveConfiguration}
validationSchema={validationSchema}
onSettingsSave={handleSettingsSave}
configureBeforeEnable
enableReinitialize
>
{({ values, setFieldValue }) => (
(status === RequestStatus.IN_PROGRESS) ? (
<Loading />
) : (
<>
<h4 className="my-3">{intl.formatMessage(messages.selectProvider)}</h4>
<SelectableBox.Set
type="checkbox"
value={values.provider}
onChange={(event) => handleProviderChange(event.target.value, setFieldValue, values)}
name="provider"
columns={3}
className="mb-3"
>
{availableProviders.map((provider) => (
<SelectableBox value={provider} type="checkbox" key={provider}>
<div className="d-flex flex-column align-items-center">
<Icon src={iconsSrc[`${camelCase(provider)}`]} alt={provider} />
<span>{intl.formatMessage(messages[`appName-${camelCase(provider)}`])}</span>
</div>
</SelectableBox>
))}
</SelectableBox.Set>
{values.provider === 'zoom' ? <ZoomSettings values={values} />
: (
<BBBSettings
values={values}
setFieldValue={setFieldValue}
/>
)}
</>
)
<>
<AppSettingsModal
appId="live"
title={intl.formatMessage(messages.heading)}
enableAppHelp={intl.formatMessage(messages.enableLiveHelp)}
enableAppLabel={intl.formatMessage(messages.enableLiveLabel)}
learnMoreText={intl.formatMessage(messages.enableLiveLink)}
onClose={onClose}
initialValues={liveConfiguration}
validationSchema={validationSchema}
onSettingsSave={handleSettingsSave}
configureBeforeEnable
enableReinitialize
>
{({ values, setFieldValue }) => (
<>
{(status === RequestStatus.IN_PROGRESS) ? (
<Loading />
) : (
<>
<h4 className="my-3">{intl.formatMessage(messages.selectProvider)}</h4>
<SelectableBox.Set
type="checkbox"
value={values.provider}
onChange={(event) => handleProviderChange(event.target.value, setFieldValue, values)}
name="provider"
columns={3}
className="mb-3"
>
{availableProviders.map((provider) => (
<SelectableBox value={provider} type="checkbox" key={provider}>
<div className="d-flex flex-column align-items-center">
<Icon src={iconsSrc[`${camelCase(provider)}`]} alt={provider} />
<span>{intl.formatMessage(messages[`appName-${camelCase(provider)}`])}</span>
</div>
</SelectableBox>
))}
</SelectableBox.Set>
{values.provider === 'zoom' ? <ZoomSettings values={values} />
: (
<BBBSettings
values={values}
setFieldValue={setFieldValue}
/>
)}
</>
)}
</>
)}
</AppSettingsModal>
</AppSettingsModal>
</>
);
};
}
LiveSettings.propTypes = {
intl: intlShape.isRequired,

View File

@@ -6,16 +6,16 @@ import { providerNames } from './constants';
import LiveCommonFields from './LiveCommonFields';
import FormikControl from '../../generic/FormikControl';
const ZoomSettings = ({
function ZoomsSettings({
intl,
values,
}) => (
// eslint-disable-next-line react/jsx-no-useless-fragment
<>
{!values.piiSharingEnable ? (
<p data-testid="request-pii-sharing">
{intl.formatMessage(messages.requestPiiSharingEnable, { provider: providerNames[values.provider] })}
</p>
}) {
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)
@@ -33,10 +33,11 @@ const ZoomSettings = ({
/>
</>
)}
</>
</>
);
}
ZoomSettings.propTypes = {
ZoomsSettings.propTypes = {
intl: intlShape.isRequired,
values: PropTypes.shape({
consumerKey: PropTypes.string,
@@ -50,4 +51,4 @@ ZoomSettings.propTypes = {
}).isRequired,
};
export default injectIntl(ZoomSettings);
export default injectIntl(ZoomsSettings);

View File

@@ -84,8 +84,8 @@ describe('Zoom Settings', () => {
history.push(liveSettingsUrl);
});
test('LTI fields are visible when pii sharing is enabled', async () => {
await mockStore({ piiSharingAllowed: true });
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');
@@ -107,9 +107,9 @@ describe('Zoom Settings', () => {
});
test(
'Only connect to support message is visible when pii sharing is disabled',
'Only connect to support message is visible when pii sharing is disabled and email or username sharing is required',
async () => {
await mockStore({ piiSharingAllowed: false });
await mockStore({ emailSharing: true, piiSharingAllowed: false });
renderComponent();
const spinner = getByRole(container, 'status');
@@ -133,7 +133,7 @@ describe('Zoom Settings', () => {
test('Provider Configuration should be displayed correctly', async () => {
const apiDefaultResponse = generateLiveConfigurationApiResponse(true, true);
await mockStore({ piiSharingAllowed: true });
await mockStore({ emailSharing: false, piiSharingAllowed: false });
renderComponent();
const spinner = getByRole(container, 'status');

View File

@@ -36,11 +36,11 @@ export const initialState = {
export const configurationProviders = (
emailSharing,
usernameSharing,
activeProvider,
activeProvider = 'zoom',
hasFreeTier,
) => ({
providers: {
active: activeProvider || 'zoom',
active: activeProvider,
available: {
zoom: {
features: [],
@@ -65,7 +65,7 @@ export const generateLiveConfigurationApiResponse = (
enabled,
piiSharingAllowed,
providerType = 'zoom',
isFreeTier = undefined,
isFreeTier,
) => ({
course_key: courseId,
enabled,

View File

@@ -160,14 +160,9 @@ const messages = defineMessages({
freePlanMessage: {
id: 'authoring.live.freePlanMessage',
defaultMessage: 'The free plan is pre-configured, and no additional configurations are required. By selecting the free plan, you are agreeing to Blindside Networks',
defaultMessage: 'The free plan is pre-configured, and no additional configurations are required.',
description: 'Tells user that free plans requires no additional configurations',
},
privacyPolicy: {
id: 'authoring.live.privacyPolicy',
defaultMessage: 'Privacy Policy.',
description: 'The text of privacy policy hyperlink for free plan',
},
});
export default messages;

View File

@@ -17,7 +17,6 @@ const CoursePageShape = PropTypes.shape({
name: PropTypes.string.isRequired,
description: PropTypes.string.isRequired,
enabled: PropTypes.bool.isRequired,
legacyLink: PropTypes.string,
allowedOperations: PropTypes.shape({
enable: PropTypes.bool.isRequired,
configure: PropTypes.bool.isRequired,
@@ -26,14 +25,13 @@ const CoursePageShape = PropTypes.shape({
export { CoursePageShape };
const PageCard = ({
function PageCard({
intl,
page,
}) => {
}) {
const { path: pagesAndResourcesPath } = useContext(PagesAndResourcesContext);
const isDesktop = useIsDesktop();
// eslint-disable-next-line react/no-unstable-nested-components
const SettingsButton = () => {
if (page.legacyLink) {
return (
@@ -82,7 +80,7 @@ const PageCard = ({
</Card.Body>
</Card>
);
};
}
PageCard.propTypes = {
intl: intlShape.isRequired,

View File

@@ -12,11 +12,3 @@
-webkit-box-shadow: 0 0.125rem 0.25rem rgba(0, 0, 0, 0.3) !important;
box-shadow: 0 0.125rem 0.25rem rgba(0, 0, 0, 0.3) !important;
}
[dir=rtl] {
.desktop-card, .mobile-card {
.pgn__card-header-actions .pgn__hyperlink .btn-icon {
transform: scaleX(-1)
}
}
}

View File

@@ -4,19 +4,21 @@ import { injectIntl } from '@edx/frontend-platform/i18n';
import { CardGrid } from '@edx/paragon';
import PageCard, { CoursePageShape } from './PageCard';
const PageGrid = ({ pages }) => (
<CardGrid columnSizes={{
function PageGrid({ pages }) {
return (
<CardGrid columnSizes={{
xs: 12,
sm: 6,
lg: 4,
xl: 4,
}}
>
{pages.map((page) => (
<PageCard page={page} key={page.id} />
>
{pages.map((page) => (
<PageCard page={page} key={page.id} />
))}
</CardGrid>
</CardGrid>
);
}
PageGrid.propTypes = {
pages: PropTypes.arrayOf(CoursePageShape.isRequired).isRequired,

View File

@@ -13,7 +13,6 @@ import {
ActionRow, Alert, Badge, Form, Hyperlink, ModalDialog, StatefulButton,
} from '@edx/paragon';
import ExamsApiService from '../../data/services/ExamsApiService';
import StudioApiService from '../../data/services/StudioApiService';
import Loading from '../../generic/Loading';
import ConnectionErrorAlert from '../../generic/ConnectionErrorAlert';
@@ -24,7 +23,7 @@ import { useIsMobile } from '../../utils';
import { PagesAndResourcesContext } from '../PagesAndResourcesProvider';
import messages from './messages';
const ProctoringSettings = ({ intl, onClose }) => {
function ProctoringSettings({ intl, onClose }) {
const initialFormValues = {
enableProctoredExams: false,
proctoringProvider: false,
@@ -37,9 +36,7 @@ const ProctoringSettings = ({ intl, onClose }) => {
const [loaded, setLoaded] = useState(false);
const [loadingConnectionError, setLoadingConnectionError] = useState(false);
const [loadingPermissionError, setLoadingPermissionError] = useState(false);
const [allowLtiProviders, setAllowLtiProviders] = useState(false);
const [availableProctoringProviders, setAvailableProctoringProviders] = useState([]);
const [ltiProctoringProviders, setLtiProctoringProviders] = useState([]);
const [courseStartDate, setCourseStartDate] = useState('');
const [saveSuccess, setSaveSuccess] = useState(false);
const [saveError, setSaveError] = useState(false);
@@ -60,7 +57,7 @@ const ProctoringSettings = ({ intl, onClose }) => {
const proctoringEscalationEmailInputRef = useRef(null);
const submitButtonState = submissionInProgress ? 'pending' : 'default';
const handleChange = (event) => {
function handleChange(event) {
const { target } = event;
const value = target.type === 'checkbox' ? target.checked : target.value;
const { name } = target;
@@ -86,49 +83,32 @@ const ProctoringSettings = ({ intl, onClose }) => {
} else {
setFormValues({ ...formValues, [name]: value });
}
};
function isLtiProvider(provider) {
return ltiProctoringProviders.some(p => p.name === provider);
}
const setFocusToProctortrackEscalationEmailInput = () => {
function setFocusToProctortrackEscalationEmailInput() {
if (proctoringEscalationEmailInputRef && proctoringEscalationEmailInputRef.current) {
proctoringEscalationEmailInputRef.current.focus();
}
};
}
function postSettingsBackToServer() {
const providerIsLti = isLtiProvider(formValues.proctoringProvider);
const studioDataToPostBack = {
const dataToPostBack = {
proctored_exam_settings: {
enable_proctored_exams: formValues.enableProctoredExams,
// lti providers are managed outside edx-platform, lti_external indicates this
proctoring_provider: providerIsLti ? 'lti_external' : formValues.proctoringProvider,
proctoring_provider: formValues.proctoringProvider,
create_zendesk_tickets: formValues.createZendeskTickets,
},
};
if (isEdxStaff) {
studioDataToPostBack.proctored_exam_settings.allow_proctoring_opt_out = formValues.allowOptingOut;
dataToPostBack.proctored_exam_settings.allow_proctoring_opt_out = formValues.allowOptingOut;
}
if (formValues.proctoringProvider === 'proctortrack') {
studioDataToPostBack.proctored_exam_settings.proctoring_escalation_email = formValues.proctortrackEscalationEmail === '' ? null : formValues.proctortrackEscalationEmail;
dataToPostBack.proctored_exam_settings.proctoring_escalation_email = formValues.proctortrackEscalationEmail === '' ? null : formValues.proctortrackEscalationEmail;
}
// only save back to exam service if necessary
setSubmissionInProgress(true);
const saveOperations = [StudioApiService.saveProctoredExamSettingsData(courseId, studioDataToPostBack)];
if (allowLtiProviders && ExamsApiService.isAvailable()) {
saveOperations.push(
ExamsApiService.saveCourseExamConfiguration(
courseId,
{ provider: providerIsLti ? formValues.proctoringProvider : null },
),
);
}
Promise.all(saveOperations)
.then(() => {
StudioApiService.saveProctoredExamSettingsData(courseId, dataToPostBack).then(() => {
setSaveSuccess(true);
setSaveError(false);
setSubmissionInProgress(false);
@@ -139,7 +119,7 @@ const ProctoringSettings = ({ intl, onClose }) => {
});
}
const handleSubmit = (event) => {
function handleSubmit(event) {
event.preventDefault();
if (
formValues.proctoringProvider === 'proctortrack'
@@ -153,11 +133,7 @@ const ProctoringSettings = ({ intl, onClose }) => {
isValid: false,
errors: {
formProctortrackEscalationEmail: {
dialogErrorMessage: (
<Alert.Link onClick={setFocusToProctortrackEscalationEmailInput} href="#formProctortrackEscalationEmail" data-testid="proctorTrackEscalationEmailErrorLink">
{errorMessage}
</Alert.Link>
),
dialogErrorMessage: (<Alert.Link onClick={setFocusToProctortrackEscalationEmailInput} href="#formProctortrackEscalationEmail" data-testid="proctorTrackEscalationEmailErrorLink">{errorMessage}</Alert.Link>),
inputErrorMessage: errorMessage,
},
},
@@ -184,7 +160,7 @@ const ProctoringSettings = ({ intl, onClose }) => {
errors,
});
}
};
}
function cannotEditProctoringProvider() {
const currentDate = moment(moment()).format('YYYY-MM-DD[T]hh:mm:ss[Z]');
@@ -202,11 +178,6 @@ const ProctoringSettings = ({ intl, onClose }) => {
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
@@ -215,7 +186,7 @@ const ProctoringSettings = ({ intl, onClose }) => {
disabled={isDisabledOption(provider)}
data-testid={provider}
>
{getProviderDisplayLabel(provider)}
{provider}
</option>
));
}
@@ -373,7 +344,7 @@ const ProctoringSettings = ({ intl, onClose }) => {
)}
{/* CREATE ZENDESK TICKETS */}
{ isEdxStaff && formValues.enableProctoredExams && !isLtiProvider(formValues.proctoringProvider) && (
{ isEdxStaff && formValues.enableProctoredExams && (
<fieldset aria-describedby="createZendeskTicketsText">
<Form.Group controlId="formCreateZendeskTickets">
<Form.Label as="legend" className="font-weight-bold">
@@ -473,53 +444,25 @@ const ProctoringSettings = ({ intl, onClose }) => {
);
}
useEffect(() => {
Promise.all([
StudioApiService.getProctoredExamSettingsData(courseId),
ExamsApiService.isAvailable() ? ExamsApiService.getCourseExamConfiguration(courseId) : Promise.resolve(),
ExamsApiService.isAvailable() ? ExamsApiService.getAvailableProviders() : Promise.resolve(),
])
useEffect(
() => {
StudioApiService.getProctoredExamSettingsData(courseId)
.then(
([settingsResponse, examConfigResponse, ltiProvidersResponse]) => {
const proctoredExamSettings = settingsResponse.data.proctored_exam_settings;
response => {
const proctoredExamSettings = response.data.proctored_exam_settings;
setLoaded(true);
setLoading(false);
setSubmissionInProgress(false);
setCourseStartDate(settingsResponse.data.course_start_date);
setCourseStartDate(response.data.course_start_date);
const isProctortrack = proctoredExamSettings.proctoring_provider === 'proctortrack';
setShowProctortrackEscalationEmail(isProctortrack);
setAvailableProctoringProviders(settingsResponse.data.available_proctoring_providers);
setAvailableProctoringProviders(response.data.available_proctoring_providers);
const proctoringEscalationEmail = proctoredExamSettings.proctoring_escalation_email;
// 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 || [];
const enableLtiProviders = proctoringProvidersStudio.includes('lti_external');
setAllowLtiProviders(enableLtiProviders);
setLtiProctoringProviders(proctoringProvidersLti);
// flatten provider objects and coalesce values to just the provider key
let availableProviders = proctoringProvidersStudio.filter(value => value !== 'lti_external');
if (enableLtiProviders) {
availableProviders = proctoringProvidersLti.reduce(
(result, provider) => [...result, provider.name],
availableProviders,
);
}
setAvailableProctoringProviders(availableProviders);
let selectedProvider;
if (proctoredExamSettings.proctoring_provider === 'lti_external') {
selectedProvider = examConfigResponse.data.provider;
} else {
selectedProvider = proctoredExamSettings.proctoring_provider;
}
setFormValues({
...formValues,
proctoringProvider: selectedProvider,
enableProctoredExams: proctoredExamSettings.enable_proctored_exams,
proctoringProvider: proctoredExamSettings.proctoring_provider,
allowOptingOut: proctoredExamSettings.allow_proctoring_opt_out,
createZendeskTickets: proctoredExamSettings.create_zendesk_tickets,
// The backend API may return null for the proctoringEscalationEmail value, which is the default.
@@ -530,7 +473,7 @@ const ProctoringSettings = ({ intl, onClose }) => {
},
).catch(
error => {
if (error.response?.status === 403) {
if (error.response.status === 403) {
setLoadingPermissionError(true);
} else {
setLoadingConnectionError(true);
@@ -540,7 +483,8 @@ const ProctoringSettings = ({ intl, onClose }) => {
setSubmissionInProgress(false);
},
);
}, []);
}, [],
);
useEffect(() => {
if ((saveSuccess || saveError) && !!saveStatusAlertRef.current) {
@@ -601,7 +545,7 @@ const ProctoringSettings = ({ intl, onClose }) => {
</Form>
</ModalDialog>
);
};
}
ProctoringSettings.propTypes = {
intl: intlShape.isRequired,

View File

@@ -4,13 +4,12 @@ import {
} from '@testing-library/react';
import MockAdapter from 'axios-mock-adapter';
import { initializeMockApp, mergeConfig } from '@edx/frontend-platform';
import { initializeMockApp } from '@edx/frontend-platform';
import { getAuthenticatedHttpClient } from '@edx/frontend-platform/auth';
import { IntlProvider, injectIntl } from '@edx/frontend-platform/i18n';
import { AppProvider } from '@edx/frontend-platform/react';
import StudioApiService from '../../data/services/StudioApiService';
import ExamsApiService from '../../data/services/ExamsApiService';
import initializeStore from '../../store';
import PagesAndResourcesProvider from '../PagesAndResourcesProvider';
import ProctoredExamSettings from './Settings';
@@ -34,19 +33,7 @@ const intlWrapper = children => (
let axiosMock;
describe('ProctoredExamSettings', () => {
function setupApp(isAdmin = true) {
mergeConfig({
EXAMS_BASE_URL: 'http://exams.testing.co',
}, 'CourseAuthoringConfig');
initializeMockApp({
authenticatedUser: {
userId: 3,
username: 'abc123',
administrator: isAdmin,
roles: [],
},
});
beforeEach(() => {
store = initializeStore({
models: {
courseApps: {
@@ -54,47 +41,39 @@ describe('ProctoredExamSettings', () => {
},
},
});
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: [],
},
});
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} />)));
});
@@ -187,23 +166,21 @@ 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, {
@@ -422,131 +399,83 @@ describe('ProctoredExamSettings', () => {
course_start_date: '2013-01-01T00:00:00Z',
};
function mockCourseData(data) {
function setup(data, isAdmin) {
initializeMockApp({
authenticatedUser: {
userId: 3,
username: 'abc123',
administrator: isAdmin,
roles: [],
},
});
axiosMock = new MockAdapter(getAuthenticatedHttpClient());
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 () => {
const isAdmin = false;
setupApp(isAdmin);
mockCourseData(mockGetPastCourseData);
setup(mockGetPastCourseData, false);
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 () => {
const isAdmin = false;
setupApp(isAdmin);
mockCourseData(mockGetFutureCourseData);
setup(mockGetFutureCourseData, false);
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 () => {
const isAdmin = true;
setupApp(isAdmin);
mockCourseData(mockGetPastCourseData);
setup(mockGetPastCourseData, true);
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 () => {
const isAdmin = true;
setupApp(isAdmin);
mockCourseData(mockGetFutureCourseData);
setup(mockGetFutureCourseData, true);
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,
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,
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 include lti provider options when lti_external is not available in studio', async () => {
const isAdmin = true;
setupApp(isAdmin);
mockCourseData(mockGetFutureCourseData);
await act(async () => render(intlWrapper(<IntlProctoredExamSettings {...defaultProps} />)));
await waitFor(() => {
screen.getByDisplayValue('mockproc');
});
const providerOption = screen.queryByTestId('test_lti');
expect(providerOption).not.toBeInTheDocument();
});
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 () => {
setupApp(false);
setup(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 () => {
setupApp(true);
setup(true);
await act(async () => render(intlWrapper(<IntlProctoredExamSettings {...defaultProps} />)));
expect(screen.queryByTestId('allowOptingOutYes')).not.toBeNull();
expect(screen.queryByTestId('createZendeskTicketsYes')).not.toBeNull();
@@ -554,6 +483,18 @@ 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} />));
@@ -563,7 +504,7 @@ describe('ProctoredExamSettings', () => {
});
});
it('Show connection error message when we suffer studio server side error', async () => {
it('Show connection error message when we suffer server side error', async () => {
axiosMock.onGet(
StudioApiService.getProctoredExamSettingsUrl(defaultProps.courseId),
).reply(500);
@@ -575,18 +516,6 @@ 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),
@@ -601,16 +530,34 @@ describe('ProctoredExamSettings', () => {
});
describe('Save settings', () => {
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');
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'],
});
});
it('Disable button while submitting', async () => {
axiosMock.onPost(
StudioApiService.getProctoredExamSettingsUrl(defaultProps.courseId),
).reply(200, 'success');
await act(async () => render(intlWrapper(<IntlProctoredExamSettings {...defaultProps} />)));
let submitButton = screen.getByTestId('submissionButton');
expect(screen.queryByTestId('saveInProgress')).toBeFalsy();
@@ -623,6 +570,10 @@ describe('ProctoredExamSettings', () => {
});
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');
@@ -658,6 +609,10 @@ 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
@@ -684,112 +639,7 @@ describe('ProctoredExamSettings', () => {
expect(document.activeElement).toEqual(errorAlert);
});
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 () => {
it('Makes API call generated error', async () => {
axiosMock.onPost(
StudioApiService.getProctoredExamSettingsUrl(defaultProps.courseId),
).reply(500);
@@ -807,29 +657,11 @@ 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),
).reply(500);
).replyOnce(500);
await act(async () => render(intlWrapper(<IntlProctoredExamSettings {...defaultProps} />)));
const submitButton = screen.getByTestId('submissionButton');
@@ -846,7 +678,7 @@ describe('ProctoredExamSettings', () => {
// now make a call that will allow for a successful save
axiosMock.onPost(
StudioApiService.getProctoredExamSettingsUrl(defaultProps.courseId),
).reply(200, 'success');
).replyOnce(200, 'success');
await act(async () => {
fireEvent.click(submitButton);
});
@@ -861,8 +693,28 @@ describe('ProctoredExamSettings', () => {
it('Include Zendesk ticket in post request if user is not an admin', async () => {
// use non-admin user for test
const isAdmin = false;
setupApp(isAdmin);
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');
await act(async () => render(intlWrapper(<IntlProctoredExamSettings {...defaultProps} />)));
// Make a change to the proctoring provider

View File

@@ -8,9 +8,9 @@ import { useAppSetting } from '../../utils';
import AppSettingsModal from '../app-settings-modal/AppSettingsModal';
import messages from './messages';
const ProgressSettings = ({ intl, onClose }) => {
function ProgressSettings({ intl, onClose }) {
const [disableProgressGraph, saveSetting] = useAppSetting('disableProgressGraph');
const showProgressGraphSetting = getConfig().ENABLE_PROGRESS_GRAPH_SETTINGS.toString().toLowerCase() === 'true';
const showProgressGraphSetting = getConfig().ENABLE_PROGRESS_GRAPH_SETTINGS.toLowerCase() === 'true';
const handleSettingsSave = (values) => {
if (showProgressGraphSetting) { saveSetting(!values.enableProgressGraph); }
@@ -45,7 +45,7 @@ const ProgressSettings = ({ intl, onClose }) => {
}
</AppSettingsModal>
);
};
}
ProgressSettings.propTypes = {
intl: intlShape.isRequired,

View File

@@ -24,9 +24,9 @@ const TeamTypeNameMessage = {
},
};
const GroupEditor = ({
function GroupEditor({
intl, group, onDelete, onChange, onBlur, fieldNameCommonBase, errors,
}) => {
}) {
const [isDeleting, setDeleting] = useState(false);
const [isOpen, setOpen] = useState(group.id === null);
const initiateDeletion = () => setDeleting(true);
@@ -133,7 +133,7 @@ const GroupEditor = ({
)}
</TransitionReplace>
);
};
}
export const groupShape = PropTypes.shape({
id: PropTypes.string,

View File

@@ -16,10 +16,10 @@ import messages from './messages';
setupYupExtensions();
const TeamSettings = ({
function TeamSettings({
intl,
onClose,
}) => {
}) {
const [teamsConfiguration, saveSettings] = useAppSetting('teamsConfiguration');
const blankNewGroup = {
name: '',
@@ -161,7 +161,7 @@ const TeamSettings = ({
}
</AppSettingsModal>
);
};
}
TeamSettings.propTypes = {
intl: intlShape.isRequired,

View File

@@ -8,7 +8,7 @@ import { useAppSetting } from '../../utils';
import AppSettingsModal from '../app-settings-modal/AppSettingsModal';
import messages from './messages';
const WikiSettings = ({ intl, onClose }) => {
function WikiSettings({ intl, onClose }) {
const [enablePublicWiki, saveSetting] = useAppSetting('allowPublicWikiAccess');
const handleSettingsSave = (values) => saveSetting(values.enablePublicWiki);
@@ -39,7 +39,7 @@ const WikiSettings = ({ intl, onClose }) => {
}
</AppSettingsModal>
);
};
}
WikiSettings.propTypes = {
intl: intlShape.isRequired,

View File

@@ -26,7 +26,7 @@ import {
fetchExamSettingsSuccess,
} from './data/thunks';
const ProctoredExamSettings = ({ courseId, intl }) => {
function ProctoredExamSettings({ courseId, intl }) {
const dispatch = useDispatch();
const [loading, setLoading] = useState(true);
const [loaded, setLoaded] = useState(false);
@@ -55,9 +55,9 @@ const ProctoredExamSettings = ({ courseId, intl }) => {
const saveStatusAlertRef = React.createRef();
const proctoringEscalationEmailInputRef = useRef(null);
const onEnableProctoredExamsChange = (event) => {
function onEnableProctoredExamsChange(event) {
setEnableProctoredExams(event.target.checked);
};
}
function onAllowOptingOutChange(value) {
setAllowOptingOut(value);
@@ -67,7 +67,7 @@ const ProctoredExamSettings = ({ courseId, intl }) => {
setCreateZendeskTickets(value);
}
const onProctoringProviderChange = (event) => {
function onProctoringProviderChange(event) {
const provider = event.target.value;
setProctoringProvider(provider);
@@ -80,17 +80,17 @@ const ProctoredExamSettings = ({ courseId, intl }) => {
}
setShowProctortrackEscalationEmail(false);
}
};
}
const onProctortrackEscalationEmailChange = (event) => {
function onProctortrackEscalationEmailChange(event) {
setProctortrackEscalationEmail(event.target.value);
};
}
const setFocusToProctortrackEscalationEmailInput = () => {
function setFocusToProctortrackEscalationEmailInput() {
if (proctoringEscalationEmailInputRef && proctoringEscalationEmailInputRef.current) {
proctoringEscalationEmailInputRef.current.focus();
}
};
}
function isLtiProvider(provider) {
return ltiProctoringProviders.some(p => p.name === provider);
@@ -120,7 +120,9 @@ const ProctoredExamSettings = ({ courseId, intl }) => {
const saveOperations = [StudioApiService.saveProctoredExamSettingsData(courseId, studioDataToPostBack)];
if (allowLtiProviders && ExamsApiService.isAvailable()) {
saveOperations.push(
ExamsApiService.saveCourseExamConfiguration(courseId, { provider: providerIsLti ? proctoringProvider : null }),
ExamsApiService.saveCourseExamConfiguration(
courseId, { provider: providerIsLti ? proctoringProvider : null },
),
);
}
Promise.all(saveOperations)
@@ -135,7 +137,7 @@ const ProctoredExamSettings = ({ courseId, intl }) => {
});
}
const handleSubmit = (event) => {
function handleSubmit(event) {
event.preventDefault();
if (proctoringProvider === 'proctortrack' && !EmailValidator.validate(proctortrackEscalationEmail) && !(proctortrackEscalationEmail === '' && !enableProctoredExams)) {
if (proctortrackEscalationEmail === '') {
@@ -172,7 +174,7 @@ const ProctoredExamSettings = ({ courseId, intl }) => {
errors,
});
}
};
}
function cannotEditProctoringProvider() {
const currentDate = moment(moment()).format('YYYY-MM-DD[T]hh:mm:ss[Z]');
@@ -490,7 +492,8 @@ const ProctoredExamSettings = ({ courseId, intl }) => {
);
}
useEffect(() => {
useEffect(
() => {
dispatch(fetchExamSettingsPending(courseId));
Promise.all([
@@ -516,19 +519,14 @@ const ProctoredExamSettings = ({ courseId, intl }) => {
// This option is not directly selectable.
const proctoringProvidersStudio = settingsResponse.data.available_proctoring_providers;
const proctoringProvidersLti = ltiProvidersResponse?.data || [];
const enableLtiProviders = proctoringProvidersStudio.includes('lti_external');
setAllowLtiProviders(enableLtiProviders);
setAllowLtiProviders(proctoringProvidersStudio.includes('lti_external'));
setLtiProctoringProviders(proctoringProvidersLti);
// flatten provider objects and coalesce values to just the provider key
let availableProviders = proctoringProvidersStudio.filter(value => value !== 'lti_external');
if (enableLtiProviders) {
availableProviders = proctoringProvidersLti.reduce(
(result, provider) => [...result, provider.name],
availableProviders,
);
}
setAvailableProctoringProviders(availableProviders);
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 {
@@ -558,7 +556,8 @@ const ProctoredExamSettings = ({ courseId, intl }) => {
dispatch(fetchExamSettingsFailure(courseId));
},
);
}, []);
}, [],
);
useEffect(() => {
if ((saveSuccess || saveError) && !!saveStatusAlertRef.current) {
@@ -584,7 +583,7 @@ const ProctoredExamSettings = ({ courseId, intl }) => {
</div>
</div>
);
};
}
ProctoredExamSettings.propTypes = {
courseId: PropTypes.string.isRequired,

View File

@@ -4,12 +4,12 @@ import PropTypes from 'prop-types';
import { AvatarIcon } from './Icons';
const Avatar = ({
function Avatar({
size,
src,
alt,
className,
}) => {
}) {
const avatar = src ? (
<img className="d-block w-100 h-100" src={src} alt={alt} />
) : (
@@ -24,7 +24,7 @@ const Avatar = ({
{avatar}
</span>
);
};
}
Avatar.propTypes = {
src: PropTypes.string,

View File

@@ -22,9 +22,9 @@ ensureConfig([
'LOGO_URL',
], 'Header component');
const Header = ({
function Header({
courseId, courseNumber, courseOrg, courseTitle, intl,
}) => {
}) {
const { authenticatedUser, config } = useContext(AppContext);
const mainMenu = [
@@ -48,11 +48,9 @@ const Header = ({
<div className="mb-1 small">
<a rel="noopener" href={`${config.STUDIO_BASE_URL}/textbooks/${courseId}`}>{intl.formatMessage(messages['header.links.textbooks'])}</a>
</div>
{process.env.ENABLE_VIDEO_UPLOAD_PAGE_LINK_IN_CONTENT_DROPDOWN === 'true' && (
<div className="mb-1 small">
<a rel="noopener" href={`${config.STUDIO_BASE_URL}/videos/${courseId}`}>{intl.formatMessage(messages['header.links.videoUploads'])}</a>
</div>
)}
<div className="mb-1 small">
<a rel="noopener" href={`${config.STUDIO_BASE_URL}/videos/${courseId}`}>{intl.formatMessage(messages['header.links.videoUploads'])}</a>
</div>
</>
),
},
@@ -176,7 +174,7 @@ const Header = ({
</Responsive>
</>
);
};
}
Header.propTypes = {
courseId: PropTypes.string.isRequired,

View File

@@ -1,4 +1,3 @@
/* eslint-disable react/jsx-no-constructed-context-values */
// This file was copied from edx/frontend-component-header-edx.
import React from 'react';
import { IntlProvider } from '@edx/frontend-platform/i18n';
@@ -6,7 +5,6 @@ import { AppContext } from '@edx/frontend-platform/react';
import { Context as ResponsiveContext } from 'react-responsive';
import {
cleanup,
fireEvent,
render,
screen,
} from '@testing-library/react';
@@ -41,16 +39,14 @@ describe('<Header />', () => {
}
it('renders desktop header correctly with API call', async () => {
const component = createComponent(
1280, (
<Header
courseId="course-v1:edX+DemoX+Demo_Course"
courseNumber="DemoX"
courseOrg="edX"
courseTitle="Demonstration Course"
/>
),
);
const component = createComponent(1280, (
<Header
courseId="course-v1:edX+DemoX+Demo_Course"
courseNumber="DemoX"
courseOrg="edX"
courseTitle="Demonstration Course"
/>
));
render(component);
expect(screen.getByTestId('course-org-number').textContent).toEqual(expect.stringContaining('edX DemoX'));
@@ -58,93 +54,47 @@ describe('<Header />', () => {
});
it('renders mobile header correctly with API call', async () => {
const component = createComponent(
500, (
<Header
courseId="course-v1:edX+DemoX+Demo_Course"
courseNumber="DemoX"
courseOrg="edX"
courseTitle="Demonstration Course"
/>
),
);
const component = createComponent(500, (
<Header
courseId="course-v1:edX+DemoX+Demo_Course"
courseNumber="DemoX"
courseOrg="edX"
courseTitle="Demonstration Course"
/>
));
render(component);
expect(screen.getByTestId('edx-header-logo'));
});
it('renders desktop header correctly with bad API call', async () => {
const component = createComponent(
1280, (
<Header
courseId="course-v1:edX+DemoX+Demo_Course"
courseNumber={null}
courseOrg={null}
courseTitle="course-v1:edX+DemoX+Demo_Course"
/>
),
);
const component = createComponent(1280, (
<Header
courseId="course-v1:edX+DemoX+Demo_Course"
courseNumber={null}
courseOrg={null}
courseTitle="course-v1:edX+DemoX+Demo_Course"
/>
));
render(component);
expect(screen.getByTestId('course-title').textContent).toEqual(expect.stringContaining('course-v1:edX+DemoX+Demo_Course'));
});
it('renders mobile header correctly with bad API call', async () => {
const component = createComponent(
500, (
<Header
courseId="course-v1:edX+DemoX+Demo_Course"
courseNumber={null}
courseOrg={null}
courseTitle="course-v1:edX+DemoX+Demo_Course"
/>
),
);
const component = createComponent(500, (
<Header
courseId="course-v1:edX+DemoX+Demo_Course"
courseNumber={null}
courseOrg={null}
courseTitle="course-v1:edX+DemoX+Demo_Course"
/>
));
render(component);
expect(screen.getByTestId('edx-header-logo'));
});
it('renders Video Uploads link', () => {
process.env.ENABLE_VIDEO_UPLOAD_PAGE_LINK_IN_CONTENT_DROPDOWN = 'true';
const component = createComponent(
1280, (
<Header
courseId="course-v1:edX+DemoX+Demo_Course"
courseNumber="DemoX"
courseOrg="edX"
courseTitle="Demonstration Course"
/>
),
);
render(component);
fireEvent.click(screen.getByText('Content'));
expect(screen.getByText('Video Uploads')).toBeInTheDocument();
});
it('does not render Video Uploads link', () => {
process.env.ENABLE_VIDEO_UPLOAD_PAGE_LINK_IN_CONTENT_DROPDOWN = 'false';
const component = createComponent(
1280, (
<Header
courseId="course-v1:edX+DemoX+Demo_Course"
courseNumber="DemoX"
courseOrg="edX"
courseTitle="Demonstration Course"
/>
),
);
render(component);
fireEvent.click(screen.getByText('Content'));
expect(screen.queryByText('Video Uploads')).toBeNull();
});
afterEach(() => {
cleanup();
});

View File

@@ -1,7 +1,7 @@
// This file was copied from edx/frontend-component-header-edx.
import React from 'react';
export const MenuIcon = (props) => (
export const MenuIcon = props => (
<svg
width="24px"
height="24px"
@@ -13,9 +13,9 @@ export const MenuIcon = (props) => (
<rect fill="currentColor" x="2" y="11" width="20" height="2" />
<rect fill="currentColor" x="2" y="17" width="20" height="2" />
</svg>
);
);
export const AvatarIcon = (props) => (
export const AvatarIcon = props => (
<svg
width="24px"
height="24px"
@@ -28,9 +28,9 @@ export const AvatarIcon = (props) => (
fill="currentColor"
/>
</svg>
);
);
export const CaretIcon = (props) => (
export const CaretIcon = props => (
<svg
width="16px"
height="16px"
@@ -44,4 +44,4 @@ export const CaretIcon = (props) => (
transform="translate(8.000000, 7.000000) rotate(-45.000000) translate(-8.000000, -7.000000) "
/>
</svg>
);
);

View File

@@ -2,25 +2,29 @@
import React from 'react';
import PropTypes from 'prop-types';
const Logo = ({ src, alt, ...attributes }) => (
<img src={src} alt={alt} {...attributes} />
function Logo({ src, alt, ...attributes }) {
return (
<img src={src} alt={alt} {...attributes} />
);
}
Logo.propTypes = {
src: PropTypes.string.isRequired,
alt: PropTypes.string.isRequired,
};
const LinkedLogo = ({
function LinkedLogo({
href,
src,
alt,
...attributes
}) => (
<a href={href} {...attributes}>
<img className="d-block" src={src} alt={alt} />
</a>
}) {
return (
<a href={href} {...attributes}>
<img className="d-block" src={src} alt={alt} />
</a>
);
}
LinkedLogo.propTypes = {
href: PropTypes.string.isRequired,

View File

@@ -3,10 +3,12 @@ import React from 'react';
import { CSSTransition } from 'react-transition-group';
import PropTypes from 'prop-types';
const MenuTrigger = ({ tag, className, ...attributes }) => React.createElement(tag, {
function MenuTrigger({ tag, className, ...attributes }) {
return React.createElement(tag, {
className: `menu-trigger ${className}`,
...attributes,
});
}
MenuTrigger.propTypes = {
tag: PropTypes.string,
className: PropTypes.string,
@@ -17,10 +19,12 @@ MenuTrigger.defaultProps = {
};
const MenuTriggerType = <MenuTrigger />.type;
const MenuContent = ({ tag, className, ...attributes }) => React.createElement(tag, {
function MenuContent({ tag, className, ...attributes }) {
return React.createElement(tag, {
className: ['menu-content', className].join(' '),
...attributes,
});
}
MenuContent.propTypes = {
tag: PropTypes.string,
className: PropTypes.string,