Convert "Pages & Resources" page to a plugin system (#638)

* feat: Make "Pages & Resources" course apps into plugins

* feat: move ora_settings

* feat: move proctoring

* feat: move progress

* feat: move teams

* feat: move wiki

* feat: move Xpert settings

* fix: add webpack.prod.config.js

* fix: clean up unused parts of package.json files

* feat: Add an error message when displaying a Course App Plugin fails

* chore: fix various eslint warnings

* chore: fix jest tests

* fix: error preventing "npm ci" from working

* feat: better tests for <SettingsComponent>

* chore: move xpert_unit_summary into same dir as other plugins

* fix: eslint-import-resolver-webpack is a dev dependency

* chore: move learning_assistant to be a plugin too

* feat: for compatibility, install 2U plugins by default

* fix: bug with learning_assistant package.json
This commit is contained in:
Braden MacDonald
2024-02-28 08:50:54 -08:00
committed by GitHub
parent 49fce4622c
commit 3c661e15cb
75 changed files with 910 additions and 254 deletions

View File

@@ -1,3 +1,4 @@
const path = require('path');
// eslint-disable-next-line import/no-extraneous-dependencies
const { createConfig } = require('@openedx/frontend-build');
@@ -13,5 +14,21 @@ module.exports = createConfig(
indent: ['error', 2],
'no-restricted-exports': 'off',
},
settings: {
// Import URLs should be resolved using aliases
'import/resolver': {
webpack: {
config: path.resolve(__dirname, 'webpack.dev.config.js'),
},
},
},
overrides: [
{
files: ['plugins/**/*.test.jsx'],
rules: {
'import/no-extraneous-dependencies': 'off',
},
},
],
},
);

View File

@@ -11,6 +11,7 @@ module.exports = createConfig('jest', {
],
moduleNameMapper: {
'^lodash-es$': 'lodash',
'^CourseAuthoring/(.*)$': '<rootDir>/src/$1',
},
modulePathIgnorePatterns: [
'/src/pages-and-resources/utils.test.jsx',

340
package-lock.json generated
View File

@@ -23,6 +23,16 @@
"@fortawesome/free-regular-svg-icons": "5.15.4",
"@fortawesome/free-solid-svg-icons": "5.15.4",
"@fortawesome/react-fontawesome": "0.2.0",
"@openedx-plugins/course-app-calculator": "file:plugins/course-apps/calculator",
"@openedx-plugins/course-app-edxnotes": "file:plugins/course-apps/edxnotes",
"@openedx-plugins/course-app-learning_assistant": "file:plugins/course-apps/learning_assistant",
"@openedx-plugins/course-app-live": "file:plugins/course-apps/live",
"@openedx-plugins/course-app-ora_settings": "file:plugins/course-apps/ora_settings",
"@openedx-plugins/course-app-proctoring": "file:plugins/course-apps/proctoring",
"@openedx-plugins/course-app-progress": "file:plugins/course-apps/progress",
"@openedx-plugins/course-app-teams": "file:plugins/course-apps/teams",
"@openedx-plugins/course-app-wiki": "file:plugins/course-apps/wiki",
"@openedx-plugins/course-app-xpert_unit_summary": "file:plugins/course-apps/xpert_unit_summary",
"@openedx/paragon": "^21.5.7",
"@reduxjs/toolkit": "1.9.7",
"@tanstack/react-query": "4.36.1",
@@ -65,6 +75,7 @@
"@testing-library/user-event": "^13.2.1",
"axios": "^0.27.2",
"axios-mock-adapter": "1.22.0",
"eslint-import-resolver-webpack": "^0.13.8",
"glob": "7.2.3",
"husky": "7.0.4",
"jest-canvas-mock": "^2.5.2",
@@ -4721,6 +4732,46 @@
"node": ">= 8"
}
},
"node_modules/@openedx-plugins/course-app-calculator": {
"resolved": "plugins/course-apps/calculator",
"link": true
},
"node_modules/@openedx-plugins/course-app-edxnotes": {
"resolved": "plugins/course-apps/edxnotes",
"link": true
},
"node_modules/@openedx-plugins/course-app-learning_assistant": {
"resolved": "plugins/course-apps/learning_assistant",
"link": true
},
"node_modules/@openedx-plugins/course-app-live": {
"resolved": "plugins/course-apps/live",
"link": true
},
"node_modules/@openedx-plugins/course-app-ora_settings": {
"resolved": "plugins/course-apps/ora_settings",
"link": true
},
"node_modules/@openedx-plugins/course-app-proctoring": {
"resolved": "plugins/course-apps/proctoring",
"link": true
},
"node_modules/@openedx-plugins/course-app-progress": {
"resolved": "plugins/course-apps/progress",
"link": true
},
"node_modules/@openedx-plugins/course-app-teams": {
"resolved": "plugins/course-apps/teams",
"link": true
},
"node_modules/@openedx-plugins/course-app-wiki": {
"resolved": "plugins/course-apps/wiki",
"link": true
},
"node_modules/@openedx-plugins/course-app-xpert_unit_summary": {
"resolved": "plugins/course-apps/xpert_unit_summary",
"link": true
},
"node_modules/@openedx/frontend-build": {
"version": "13.0.27",
"resolved": "https://registry.npmjs.org/@openedx/frontend-build/-/frontend-build-13.0.27.tgz",
@@ -6622,6 +6673,21 @@
"node": ">=0.10.0"
}
},
"node_modules/array.prototype.find": {
"version": "2.2.2",
"resolved": "https://registry.npmjs.org/array.prototype.find/-/array.prototype.find-2.2.2.tgz",
"integrity": "sha512-DRumkfW97iZGOfn+lIXbkVrXL04sfYKX+EfOodo8XboR5sxPDVvOjZTF/rysusa9lmhmSOeD6Vp6RKQP+eP4Tg==",
"dev": true,
"dependencies": {
"call-bind": "^1.0.2",
"define-properties": "^1.2.0",
"es-abstract": "^1.22.1",
"es-shim-unscopables": "^1.0.0"
},
"funding": {
"url": "https://github.com/sponsors/ljharb"
}
},
"node_modules/array.prototype.flat": {
"version": "1.3.2",
"resolved": "https://registry.npmjs.org/array.prototype.flat/-/array.prototype.flat-1.3.2.tgz",
@@ -9436,6 +9502,99 @@
"ms": "^2.1.1"
}
},
"node_modules/eslint-import-resolver-webpack": {
"version": "0.13.8",
"resolved": "https://registry.npmjs.org/eslint-import-resolver-webpack/-/eslint-import-resolver-webpack-0.13.8.tgz",
"integrity": "sha512-Y7WIaXWV+Q21Rz/PJgUxiW/FTBOWmU8NTLdz+nz9mMoiz5vAev/fOaQxwD7qRzTfE3HSm1qsxZ5uRd7eX+VEtA==",
"dev": true,
"dependencies": {
"array.prototype.find": "^2.2.2",
"debug": "^3.2.7",
"enhanced-resolve": "^0.9.1",
"find-root": "^1.1.0",
"hasown": "^2.0.0",
"interpret": "^1.4.0",
"is-core-module": "^2.13.1",
"is-regex": "^1.1.4",
"lodash": "^4.17.21",
"resolve": "^2.0.0-next.5",
"semver": "^5.7.2"
},
"engines": {
"node": ">= 6"
},
"peerDependencies": {
"eslint-plugin-import": ">=1.4.0",
"webpack": ">=1.11.0"
}
},
"node_modules/eslint-import-resolver-webpack/node_modules/debug": {
"version": "3.2.7",
"resolved": "https://registry.npmjs.org/debug/-/debug-3.2.7.tgz",
"integrity": "sha512-CFjzYYAi4ThfiQvizrFQevTTXHtnCqWfe7x1AhgEscTz6ZbLbfoLRLPugTQyBth6f8ZERVUSyWHFD/7Wu4t1XQ==",
"dev": true,
"dependencies": {
"ms": "^2.1.1"
}
},
"node_modules/eslint-import-resolver-webpack/node_modules/enhanced-resolve": {
"version": "0.9.1",
"resolved": "https://registry.npmjs.org/enhanced-resolve/-/enhanced-resolve-0.9.1.tgz",
"integrity": "sha512-kxpoMgrdtkXZ5h0SeraBS1iRntpTpQ3R8ussdb38+UAFnMGX5DDyJXePm+OCHOcoXvHDw7mc2erbJBpDnl7TPw==",
"dev": true,
"dependencies": {
"graceful-fs": "^4.1.2",
"memory-fs": "^0.2.0",
"tapable": "^0.1.8"
},
"engines": {
"node": ">=0.6"
}
},
"node_modules/eslint-import-resolver-webpack/node_modules/interpret": {
"version": "1.4.0",
"resolved": "https://registry.npmjs.org/interpret/-/interpret-1.4.0.tgz",
"integrity": "sha512-agE4QfB2Lkp9uICn7BAqoscw4SZP9kTE2hxiFI3jBPmXJfdqiahTbUuKGsMoN2GtqL9AxhYioAcVvgsb1HvRbA==",
"dev": true,
"engines": {
"node": ">= 0.10"
}
},
"node_modules/eslint-import-resolver-webpack/node_modules/resolve": {
"version": "2.0.0-next.5",
"resolved": "https://registry.npmjs.org/resolve/-/resolve-2.0.0-next.5.tgz",
"integrity": "sha512-U7WjGVG9sH8tvjW5SmGbQuui75FiyjAX72HX15DwBBwF9dNiQZRQAg9nnPhYy+TUnE0+VcrttuvNI8oSxZcocA==",
"dev": true,
"dependencies": {
"is-core-module": "^2.13.0",
"path-parse": "^1.0.7",
"supports-preserve-symlinks-flag": "^1.0.0"
},
"bin": {
"resolve": "bin/resolve"
},
"funding": {
"url": "https://github.com/sponsors/ljharb"
}
},
"node_modules/eslint-import-resolver-webpack/node_modules/semver": {
"version": "5.7.2",
"resolved": "https://registry.npmjs.org/semver/-/semver-5.7.2.tgz",
"integrity": "sha512-cBznnQ9KjJqU67B52RMC65CMarK2600WFnbkcaiwWq3xy/5haFJlshgnpjovMVJ+Hff49d8GEn0b87C5pDQ10g==",
"dev": true,
"bin": {
"semver": "bin/semver"
}
},
"node_modules/eslint-import-resolver-webpack/node_modules/tapable": {
"version": "0.1.10",
"resolved": "https://registry.npmjs.org/tapable/-/tapable-0.1.10.tgz",
"integrity": "sha512-jX8Et4hHg57mug1/079yitEKWGB3LCwoxByLsNim89LABq8NqgiX+6iYVOsq0vX8uJHkU+DZ5fnq95f800bEsQ==",
"dev": true,
"engines": {
"node": ">=0.6"
}
},
"node_modules/eslint-module-utils": {
"version": "2.8.0",
"resolved": "https://registry.npmjs.org/eslint-module-utils/-/eslint-module-utils-2.8.0.tgz",
@@ -10360,6 +10519,12 @@
"url": "https://github.com/sponsors/sindresorhus"
}
},
"node_modules/find-root": {
"version": "1.1.0",
"resolved": "https://registry.npmjs.org/find-root/-/find-root-1.1.0.tgz",
"integrity": "sha512-NKfW6bec6GfKc0SGx1e07QZY9PE99u0Bft/0rzSD5k3sO/vwkVUpDUKVm5Gpp5Ue3YfShPFTX2070tDs5kB9Ng==",
"dev": true
},
"node_modules/find-up": {
"version": "5.0.0",
"resolved": "https://registry.npmjs.org/find-up/-/find-up-5.0.0.tgz",
@@ -14515,6 +14680,12 @@
"node": ">= 4.0.0"
}
},
"node_modules/memory-fs": {
"version": "0.2.0",
"resolved": "https://registry.npmjs.org/memory-fs/-/memory-fs-0.2.0.tgz",
"integrity": "sha512-+y4mDxU4rvXXu5UDSGCGNiesFmwCHuefGMoPCO1WYucNYj7DsLqrFaa2fXVI0H+NNiPTwwzKwspn9yTZqUGqng==",
"dev": true
},
"node_modules/meow": {
"version": "9.0.0",
"resolved": "https://registry.npmjs.org/meow/-/meow-9.0.0.tgz",
@@ -21682,6 +21853,175 @@
"engines": {
"node": ">=10"
}
},
"plugins/course-apps/calculator": {
"version": "0.1.0",
"peerDependencies": {
"@edx/frontend-app-course-authoring": "*",
"@edx/frontend-platform": "*",
"@openedx/paragon": "*",
"prop-types": "*",
"react": "*"
},
"peerDependenciesMeta": {
"@edx/frontend-app-course-authoring": {
"optional": true
}
}
},
"plugins/course-apps/edxnotes": {
"version": "0.1.0",
"peerDependencies": {
"@edx/frontend-app-course-authoring": "*",
"@edx/frontend-platform": "*",
"@openedx/paragon": "*",
"prop-types": "*",
"react": "*"
},
"peerDependenciesMeta": {
"@edx/frontend-app-course-authoring": {
"optional": true
}
}
},
"plugins/course-apps/learning_assistant": {
"version": "0.1.0",
"peerDependencies": {
"@edx/frontend-app-course-authoring": "*",
"@edx/frontend-platform": "*",
"@openedx/paragon": "*",
"prop-types": "*",
"react": "*",
"yup": "*"
},
"peerDependenciesMeta": {
"@edx/frontend-app-course-authoring": {
"optional": true
}
}
},
"plugins/course-apps/live": {
"version": "0.1.0",
"peerDependencies": {
"@edx/frontend-app-course-authoring": "*",
"@edx/frontend-platform": "*",
"@openedx/paragon": "*",
"@reduxjs/toolkit": "*",
"lodash": "*",
"prop-types": "*",
"react": "*",
"react-redux": "*",
"react-router-dom": "*",
"yup": "*"
},
"peerDependenciesMeta": {
"@edx/frontend-app-course-authoring": {
"optional": true
}
}
},
"plugins/course-apps/ora_settings": {
"version": "0.1.0",
"peerDependencies": {
"@edx/frontend-app-course-authoring": "*",
"@edx/frontend-platform": "*",
"@openedx/paragon": "*",
"prop-types": "*",
"react": "*",
"yup": "*"
},
"peerDependenciesMeta": {
"@edx/frontend-app-course-authoring": {
"optional": true
}
}
},
"plugins/course-apps/proctoring": {
"version": "0.1.0",
"peerDependencies": {
"@edx/frontend-app-course-authoring": "*",
"@edx/frontend-platform": "*",
"@openedx/paragon": "*",
"classnames": "*",
"email-validator": "*",
"moment": "*",
"prop-types": "*",
"react": "*"
},
"peerDependenciesMeta": {
"@edx/frontend-app-course-authoring": {
"optional": true
}
}
},
"plugins/course-apps/progress": {
"version": "0.1.0",
"peerDependencies": {
"@edx/frontend-app-course-authoring": "*",
"@edx/frontend-platform": "*",
"@openedx/paragon": "*",
"prop-types": "*",
"react": "*",
"yup": "*"
},
"peerDependenciesMeta": {
"@edx/frontend-app-course-authoring": {
"optional": true
}
}
},
"plugins/course-apps/teams": {
"version": "0.1.0",
"peerDependencies": {
"@edx/frontend-app-course-authoring": "*",
"@edx/frontend-platform": "*",
"@openedx/paragon": "*",
"formik": "*",
"prop-types": "*",
"react": "*",
"uuid": "*",
"yup": "*"
},
"peerDependenciesMeta": {
"@edx/frontend-app-course-authoring": {
"optional": true
}
}
},
"plugins/course-apps/wiki": {
"version": "0.1.0",
"peerDependencies": {
"@edx/frontend-app-course-authoring": "*",
"@edx/frontend-platform": "*",
"@openedx/paragon": "*",
"prop-types": "*",
"react": "*",
"yup": "*"
},
"peerDependenciesMeta": {
"@edx/frontend-app-course-authoring": {
"optional": true
}
}
},
"plugins/course-apps/xpert_unit_summary": {
"version": "0.1.0",
"peerDependencies": {
"@edx/frontend-app-course-authoring": "*",
"@edx/frontend-platform": "*",
"@openedx/paragon": "*",
"formik": "*",
"prop-types": "*",
"react": "*",
"react-redux": "*",
"react-router-dom": "*",
"yup": "*"
},
"peerDependenciesMeta": {
"@edx/frontend-app-course-authoring": {
"optional": true
}
}
}
}
}

View File

@@ -51,6 +51,16 @@
"@fortawesome/free-regular-svg-icons": "5.15.4",
"@fortawesome/free-solid-svg-icons": "5.15.4",
"@fortawesome/react-fontawesome": "0.2.0",
"@openedx-plugins/course-app-calculator": "file:plugins/course-apps/calculator",
"@openedx-plugins/course-app-edxnotes": "file:plugins/course-apps/edxnotes",
"@openedx-plugins/course-app-live": "file:plugins/course-apps/live",
"@openedx-plugins/course-app-ora_settings": "file:plugins/course-apps/ora_settings",
"@openedx-plugins/course-app-proctoring": "file:plugins/course-apps/proctoring",
"@openedx-plugins/course-app-progress": "file:plugins/course-apps/progress",
"@openedx-plugins/course-app-teams": "file:plugins/course-apps/teams",
"@openedx-plugins/course-app-wiki": "file:plugins/course-apps/wiki",
"@openedx-plugins/course-app-learning_assistant": "file:plugins/course-apps/learning_assistant",
"@openedx-plugins/course-app-xpert_unit_summary": "file:plugins/course-apps/xpert_unit_summary",
"@reduxjs/toolkit": "1.9.7",
"@tanstack/react-query": "4.36.1",
"broadcast-channel": "^7.0.0",
@@ -92,6 +102,7 @@
"@testing-library/user-event": "^13.2.1",
"axios": "^0.27.2",
"axios-mock-adapter": "1.22.0",
"eslint-import-resolver-webpack": "^0.13.8",
"glob": "7.2.3",
"husky": "7.0.4",
"jest-canvas-mock": "^2.5.2",

View File

@@ -0,0 +1,31 @@
import React from 'react';
import PropTypes from 'prop-types';
import { useIntl } from '@edx/frontend-platform/i18n';
import AppSettingsModal from 'CourseAuthoring/pages-and-resources/app-settings-modal/AppSettingsModal';
import messages from './messages';
/**
* Settings widget for the "calculator" Course App.
* @param {{onClose: () => void}} props
*/
const CalculatorSettings = ({ onClose }) => {
const intl = useIntl();
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 = {
onClose: PropTypes.func.isRequired,
};
export default CalculatorSettings;

View File

@@ -0,0 +1,17 @@
{
"name": "@openedx-plugins/course-app-calculator",
"version": "0.1.0",
"description": "Calculator configuration for courses using it",
"peerDependencies": {
"@edx/frontend-app-course-authoring": "*",
"@edx/frontend-platform": "*",
"@openedx/paragon": "*",
"prop-types": "*",
"react": "*"
},
"peerDependenciesMeta": {
"@edx/frontend-app-course-authoring": {
"optional": true
}
}
}

View File

@@ -0,0 +1,31 @@
import React from 'react';
import PropTypes from 'prop-types';
import { useIntl } from '@edx/frontend-platform/i18n';
import AppSettingsModal from 'CourseAuthoring/pages-and-resources/app-settings-modal/AppSettingsModal';
import messages from './messages';
/**
* Settings widget for the "edxnotes" Course App.
* @param {{onClose: () => void}} props
*/
const NotesSettings = ({ onClose }) => {
const intl = useIntl();
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 = {
onClose: PropTypes.func.isRequired,
};
export default NotesSettings;

View File

@@ -0,0 +1,17 @@
{
"name": "@openedx-plugins/course-app-edxnotes",
"version": "0.1.0",
"description": "edxnotes configuration for courses using it",
"peerDependencies": {
"@edx/frontend-app-course-authoring": "*",
"@edx/frontend-platform": "*",
"@openedx/paragon": "*",
"prop-types": "*",
"react": "*"
},
"peerDependenciesMeta": {
"@edx/frontend-app-course-authoring": {
"optional": true
}
}
}

View File

@@ -1,16 +1,18 @@
import React from 'react';
import PropTypes from 'prop-types';
import { injectIntl, intlShape } from '@edx/frontend-platform/i18n';
import { useIntl } from '@edx/frontend-platform/i18n';
import { Hyperlink } from '@openedx/paragon';
import AppSettingsModal from '../app-settings-modal/AppSettingsModal';
import messages from './messages';
import { useModel } from '../../generic/model-store';
import AppSettingsModal from 'CourseAuthoring/pages-and-resources/app-settings-modal/AppSettingsModal';
import { useModel } from 'CourseAuthoring/generic/model-store';
const LearningAssistantSettings = ({ intl, onClose }) => {
import messages from './messages';
const LearningAssistantSettings = ({ onClose }) => {
const appId = 'learning_assistant';
const appInfo = useModel('courseApps', appId);
const intl = useIntl();
// We need to render more than one link, so we use the bodyChildren prop.
const bodyChildren = (
@@ -55,8 +57,7 @@ const LearningAssistantSettings = ({ intl, onClose }) => {
};
LearningAssistantSettings.propTypes = {
intl: intlShape.isRequired,
onClose: PropTypes.func.isRequired,
};
export default injectIntl(LearningAssistantSettings);
export default LearningAssistantSettings;

View File

@@ -1,9 +1,9 @@
import React from 'react';
import { screen, waitFor } from '@testing-library/react';
import { RequestStatus } from 'CourseAuthoring/data/constants';
import { render } from 'CourseAuthoring/pages-and-resources/utils.test';
import LearningAssistantSettings from './Settings';
import { render } from '../utils.test';
import { RequestStatus } from '../../data/constants';
const onClose = () => { };

View File

@@ -0,0 +1,19 @@
{
"name": "@openedx-plugins/course-app-learning_assistant",
"version": "0.1.0",
"description": "Learning Assistant configuration for courses using it",
"peerDependencies": {
"@edx/frontend-app-course-authoring": "*",
"@edx/frontend-platform": "*",
"@openedx/paragon": "*",
"prop-types": "*",
"react": "*",
"yup": "*"
},
"peerDependenciesMeta": {
"@edx/frontend-app-course-authoring": {
"optional": true
}
}
}

View File

@@ -3,11 +3,12 @@ import { getConfig } from '@edx/frontend-platform';
import { FormattedMessage, injectIntl, intlShape } from '@edx/frontend-platform/i18n';
import { Form, Hyperlink } from '@openedx/paragon';
import PropTypes from 'prop-types';
import messages from './messages';
import AppConfigFormDivider from 'CourseAuthoring/pages-and-resources/discussions/app-config-form/apps/shared/AppConfigFormDivider';
import { useModel } from 'CourseAuthoring/generic/model-store';
import { providerNames, bbbPlanTypes } from './constants';
import AppConfigFormDivider from '../discussions/app-config-form/apps/shared/AppConfigFormDivider';
import LiveCommonFields from './LiveCommonFields';
import { useModel } from '../../generic/model-store';
import messages from './messages';
const BbbSettings = ({
intl,

View File

@@ -15,8 +15,10 @@ import { AppProvider, PageWrap } from '@edx/frontend-platform/react';
import { IntlProvider } from '@edx/frontend-platform/i18n';
import userEvent from '@testing-library/user-event';
import initializeStore from '../../store';
import { executeThunk } from '../../utils';
import initializeStore from 'CourseAuthoring/store';
import { executeThunk } from 'CourseAuthoring/utils';
import PagesAndResourcesProvider from 'CourseAuthoring/pages-and-resources/PagesAndResourcesProvider';
import LiveSettings from './Settings';
import {
generateLiveConfigurationApiResponse,
@@ -24,11 +26,9 @@ import {
initialState,
configurationProviders,
} from './factories/mockApiResponses';
import { fetchLiveConfiguration, fetchLiveProviders } from './data/thunks';
import { providerConfigurationApiUrl, providersApiUrl } from './data/api';
import messages from './messages';
import PagesAndResourcesProvider from '../PagesAndResourcesProvider';
let axiosMock;
let container;

View File

@@ -1,8 +1,9 @@
import React from 'react';
import { injectIntl, intlShape } from '@edx/frontend-platform/i18n';
import PropTypes from 'prop-types';
import FormikControl from 'CourseAuthoring/generic/FormikControl';
import messages from './messages';
import FormikControl from '../../generic/FormikControl';
const LiveCommonFields = ({
intl,

View File

@@ -6,13 +6,14 @@ import { injectIntl, intlShape } from '@edx/frontend-platform/i18n';
import PropTypes from 'prop-types';
import * as Yup from 'yup';
import { useNavigate } from 'react-router-dom';
import AppSettingsModal from 'CourseAuthoring/pages-and-resources/app-settings-modal/AppSettingsModal';
import { useModel } from 'CourseAuthoring/generic/model-store';
import Loading from 'CourseAuthoring/generic/Loading';
import { RequestStatus } from 'CourseAuthoring/data/constants';
import { fetchLiveData, saveLiveConfiguration, saveLiveConfigurationAsDraft } from './data/thunks';
import { selectApp } from './data/slice';
import AppSettingsModal from '../app-settings-modal/AppSettingsModal';
import { useModel } from '../../generic/model-store';
import Loading from '../../generic/Loading';
import { iconsSrc, bbbPlanTypes } from './constants';
import { RequestStatus } from '../../data/constants';
import messages from './messages';
import ZoomSettings from './ZoomSettings';
import BBBSettings from './BBBSettings';

View File

@@ -18,8 +18,10 @@ import { getAuthenticatedHttpClient } from '@edx/frontend-platform/auth';
import { AppProvider, PageWrap } from '@edx/frontend-platform/react';
import { IntlProvider } from '@edx/frontend-platform/i18n';
import initializeStore from '../../store';
import { executeThunk } from '../../utils';
import initializeStore from 'CourseAuthoring/store';
import { executeThunk } from 'CourseAuthoring/utils';
import PagesAndResourcesProvider from 'CourseAuthoring/pages-and-resources/PagesAndResourcesProvider';
import LiveSettings from './Settings';
import {
generateLiveConfigurationApiResponse,
@@ -31,7 +33,6 @@ import {
import { fetchLiveConfiguration, fetchLiveProviders } from './data/thunks';
import { providerConfigurationApiUrl, providersApiUrl } from './data/api';
import messages from './messages';
import PagesAndResourcesProvider from '../PagesAndResourcesProvider';
let axiosMock;
let container;

View File

@@ -1,10 +1,11 @@
import React from 'react';
import { injectIntl, intlShape } from '@edx/frontend-platform/i18n';
import PropTypes from 'prop-types';
import FormikControl from 'CourseAuthoring/generic/FormikControl';
import messages from './messages';
import { providerNames } from './constants';
import LiveCommonFields from './LiveCommonFields';
import FormikControl from '../../generic/FormikControl';
const ZoomSettings = ({
intl,

View File

@@ -13,8 +13,9 @@ import { getAuthenticatedHttpClient } from '@edx/frontend-platform/auth';
import { AppProvider, PageWrap } from '@edx/frontend-platform/react';
import { IntlProvider } from '@edx/frontend-platform/i18n';
import initializeStore from '../../store';
import { executeThunk } from '../../utils';
import initializeStore from 'CourseAuthoring/store';
import { executeThunk } from 'CourseAuthoring/utils';
import PagesAndResourcesProvider from 'CourseAuthoring/pages-and-resources/PagesAndResourcesProvider';
import LiveSettings from './Settings';
import {
generateLiveConfigurationApiResponse,
@@ -26,7 +27,6 @@ import {
import { fetchLiveConfiguration, fetchLiveProviders } from './data/thunks';
import { providerConfigurationApiUrl, providersApiUrl } from './data/api';
import messages from './messages';
import PagesAndResourcesProvider from '../PagesAndResourcesProvider';
let axiosMock;
let container;

View File

@@ -1,6 +1,6 @@
/* eslint-disable no-param-reassign */
import { createSlice } from '@reduxjs/toolkit';
import { RequestStatus } from '../../../data/constants';
import { RequestStatus } from 'CourseAuthoring/data/constants';
const slice = createSlice({
name: 'live',

View File

@@ -1,4 +1,6 @@
import { addModel, addModels, updateModel } from '../../../generic/model-store';
import { addModel, addModels, updateModel } from 'CourseAuthoring/generic/model-store';
import { RequestStatus } from 'CourseAuthoring/data/constants';
import {
getLiveConfiguration,
getLiveProviders,
@@ -7,7 +9,6 @@ import {
deNormalizeSettings,
} from './api';
import { loadApps, updateStatus, updateSaveStatus } from './slice';
import { RequestStatus } from '../../../data/constants';
function updateLiveSettingsState({
appConfig,

View File

@@ -0,0 +1,22 @@
{
"name": "@openedx-plugins/course-app-live",
"version": "0.1.0",
"description": "Live course configuration for courses using it",
"peerDependencies": {
"@edx/frontend-app-course-authoring": "*",
"@edx/frontend-platform": "*",
"@openedx/paragon": "*",
"@reduxjs/toolkit": "*",
"lodash": "*",
"prop-types": "*",
"react": "*",
"react-redux": "*",
"react-router-dom": "*",
"yup": "*"
},
"peerDependenciesMeta": {
"@edx/frontend-app-course-authoring": {
"optional": true
}
}
}

View File

@@ -5,11 +5,11 @@ import * as Yup from 'yup';
import { injectIntl, intlShape } from '@edx/frontend-platform/i18n';
import { Hyperlink } from '@openedx/paragon';
import { useModel } from '../../generic/model-store';
import { useModel } from 'CourseAuthoring/generic/model-store';
import FormSwitchGroup from '../../generic/FormSwitchGroup';
import { useAppSetting } from '../../utils';
import AppSettingsModal from '../app-settings-modal/AppSettingsModal';
import FormSwitchGroup from 'CourseAuthoring/generic/FormSwitchGroup';
import { useAppSetting } from 'CourseAuthoring/utils';
import AppSettingsModal from 'CourseAuthoring/pages-and-resources/app-settings-modal/AppSettingsModal';
import messages from './messages';
const ORASettings = ({ intl, onClose }) => {

View File

@@ -9,14 +9,14 @@ jest.mock('@edx/frontend-platform/i18n', () => ({
jest.mock('yup', () => ({
boolean: jest.fn().mockReturnValue('Yub.boolean'),
}));
jest.mock('../../generic/model-store', () => ({
jest.mock('CourseAuthoring/generic/model-store', () => ({
useModel: jest.fn().mockReturnValue({ documentationLinks: { learnMoreConfiguration: 'https://learnmore.test' } }),
}));
jest.mock('../../generic/FormSwitchGroup', () => 'FormSwitchGroup');
jest.mock('../../utils', () => ({
jest.mock('CourseAuthoring/generic/FormSwitchGroup', () => 'FormSwitchGroup');
jest.mock('CourseAuthoring/utils', () => ({
useAppSetting: jest.fn().mockReturnValue(['abitrary value', jest.fn().mockName('saveSetting')]),
}));
jest.mock('../app-settings-modal/AppSettingsModal', () => 'AppSettingsModal');
jest.mock('CourseAuthoring/pages-and-resources/app-settings-modal/AppSettingsModal', () => 'AppSettingsModal');
const props = {
onClose: jest.fn().mockName('onClose'),

View File

@@ -0,0 +1,19 @@
{
"name": "@openedx-plugins/course-app-ora_settings",
"version": "0.1.0",
"description": "Open Response Assessment configuration for courses using it",
"peerDependencies": {
"@edx/frontend-app-course-authoring": "*",
"@edx/frontend-platform": "*",
"@openedx/paragon": "*",
"prop-types": "*",
"react": "*",
"yup": "*"
},
"peerDependenciesMeta": {
"@edx/frontend-app-course-authoring": {
"optional": true
}
}
}

View File

@@ -13,15 +13,16 @@ import {
ActionRow, Alert, Badge, Form, Hyperlink, ModalDialog, StatefulButton,
} from '@openedx/paragon';
import ExamsApiService from '../../data/services/ExamsApiService';
import StudioApiService from '../../data/services/StudioApiService';
import Loading from '../../generic/Loading';
import ConnectionErrorAlert from '../../generic/ConnectionErrorAlert';
import FormSwitchGroup from '../../generic/FormSwitchGroup';
import { useModel } from '../../generic/model-store';
import PermissionDeniedAlert from '../../generic/PermissionDeniedAlert';
import { useIsMobile } from '../../utils';
import { PagesAndResourcesContext } from '../PagesAndResourcesProvider';
import ExamsApiService from 'CourseAuthoring/data/services/ExamsApiService';
import StudioApiService from 'CourseAuthoring/data/services/StudioApiService';
import Loading from 'CourseAuthoring/generic/Loading';
import ConnectionErrorAlert from 'CourseAuthoring/generic/ConnectionErrorAlert';
import FormSwitchGroup from 'CourseAuthoring/generic/FormSwitchGroup';
import { useModel } from 'CourseAuthoring/generic/model-store';
import PermissionDeniedAlert from 'CourseAuthoring/generic/PermissionDeniedAlert';
import { useIsMobile } from 'CourseAuthoring/utils';
import { PagesAndResourcesContext } from 'CourseAuthoring/pages-and-resources/PagesAndResourcesProvider';
import messages from './messages';
const ProctoringSettings = ({ intl, onClose }) => {

View File

@@ -9,10 +9,10 @@ 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 StudioApiService from 'CourseAuthoring/data/services/StudioApiService';
import ExamsApiService from 'CourseAuthoring/data/services/ExamsApiService';
import initializeStore from 'CourseAuthoring/store';
import PagesAndResourcesProvider from 'CourseAuthoring/pages-and-resources/PagesAndResourcesProvider';
import ProctoredExamSettings from './Settings';
const defaultProps = {

View File

@@ -0,0 +1,20 @@
{
"name": "@openedx-plugins/course-app-proctoring",
"version": "0.1.0",
"description": "Proctoring configuration for courses using it",
"peerDependencies": {
"@edx/frontend-app-course-authoring": "*",
"@edx/frontend-platform": "*",
"@openedx/paragon": "*",
"classnames": "*",
"email-validator": "*",
"react": "*",
"prop-types": "*",
"moment": "*"
},
"peerDependenciesMeta": {
"@edx/frontend-app-course-authoring": {
"optional": true
}
}
}

View File

@@ -3,9 +3,9 @@ import PropTypes from 'prop-types';
import React from 'react';
import * as Yup from 'yup';
import { getConfig } from '@edx/frontend-platform';
import FormSwitchGroup from '../../generic/FormSwitchGroup';
import { useAppSetting } from '../../utils';
import AppSettingsModal from '../app-settings-modal/AppSettingsModal';
import FormSwitchGroup from 'CourseAuthoring/generic/FormSwitchGroup';
import { useAppSetting } from 'CourseAuthoring/utils';
import AppSettingsModal from 'CourseAuthoring/pages-and-resources/app-settings-modal/AppSettingsModal';
import messages from './messages';
const ProgressSettings = ({ intl, onClose }) => {

View File

@@ -0,0 +1,18 @@
{
"name": "@openedx-plugins/course-app-progress",
"version": "0.1.0",
"description": "Progress configuration for courses using it",
"peerDependencies": {
"@edx/frontend-app-course-authoring": "*",
"@edx/frontend-platform": "*",
"@openedx/paragon": "*",
"prop-types": "*",
"react": "*",
"yup": "*"
},
"peerDependenciesMeta": {
"@edx/frontend-app-course-authoring": {
"optional": true
}
}
}

View File

@@ -2,10 +2,10 @@ import { injectIntl, intlShape } from '@edx/frontend-platform/i18n';
import { Button, Form, TransitionReplace } from '@openedx/paragon';
import PropTypes from 'prop-types';
import React, { useState } from 'react';
import { GroupTypes, TeamSizes } from '../../data/constants';
import { GroupTypes, TeamSizes } from 'CourseAuthoring/data/constants';
import CollapsableEditor from '../../generic/CollapsableEditor';
import FormikControl from '../../generic/FormikControl';
import CollapsableEditor from 'CourseAuthoring/generic/CollapsableEditor';
import FormikControl from 'CourseAuthoring/generic/FormikControl';
import messages from './messages';
// Maps a team type to its corresponding intl message

View File

@@ -7,10 +7,10 @@ import PropTypes from 'prop-types';
import React from 'react';
import { v4 as uuid } from 'uuid';
import * as Yup from 'yup';
import { GroupTypes, TeamSizes } from '../../data/constants';
import FormikControl from '../../generic/FormikControl';
import { setupYupExtensions, useAppSetting } from '../../utils';
import AppSettingsModal from '../app-settings-modal/AppSettingsModal';
import { GroupTypes, TeamSizes } from 'CourseAuthoring/data/constants';
import FormikControl from 'CourseAuthoring/generic/FormikControl';
import { setupYupExtensions, useAppSetting } from 'CourseAuthoring/utils';
import AppSettingsModal from 'CourseAuthoring/pages-and-resources/app-settings-modal/AppSettingsModal';
import GroupEditor from './GroupEditor';
import messages from './messages';

View File

@@ -0,0 +1,20 @@
{
"name": "@openedx-plugins/course-app-teams",
"version": "0.1.0",
"description": "Teams configuration for courses using it",
"peerDependencies": {
"@edx/frontend-app-course-authoring": "*",
"@edx/frontend-platform": "*",
"@openedx/paragon": "*",
"formik": "*",
"prop-types": "*",
"react": "*",
"uuid": "*",
"yup": "*"
},
"peerDependenciesMeta": {
"@edx/frontend-app-course-authoring": {
"optional": true
}
}
}

View File

@@ -3,9 +3,9 @@ import PropTypes from 'prop-types';
import React from 'react';
import * as Yup from 'yup';
import FormSwitchGroup from '../../generic/FormSwitchGroup';
import { useAppSetting } from '../../utils';
import AppSettingsModal from '../app-settings-modal/AppSettingsModal';
import FormSwitchGroup from 'CourseAuthoring/generic/FormSwitchGroup';
import { useAppSetting } from 'CourseAuthoring/utils';
import AppSettingsModal from 'CourseAuthoring/pages-and-resources/app-settings-modal/AppSettingsModal';
import messages from './messages';
const WikiSettings = ({ intl, onClose }) => {

View File

@@ -0,0 +1,18 @@
{
"name": "@openedx-plugins/course-app-wiki",
"version": "0.1.0",
"description": "Wiki configuration for courses using it",
"peerDependencies": {
"@edx/frontend-app-course-authoring": "*",
"@edx/frontend-platform": "*",
"@openedx/paragon": "*",
"prop-types": "*",
"react": "*",
"yup": "*"
},
"peerDependenciesMeta": {
"@edx/frontend-app-course-authoring": {
"optional": true
}
}
}

View File

@@ -0,0 +1,4 @@
Xpert Unit Summaries Configuration Plugin
=========================================
Install this using ``npm install plugins/course-apps/xpert_unit_summary/ --no-save``.

View File

@@ -2,8 +2,8 @@ import React, { useCallback, useContext, useEffect } from 'react';
import { useDispatch } from 'react-redux';
import { injectIntl, intlShape } from '@edx/frontend-platform/i18n';
import { PagesAndResourcesContext } from 'CourseAuthoring/pages-and-resources/PagesAndResourcesProvider';
import { useNavigate } from 'react-router-dom';
import { PagesAndResourcesContext } from '../PagesAndResourcesProvider';
import SettingsModal from './settings-modal/SettingsModal';
import messages from './messages';

View File

@@ -10,12 +10,13 @@ import {
queryByTestId, render, waitFor, getByText, fireEvent,
} from '@testing-library/react';
import MockAdapter from 'axios-mock-adapter';
import PagesAndResourcesProvider from '../PagesAndResourcesProvider';
import { XpertUnitSummarySettings } from './index';
import initializeStore from '../../store';
import PagesAndResourcesProvider from 'CourseAuthoring/pages-and-resources/PagesAndResourcesProvider';
import initializeStore from 'CourseAuthoring/store';
import { executeThunk } from 'CourseAuthoring/utils';
import XpertUnitSummarySettings from './Settings';
import * as API from './data/api';
import * as Thunks from './data/thunks';
import { executeThunk } from '../../utils';
const courseId = 'course-v1:edX+TestX+Test_Course';
let axiosMock;

View File

@@ -1,12 +1,11 @@
import { updateSavingStatus, updateLoadingStatus, updateResetStatus } from 'CourseAuthoring/pages-and-resources/data/slice';
import { RequestStatus } from 'CourseAuthoring/data/constants';
import { addModel, updateModel } from 'CourseAuthoring/generic/model-store';
import {
getXpertSettings, postXpertSettings, getXpertPluginConfigurable, deleteXpertSettings,
} from './api';
import { updateSavingStatus, updateLoadingStatus, updateResetStatus } from '../../data/slice';
import { RequestStatus } from '../../../data/constants';
import { addModel, updateModel } from '../../../generic/model-store';
export function updateXpertSettings(courseId, state) {
return async (dispatch) => {
dispatch(updateSavingStatus({ status: RequestStatus.IN_PROGRESS }));

View File

@@ -0,0 +1,21 @@
{
"name": "@openedx-plugins/course-app-xpert_unit_summary",
"version": "0.1.0",
"description": "Xpert Unit Summaries configuration for courses using it",
"peerDependencies": {
"@edx/frontend-app-course-authoring": "*",
"@edx/frontend-platform": "*",
"@openedx/paragon": "*",
"formik": "*",
"prop-types": "*",
"yup": "*",
"react": "*",
"react-redux": "*",
"react-router-dom": "*"
},
"peerDependenciesMeta": {
"@edx/frontend-app-course-authoring": {
"optional": true
}
}
}

View File

@@ -24,22 +24,25 @@ import React, {
import { useDispatch, useSelector } from 'react-redux';
import * as Yup from 'yup';
import { RequestStatus } from '../../../data/constants';
import ConnectionErrorAlert from '../../../generic/ConnectionErrorAlert';
import FormSwitchGroup from '../../../generic/FormSwitchGroup';
import Loading from '../../../generic/Loading';
import { useModel } from '../../../generic/model-store';
import PermissionDeniedAlert from '../../../generic/PermissionDeniedAlert';
import { useIsMobile } from '../../../utils';
import { getLoadingStatus, getSavingStatus, getResetStatus } from '../../data/selectors';
import { updateSavingStatus, updateResetStatus } from '../../data/slice';
import { RequestStatus } from 'CourseAuthoring/data/constants';
import ConnectionErrorAlert from 'CourseAuthoring/generic/ConnectionErrorAlert';
import FormSwitchGroup from 'CourseAuthoring/generic/FormSwitchGroup';
import Loading from 'CourseAuthoring/generic/Loading';
import { useModel } from 'CourseAuthoring/generic/model-store';
import PermissionDeniedAlert from 'CourseAuthoring/generic/PermissionDeniedAlert';
import { useIsMobile } from 'CourseAuthoring/utils';
import { getLoadingStatus, getSavingStatus, getResetStatus } from 'CourseAuthoring/pages-and-resources/data/selectors';
import { updateSavingStatus, updateResetStatus } from 'CourseAuthoring/pages-and-resources/data/slice';
import AppConfigFormDivider from 'CourseAuthoring/pages-and-resources/discussions/app-config-form/apps/shared/AppConfigFormDivider';
import { PagesAndResourcesContext } from 'CourseAuthoring/pages-and-resources/PagesAndResourcesProvider';
import { updateXpertSettings, resetXpertSettings, removeXpertSettings } from '../data/thunks';
import AppConfigFormDivider from '../../discussions/app-config-form/apps/shared/AppConfigFormDivider';
import { PagesAndResourcesContext } from '../../PagesAndResourcesProvider';
import messages from './messages';
import appInfo from '../appInfo';
import ResetIcon from './ResetIcon';
import './SettingsModal.scss';
const AppSettingsForm = ({
formikProps, children, showForm,
}) => children && (

View File

@@ -1,3 +1,6 @@
@import "~@edx/brand/paragon/variables";
@import "~@openedx/paragon/scss/core/utilities-only";
.summary-radio {
display: flex;
align-items: center;

View File

@@ -13,7 +13,6 @@
@import "studio-home/scss/StudioHome";
@import "generic/styles";
@import "schedule-and-details/ScheduleAndDetails";
@import "pages-and-resources/PagesAndResources";
@import "course-team/CourseTeam";
@import "course-updates/CourseUpdates";
@import "export-page/CourseExportPage";

View File

@@ -5,17 +5,10 @@ import { PageWrap, AppContext } from '@edx/frontend-platform/react';
import { Routes, Route } from 'react-router-dom';
import { useDispatch, useSelector } from 'react-redux';
import _ from 'lodash';
import { Button, Hyperlink } from '@openedx/paragon';
import messages from './messages';
import DiscussionsSettings from './discussions';
import {
XpertUnitSummarySettings,
fetchXpertPluginConfigurable,
fetchXpertSettings,
appInfo as XpertAppInfo,
} from './xpert-unit-summary';
import PageGrid from './pages/PageGrid';
import { fetchCourseApps } from './data/thunks';
@@ -34,8 +27,6 @@ const PagesAndResources = ({ courseId, intl }) => {
const dispatch = useDispatch();
useEffect(() => {
dispatch(fetchCourseApps(courseId));
dispatch(fetchXpertPluginConfigurable(courseId));
dispatch(fetchXpertSettings(courseId));
}, [courseId]);
const courseAppIds = useSelector(state => state.pagesAndResources.courseAppIds);
@@ -46,32 +37,21 @@ const PagesAndResources = ({ courseId, intl }) => {
const learningCourseURL = `${config.LEARNING_BASE_URL}/course/${courseId}`;
const redirectUrl = `/course/${courseId}/pages-and-resources`;
// Most pages here are driven by a course app. The one exception is the Xpert unit summaries page.
// The pages here are driven by course apps. The list of course app IDs comes from the LMS API.
// We display all enabled course apps regardless of whether or not the corresponding frontend plugin is available.
const pages = useModels('courseApps', courseAppIds);
const xpertPluginConfigurable = useModel('XpertSettings.enabled', 'xpert-unit-summary');
const xpertSettings = useModel('XpertSettings', 'xpert-unit-summary');
// These pages appear in a separate "Content Permissions" section at the bottom of the page.
// If there are no content permission pages, this section will not appear.
// We want the Xpert learning assistant and unit summaries to appear in the "Content Permissions" section instead,
// so we remove them from pages and add them to contentPermissionsPages.
const contentPermissionsPages = [];
// Xpert unit summaries
if (xpertPluginConfigurable?.enabled) {
contentPermissionsPages.push({
...XpertAppInfo,
enabled: xpertSettings?.enabled !== undefined,
});
}
// Xpert learning assistant
if (_.some(pages, (page) => page.id === 'learning_assistant')) {
const index = pages.findIndex(app => app.id === 'learning_assistant');
// We want the Xpert learning assistant page to appear in the "Content Permissions" section instead,
// so we remove it from pages and add it to contentPermissionsPages.
const [page] = pages.splice(index, 1);
contentPermissionsPages.push(page);
}
['xpert_unit_summary', 'learning_assistant'].forEach(separateAppId => {
const index = pages.findIndex(app => app.id === separateAppId);
if (index !== -1) {
const [page] = pages.splice(index, 1);
contentPermissionsPages.push(page);
}
});
if (loadingStatus === RequestStatus.IN_PROGRESS) {
// eslint-disable-next-line react/jsx-no-useless-fragment
@@ -99,10 +79,16 @@ const PagesAndResources = ({ courseId, intl }) => {
</Hyperlink>
</div>
<PageGrid pages={pages} />
<Routes>
<Route path="discussion/configure/:appId" element={<PageWrap><DiscussionsSettings courseId={courseId} /></PageWrap>} />
<Route path="discussion" element={<PageWrap><DiscussionsSettings courseId={courseId} /></PageWrap>} />
<Route path="discussion/settings" element={<PageWrap><DiscussionsSettings courseId={courseId} /></PageWrap>} />
<Route path=":appId/settings" element={<PageWrap><Suspense fallback="..."><SettingsComponent url={redirectUrl} /></Suspense></PageWrap>} />
</Routes>
<PageGrid pages={pages} />
{
!_.isEmpty(contentPermissionsPages) && (
(contentPermissionsPages.length > 0) && (
<>
<div className="d-flex justify-content-between my-4 my-md-5 align-items-center">
<h3 className="m-0">{intl.formatMessage(messages.contentPermissions)}</h3>
@@ -111,14 +97,6 @@ const PagesAndResources = ({ courseId, intl }) => {
</>
)
}
<Routes>
<Route path="discussion/configure/:appId" element={<PageWrap><DiscussionsSettings courseId={courseId} /></PageWrap>} />
<Route path="discussion" element={<PageWrap><DiscussionsSettings courseId={courseId} /></PageWrap>} />
<Route path="discussion/settings" element={<PageWrap><DiscussionsSettings courseId={courseId} /></PageWrap>} />
<Route path="xpert-unit-summary/settings" element={<PageWrap><XpertUnitSummarySettings courseId={courseId} /></PageWrap>} />
<Route path=":appId/settings" element={<PageWrap><Suspense fallback="..."><SettingsComponent url={redirectUrl} /></Suspense></PageWrap>} />
</Routes>
</main>
</PagesAndResourcesProvider>
);

View File

@@ -1 +0,0 @@
@import "./xpert-unit-summary/settings-modal/SettingsModal";

View File

@@ -1,10 +1,7 @@
import { camelCaseObject } from '@edx/frontend-platform';
import { screen, waitFor } from '@testing-library/react';
import { PagesAndResources } from '.';
import * as pagesAndResourcesApi from './data/api';
import { render } from './utils.test';
import * as xpertUnitSummaryApi from './xpert-unit-summary/data/api';
const courseId = 'course-v1:edX+TestX+Test_Course';
@@ -14,56 +11,80 @@ describe('PagesAndResources', () => {
});
it('doesn\'t show content permissions section if relevant apps are not enabled', () => {
jest.spyOn(pagesAndResourcesApi, 'getCourseApps').mockResolvedValue(camelCaseObject([]));
const apiResponse = { response: { enabled: true } };
jest.spyOn(xpertUnitSummaryApi, 'getXpertSettings').mockResolvedValue(apiResponse);
jest.spyOn(xpertUnitSummaryApi, 'getXpertPluginConfigurable').mockResolvedValue(apiResponse);
const initialState = {
models: {
courseApps: {},
},
pagesAndResources: {
courseAppIds: [],
},
};
render(
<PagesAndResources
courseId={courseId}
/>,
<PagesAndResources courseId={courseId} />,
{ preloadedState: initialState },
);
expect(screen.queryByRole('heading', { name: 'Content permissions' })).not.toBeInTheDocument();
});
it('show content permissions section if Learning Assistant app is enabled', async () => {
const apiResponse = [
{
id: 'learning_assistant',
enabled: true,
name: 'Learning Assistant',
description: 'Learning Assistant description',
allowed_operations: {
configure: false,
enable: true,
const initialState = {
models: {
courseApps: {
learning_assistant: {
id: 'learning_assistant',
enabled: true,
name: 'Learning Assistant',
description: 'Learning Assistant description',
allowedOperations: {
configure: false,
enable: true,
},
documentationLinks: {},
},
},
documentation_links: {},
},
];
jest.spyOn(pagesAndResourcesApi, 'getCourseApps').mockResolvedValue(camelCaseObject(apiResponse));
pagesAndResources: {
courseAppIds: ['learning_assistant'],
},
};
render(
<PagesAndResources
courseId={courseId}
/>,
<PagesAndResources courseId={courseId} />,
{ preloadedState: initialState },
);
await waitFor(() => expect(screen.getByRole('heading', { name: 'Content permissions' })).toBeInTheDocument());
await waitFor(() => expect(screen.getByText('Learning Assistant')).toBeInTheDocument());
});
it('show content permissions section if Xpert learning summaries app is enabled', async () => {
const apiResponse = { response: { enabled: true } };
jest.spyOn(xpertUnitSummaryApi, 'getXpertSettings').mockResolvedValue(apiResponse);
jest.spyOn(xpertUnitSummaryApi, 'getXpertPluginConfigurable').mockResolvedValue(apiResponse);
it('show content permissions section if Xpert learning summaries app is enabled', async () => {
const initialState = {
models: {
courseApps: {
xpert_unit_summary: {
id: 'xpert_unit_summary',
enabled: false,
name: 'Xpert unit summaries',
description: 'Use generative AI to summarize course content and reinforce learning.',
allowedOperations: {
enable: true,
configure: true,
},
documentationLinks: {
learnMoreConfiguration: 'https://openai.com/',
},
},
},
},
pagesAndResources: {
courseAppIds: ['xpert_unit_summary'],
},
};
render(
<PagesAndResources
courseId={courseId}
/>,
<PagesAndResources courseId={courseId} />,
{ preloadedState: initialState },
);
await waitFor(() => expect(screen.getByRole('heading', { name: 'Content permissions' })).toBeInTheDocument());

View File

@@ -1,23 +1,29 @@
import React from 'react';
import PropTypes from 'prop-types';
import { useParams, useNavigate } from 'react-router-dom';
import { useIntl } from '@edx/frontend-platform/i18n';
import { ErrorAlert } from '@edx/frontend-lib-content-components';
import messages from './messages';
const PluginLoadFailedError = () => {
const intl = useIntl();
return <ErrorAlert isError>{intl.formatMessage(messages.errorShowingConfiguration)}</ErrorAlert>;
};
const SettingsComponent = ({ url }) => {
const { appId } = useParams();
const navigate = useNavigate();
const LazyLoadedComponent = React.lazy(async () => {
try {
// There seems to be a bug in babel-eslint that causes the checker to crash with the following error
// if we use a template string here:
// TypeError: Cannot read property 'range' of null with using template strings here.
// Ref: https://github.com/babel/babel-eslint/issues/530
return await import(`./${appId}/Settings.jsx`);
} catch (error) {
console.trace(error); // eslint-disable-line no-console
return null;
}
});
const LazyLoadedComponent = React.useMemo(
() => React.lazy(() =>
import(`@openedx-plugins/course-app-${appId}/Settings.jsx`).catch((err) => { // eslint-disable-line
// If we couldn't load this plugin, log the details to the console.
console.trace(err); // eslint-disable-line no-console
return { default: PluginLoadFailedError };
})),
[appId],
);
return <LazyLoadedComponent onClose={() => navigate(url)} />;
};

View File

@@ -1,26 +1,83 @@
import React, { Suspense } from 'react';
import { BrowserRouter } from 'react-router-dom';
import { render } from '@testing-library/react';
import { useParams } from 'react-router-dom';
import { render, screen, waitFor } from '@testing-library/react';
import { IntlProvider } from '@edx/frontend-platform/i18n';
import { AppProvider } from '@edx/frontend-platform/react';
import { initializeMockApp } from '@edx/frontend-platform/testing';
import PagesAndResourcesProvider from 'CourseAuthoring/pages-and-resources/PagesAndResourcesProvider';
import initializeStore from 'CourseAuthoring/store';
import { RequestStatus } from 'CourseAuthoring/data/constants';
import SettingsComponent from './SettingsComponent';
jest.mock('react-router-dom', () => ({
...jest.requireActual('react-router-dom'),
useParams: () => ({
appId: 'wiki',
}),
useParams: jest.fn(),
}));
jest.mock('CourseAuthoring/utils', () => ({
useAppSetting: () => [false, () => undefined],
useIsMobile: () => false,
}));
let store;
// eslint-disable-next-line react/prop-types
const RequiredProviders = ({ children }) => (
<IntlProvider locale="en" messages={{}}>
<AppProvider store={store}>
<PagesAndResourcesProvider courseId="course-v1:foo+bar+baz">
{children}
</PagesAndResourcesProvider>
</AppProvider>
</IntlProvider>
);
describe('SettingsComponent', () => {
beforeEach(async () => {
initializeMockApp();
store = initializeStore({
models: {
courseApps: {
wiki: {},
},
},
pagesAndResources: {
loadingStatus: RequestStatus.SUCCESSFUL,
},
});
});
test('renders LazyLoadedComponent when provided with props', async () => {
const { asFragment } = render(
<BrowserRouter>
<Suspense fallback="...">
<SettingsComponent url="/some-url" />
</Suspense>
</BrowserRouter>,
useParams.mockImplementation(() => ({ appId: 'wiki' }));
const rendered = render(
<Suspense fallback="...">
<SettingsComponent url="/some-url" />
</Suspense>,
{ wrapper: RequiredProviders },
);
expect(asFragment).toMatchSnapshot();
await waitFor(() => expect(rendered.getByText('Configure wiki')).toBeInTheDocument());
const modalComponent = screen.getByRole('dialog');
expect(modalComponent.querySelector('#enable-wiki-toggleHelpText')).toContainHTML('The course wiki can be set up');
});
test('renders error message when plugin is unavilable when provided with props', async () => {
// Silence noisy error about the plugin failing to load, when we do that deliberately.
jest.spyOn(console, 'trace').mockImplementation(() => {});
// Specify an invalid course app, with no matching plugin:
useParams.mockImplementation(() => ({ appId: 'invalid-plugin' }));
const rendered = render(
<Suspense fallback="...">
<SettingsComponent url="/some-url" />
</Suspense>,
{ wrapper: RequiredProviders },
);
const errorMessage = 'An error occurred when loading the configuration UI';
await waitFor(() => expect(rendered.container).toHaveTextContent(errorMessage));
});
});

View File

@@ -1,3 +0,0 @@
// Jest Snapshot v1, https://goo.gl/fbAQLP
exports[`SettingsComponent renders LazyLoadedComponent when provided with props 1`] = `[Function]`;

View File

@@ -126,6 +126,8 @@ const AppSettingsModal = ({
const updateSettingsRequestStatus = useSelector(getSavingStatus);
const alertRef = useRef(null);
const [saveError, setSaveError] = useState(false);
// FIXME: open the "Live" settings, then refresh the page. The courseApps model is not loaded, and an error occurs
// when trying to access 'appInfo.documentationLinks'. This happens even before the refactor to use plugins.
const appInfo = useModel('courseApps', appId);
const dispatch = useDispatch();
const submitButtonState = updateSettingsRequestStatus === RequestStatus.IN_PROGRESS ? 'pending' : 'default';

View File

@@ -1,25 +0,0 @@
import React from 'react';
import PropTypes from 'prop-types';
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}
/>
);
CalculatorSettings.propTypes = {
intl: intlShape.isRequired,
onClose: PropTypes.func.isRequired,
};
export default injectIntl(CalculatorSettings);

View File

@@ -6,8 +6,8 @@ import React, { useState } from 'react';
import { useSelector } from 'react-redux';
import * as Yup from 'yup';
import { useModel, useModels } from '../../../../../generic/model-store';
import { setupYupExtensions } from '../../../../../utils';
import { useModel, useModels } from 'CourseAuthoring/generic/model-store';
import { setupYupExtensions } from 'CourseAuthoring/utils';
import messages from '../../messages';
import { checkFieldErrors } from '../../utils';
import AnonymousPostingFields from '../shared/AnonymousPostingFields';

View File

@@ -1,25 +0,0 @@
import React from 'react';
import PropTypes from 'prop-types';
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}
/>
);
NotesSettings.propTypes = {
intl: intlShape.isRequired,
onClose: PropTypes.func.isRequired,
};
export default injectIntl(NotesSettings);

View File

@@ -13,6 +13,10 @@ const messages = defineMessages({
id: 'course-authoring.pages-resources.viewLive.button',
defaultMessage: 'View live',
},
errorShowingConfiguration: {
id: 'course-authoring.pages-resources.courseAppPlugin.errorMessage',
defaultMessage: 'An error occurred when loading the configuration UI for that app.',
},
enabled: {
id: 'course-authoring.badge.enabled',
defaultMessage: 'Enabled',

View File

@@ -1,10 +0,0 @@
import XpertUnitSummarySettings from './XpertUnitSummarySettings';
import appInfo from './appInfo';
import { fetchXpertPluginConfigurable, fetchXpertSettings } from './data/thunks';
export {
XpertUnitSummarySettings,
appInfo,
fetchXpertPluginConfigurable,
fetchXpertSettings,
};

View File

@@ -1,15 +1,18 @@
import { configureStore } from '@reduxjs/toolkit';
// FIXME: because the 'live' plugin is using Redux, we have to hard-code a reference to it here.
// If this app + the plugin were using React-query, there'd be no issues.
import { reducer as liveReducer } from '@openedx-plugins/course-app-live/data/slice';
import { reducer as modelsReducer } from './generic/model-store';
import { reducer as courseDetailReducer } from './data/slice';
import { reducer as discussionsReducer } from './pages-and-resources/discussions';
import { reducer as discussionsReducer } from './pages-and-resources/discussions/data/slice';
import { reducer as pagesAndResourcesReducer } from './pages-and-resources/data/slice';
import { reducer as customPagesReducer } from './custom-pages/data/slice';
import { reducer as advancedSettingsReducer } from './advanced-settings/data/slice';
import { reducer as gradingSettingsReducer } from './grading-settings/data/slice';
import { reducer as studioHomeReducer } from './studio-home/data/slice';
import { reducer as scheduleAndDetailsReducer } from './schedule-and-details/data/slice';
import { reducer as liveReducer } from './pages-and-resources/live/data/slice';
import { reducer as filesReducer } from './files-and-videos/files-page/data/slice';
import { reducer as courseTeamReducer } from './course-team/data/slice';
import { reducer as CourseUpdatesReducer } from './course-updates/data/slice';

View File

@@ -1,7 +1,12 @@
const path = require('path');
const { createConfig } = require('@openedx/frontend-build');
const config = createConfig('webpack-dev', {
resolve: {
alias: {
// Plugins can use 'CourseAuthoring' as an import alias for this app:
CourseAuthoring: path.resolve(__dirname, 'src/'),
},
fallback: {
fs: false,
constants: false,

View File

@@ -1,7 +1,12 @@
const path = require('path');
const { createConfig } = require('@openedx/frontend-build');
const config = createConfig('webpack-prod', {
resolve: {
alias: {
// Plugins can use 'CourseAuthoring' as an import alias for this app:
CourseAuthoring: path.resolve(__dirname, 'src/'),
},
fallback: {
fs: false,
constants: false,