Compare commits

..

1 Commits

Author SHA1 Message Date
Muhammad Faraz Maqsood
9610f0791f feat: add games xblock editor
With this Commit, games xblock editor is in place now!
- copy code from https://github.com/openedx-unsupported/frontend-lib-content-components/pull/371/files to authoring MFE
  - It includes refactoring in .scss files, useIntl, replacing deprecated dependencies, fixing reducers, fixed cancel/close editor button, fix dragging the cards, edit some styles and also removed duplicate styling etc.
2025-11-17 14:08:16 +05:00
905 changed files with 17945 additions and 26062 deletions

5
.env
View File

@@ -37,16 +37,15 @@ ENABLE_VIDEO_UPLOAD_PAGE_LINK_IN_CONTENT_DROPDOWN=false
ENABLE_TAGGING_TAXONOMY_PAGES=true
ENABLE_CERTIFICATE_PAGE=true
ENABLE_COURSE_IMPORT_IN_LIBRARY=false
ENABLE_UNIT_PAGE_NEW_DESIGN=false
ENABLE_COURSE_OUTLINE_NEW_DESIGN=false
BBB_LEARN_MORE_URL=''
HOTJAR_APP_ID=''
HOTJAR_VERSION=6
HOTJAR_DEBUG=false
INVITE_STUDENTS_EMAIL_TO=''
ENABLE_CHECKLIST_QUALITY=''
ENABLE_GRADING_METHOD_IN_PROBLEMS=false
# "Multi-level" blocks are unsupported in libraries
LIBRARY_UNSUPPORTED_BLOCKS="conditional,step-builder,problem-builder,library_content,itembank"
LIBRARY_UNSUPPORTED_BLOCKS="conditional,step-builder,problem-builder"
# Fallback in local style files
PARAGON_THEME_URLS={}
COURSE_TEAM_SUPPORT_EMAIL=''

View File

@@ -38,8 +38,6 @@ ENABLE_ASSETS_PAGE=false
ENABLE_VIDEO_UPLOAD_PAGE_LINK_IN_CONTENT_DROPDOWN=true
ENABLE_CERTIFICATE_PAGE=true
ENABLE_COURSE_IMPORT_IN_LIBRARY=true
ENABLE_COURSE_OUTLINE_NEW_DESIGN=true
ENABLE_UNIT_PAGE_NEW_DESIGN=true
ENABLE_NEW_VIDEO_UPLOAD_PAGE=true
ENABLE_TAGGING_TAXONOMY_PAGES=true
BBB_LEARN_MORE_URL=''
@@ -48,8 +46,9 @@ HOTJAR_VERSION=6
HOTJAR_DEBUG=true
INVITE_STUDENTS_EMAIL_TO="someone@domain.com"
ENABLE_CHECKLIST_QUALITY=true
ENABLE_GRADING_METHOD_IN_PROBLEMS=false
# "Multi-level" blocks are unsupported in libraries
LIBRARY_UNSUPPORTED_BLOCKS="conditional,step-builder,problem-builder,library_content,itembank"
LIBRARY_UNSUPPORTED_BLOCKS="conditional,step-builder,problem-builder"
# Fallback in local style files
PARAGON_THEME_URLS={}
COURSE_TEAM_SUPPORT_EMAIL=''

View File

@@ -34,13 +34,12 @@ ENABLE_ASSETS_PAGE=false
ENABLE_VIDEO_UPLOAD_PAGE_LINK_IN_CONTENT_DROPDOWN=true
ENABLE_CERTIFICATE_PAGE=true
ENABLE_COURSE_IMPORT_IN_LIBRARY=true
ENABLE_COURSE_OUTLINE_NEW_DESIGN=false
ENABLE_UNIT_PAGE_NEW_DESIGN=false
ENABLE_TAGGING_TAXONOMY_PAGES=true
BBB_LEARN_MORE_URL=''
INVITE_STUDENTS_EMAIL_TO="someone@domain.com"
ENABLE_CHECKLIST_QUALITY=true
ENABLE_GRADING_METHOD_IN_PROBLEMS=false
# "Multi-level" blocks are unsupported in libraries
LIBRARY_UNSUPPORTED_BLOCKS="conditional,step-builder,problem-builder,library_content,itembank"
LIBRARY_UNSUPPORTED_BLOCKS="conditional,step-builder,problem-builder"
PARAGON_THEME_URLS=
COURSE_TEAM_SUPPORT_EMAIL='support@example.com'

View File

@@ -11,13 +11,13 @@ jobs:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v6
- uses: actions/checkout@v5
- uses: actions/setup-node@v6
with:
node-version-file: '.nvmrc'
- run: make validate.ci
- name: Archive code coverage results
uses: actions/upload-artifact@v7
uses: actions/upload-artifact@v5
with:
name: code-coverage-report
path: coverage/*.*
@@ -25,9 +25,9 @@ jobs:
runs-on: ubuntu-latest
needs: tests
steps:
- uses: actions/checkout@v6
- uses: actions/checkout@v5
- name: Download code coverage results
uses: actions/download-artifact@v8
uses: actions/download-artifact@v6
with:
pattern: code-coverage-report
path: coverage

View File

@@ -1,23 +0,0 @@
{
"$schema": "./node_modules/oxlint/configuration_schema.json",
"categories": {
"correctness": "warn"
},
"rules": {
"eslint/no-unused-vars": "off",
"typescript/unbound-method": "off", // 🛑 TEMPORARY
"typescript/no-floating-promises": ["error", {
"allowForKnownSafeCalls": [
// queryClient.invalidateQueries returns a promise that can be awaited
// if you want to do something after all the subsequent refetches are
// complete, but we rarely if ever want that; we usually want to
// continue invalidating more things immediately. So we don't usually
// want to await this.
"invalidateQueries",
]
}]
},
"ignorePatterns": [
"webpack.dev-tutor.config.js",
]
}

View File

@@ -51,9 +51,7 @@ validate-no-uncommitted-package-lock-changes:
validate:
make validate-no-uncommitted-package-lock-changes
npm run i18n_extract
# We are trying out oxlint. Now that it's been working well for a while with both oxlint and eslint, we have disabled
# eslint, and after a few weeks we'll evaluate whether any problems are slipping through if only oxlint is used.
npm run oxlint
npm run lint -- --max-warnings 0
npm run types
npm run test:ci
npm run build

View File

@@ -40,7 +40,7 @@ Cloning and Setup
2. Use the version of Node specified in the ``.nvmrc`` file.
The current version of the micro-frontend build scripts supports the version of Node found in ``.nvmrc``.
The current version of the micro-frontend build scripts supports node 20.
Using other major versions of node *may* work, but this is unsupported. For
convenience, this repository includes an ``.nvmrc`` file to help in setting the
correct node version via `nvm <https://github.com/nvm-sh/nvm>`_.
@@ -97,7 +97,7 @@ Troubleshooting
* If tutor-mfe is not starting the authoring MFE in development mode (eg. `tutor dev start authoring` fails), it may be due to
using a tutor version that expects the MFE name to be frontend-app-course-authoring (the previous name of this repo). To fix
this, you can rename the cloned repo directory to frontend-app-course-authoring. More information can be found in
this, you can rename the cloned repo directory to frontend-app-course-authoring. More information can be found in
`this forum post <https://discuss.openedx.org/t/repo-rename-frontend-app-course-authoring-frontend-app-authoring/13930/2>`__.
@@ -175,6 +175,10 @@ Feature: New Proctoring Exams View
Requirements
------------
* ``edx-platform`` Django settings:
* ``ZENDESK_*``: necessary if automatic ZenDesk ticket creation is desired
* `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
@@ -192,6 +196,7 @@ In Studio, a new item ("Proctored Exam Settings") is added to "Other Course Sett
* 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
Feature: Advanced Settings
==========================
@@ -234,7 +239,7 @@ Configuration
In additional to the standard settings, the following local configuration items are required:
* ``ENABLE_TAGGING_TAXONOMY_PAGES``: must be enabled (which it is by default) in order to actually enable/show the new
* ``ENABLE_TAGGING_TAXONOMY_PAGES``: must be enabled (which it is by default) in order to actually enable/show the new
Tagging/Taxonomy functionality.
@@ -268,7 +273,7 @@ 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)

View File

@@ -1,80 +0,0 @@
Override External URLs
======================
What is getExternalLinkUrl?
---------------------------
The `getExternalLinkUrl` function is a utility from `@edx/frontend-platform` that allows for centralized management of external URLs. It enables the override of external links through configuration, making it possible to customize external references without modifying the source code directly.
URLs wrapped with getExternalLinkUrl
------------------------------------
Use cases:
1. **Accessibility Page** (`src/accessibility-page/AccessibilityPage.jsx`)
- `COMMUNITY_ACCESSIBILITY_LINK` - Points to community accessibility resources: https://www.edx.org/accessibility
2. **Course Outline** (if applicable)
- Documentation links
- Help resources
3. **Other pages** (search for `getExternalLinkUrl` usage across the codebase)
- Help documentation
- External tool integrations
Following external URLs are wrapped with `getExternalLinkUrl` in the authoring application:
- 'https://www.edx.org/accessibility'
- 'https://docs.openedx.org/en/latest/educators/concepts/exercise_tools/about_multi_select.html'
- 'https://docs.openedx.org/en/latest/educators/how-tos/course_development/exercise_tools/add_multi_select.html'
- 'https://docs.openedx.org/en/latest/educators/how-tos/course_development/exercise_tools/add_dropdown.html'
- 'https://docs.openedx.org/en/latest/educators/how-tos/course_development/exercise_tools/manage_numerical_input_problem.html'
- 'https://docs.openedx.org/en/latest/educators/how-tos/course_development/exercise_tools/add_text_input.html'
- 'https://docs.openedx.org/en/latest/educators/how-tos/course_development/social_sharing.html'
- 'https://docs.openedx.org/en/latest/educators/references/course_development/exercise_tools/guide_problem_types.html#advanced-problem-types'
- 'https://docs.openedx.org/en/latest/educators/references/course_development/parent_child_components.html'
- 'https://openai.com/api-data-privacy'
- 'https://docs.openedx.org/en/latest/educators/how-tos/course_development/create_new_library.html'
- 'https://bigbluebutton.org/privacy-policy/'
- 'https://creativecommons.org/about'
Note: as new external URLs are added to the codebase, more URLs will be wrapped with `getExternalLinkUrl` and this list may not always be up to date.
How to Override External URLs
-----------------------------
To override external URLs, you can use the frontend platform's configuration system.
This object should be added to the config object defined in the env.config.[js,jsx,ts,tsx], and must be named externalLinkUrlOverrides.
1. **Environment Configuration**
Add the URL overrides to your environment configuration:
.. code-block:: javascript
const config = {
// Other config options...
externalLinkUrlOverrides: {
'https://www.edx.org/accessibility': 'https://your-custom-domain.com/accessibility',
// Add other URL overrides here
}
};
Examples
--------
**Original URL:** Default community accessibility link
**Override:** Your institution's accessibility policy page
.. code-block:: javascript
// In your app configuration
getExternalLinkUrl('https://www.edx.org/accessibility')
// Returns: 'https://your-custom-domain.com/accessibility'
// Instead of the default Open edX community link
Benefits
--------
- **Customization**: Institutions can point to their own resources
- **Maintainability**: URLs can be changed without code modifications
- **Consistency**: Centralized URL management across the application
- **Flexibility**: Different environments can have different external links

8949
package-lock.json generated

File diff suppressed because it is too large Load Diff

View File

@@ -14,7 +14,6 @@
"i18n_extract": "fedx-scripts formatjs extract --include=plugins",
"stylelint": "stylelint \"plugins/**/*.scss\" \"src/**/*.scss\" \"scss/**/*.scss\" --config .stylelintrc.json",
"lint": "npm run stylelint && fedx-scripts eslint --ext .js --ext .jsx --ext .ts --ext .tsx .",
"oxlint": "oxlint --type-aware --deny-warnings",
"lint:fix": "npm run stylelint -- --fix && fedx-scripts eslint --fix --ext .js --ext .jsx --ext .ts --ext .tsx .",
"start": "fedx-scripts webpack-dev-server --progress",
"start:with-theme": "paragon install-theme && npm start && npm install",
@@ -44,14 +43,13 @@
"@dnd-kit/sortable": "^8.0.0",
"@dnd-kit/utilities": "^3.2.2",
"@edx/brand": "npm:@openedx/brand-openedx@^1.2.3",
"@edx/browserslist-config": "1.5.1",
"@edx/browserslist-config": "1.5.0",
"@edx/frontend-component-footer": "^14.9.0",
"@edx/frontend-component-header": "^8.1.0",
"@edx/frontend-enterprise-hotjar": "^7.2.0",
"@edx/frontend-platform": "^8.4.0",
"@edx/openedx-atlas": "^0.7.0",
"@openedx-plugins/course-app-calculator": "file:plugins/course-apps/calculator",
"@openedx-plugins/course-app-dates": "file:plugins/course-apps/dates",
"@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",
@@ -62,35 +60,35 @@
"@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/frontend-build": "^14.6.2",
"@openedx/frontend-plugin-framework": "^1.8.0",
"@openedx/frontend-plugin-framework": "^1.7.0",
"@openedx/paragon": "^23.5.0",
"@redux-devtools/extension": "^3.3.0",
"@reduxjs/toolkit": "2.11.2",
"@tanstack/react-query": "5.90.21",
"@reduxjs/toolkit": "1.9.7",
"@tanstack/react-query": "5.90.7",
"@tinymce/tinymce-react": "^6.0.0",
"classnames": "2.5.1",
"codemirror": "^6.0.0",
"email-validator": "2.0.4",
"fast-xml-parser": "^5.0.0",
"file-saver": "^2.0.5",
"formik": "2.4.9",
"formik": "2.4.6",
"frontend-components-tinymce-advanced-plugins": "^1.0.3",
"jszip": "^3.10.1",
"lodash": "4.17.23",
"lodash": "4.17.21",
"meilisearch": "^0.41.0",
"moment": "2.30.1",
"moment-shortformat": "^2.1.0",
"prop-types": "^15.8.1",
"react": "^18.3.1",
"react-datepicker": "^8.10.0",
"react-datepicker": "^4.13.0",
"react-dom": "^18.3.1",
"react-error-boundary": "^4.0.13",
"react-helmet": "^6.1.0",
"react-onclickoutside": "^6.13.0",
"react-redux": "7.2.9",
"react-responsive": "10.0.1",
"react-router": "6.30.3",
"react-router-dom": "6.30.3",
"react-responsive": "9.0.2",
"react-router": "6.30.1",
"react-router-dom": "6.30.1",
"react-select": "5.10.2",
"react-textarea-autosize": "^8.5.3",
"react-transition-group": "4.4.5",
@@ -118,8 +116,6 @@
"fetch-mock-jest": "^1.5.1",
"jest-canvas-mock": "^2.5.2",
"jest-expect-message": "^1.1.3",
"oxlint": "^1.42.0",
"oxlint-tsgolint": "^0.16.0",
"react-test-renderer": "^18.3.1",
"redux-mock-store": "^1.5.4"
}

View File

@@ -1,29 +0,0 @@
import React from 'react';
import { useIntl } from '@edx/frontend-platform/i18n';
import AppSettingsModal from 'CourseAuthoring/pages-and-resources/app-settings-modal/AppSettingsModal';
import messages from './messages';
type DatesSettingsProps = {
onClose: () => void;
};
const DatesSettings: React.FC<DatesSettingsProps> = ({ onClose }) => {
const intl = useIntl();
return (
<AppSettingsModal
appId="dates"
title={intl.formatMessage(messages.heading)}
enableAppHelp={intl.formatMessage(messages.enableAppHelp)}
enableAppLabel={intl.formatMessage(messages.enableAppLabel)}
learnMoreText={intl.formatMessage(messages.learnMore)}
onClose={onClose}
validationSchema={{}}
initialValues={{}}
onSettingsSave={async () => true}
/>
);
};
export default DatesSettings;

View File

@@ -1,26 +0,0 @@
import { defineMessages } from '@edx/frontend-platform/i18n';
const messages = defineMessages({
heading: {
id: 'course-authoring.pages-resources.dates.heading',
defaultMessage: 'Configure dates',
description: 'Heading for the Dates settings modal shown in Pages & Resources.',
},
enableAppLabel: {
id: 'course-authoring.pages-resources.dates.enable-app.label',
defaultMessage: 'Dates',
description: 'Label for the toggle that enables the Dates experience.',
},
enableAppHelp: {
id: 'course-authoring.pages-resources.dates.enable-app.help',
defaultMessage: 'Show the Dates tab in course navigation, where learners can view important course dates.',
description: 'Helper text explaining what enabling the Dates experience does.',
},
learnMore: {
id: 'course-authoring.pages-resources.dates.learn-more',
defaultMessage: 'Learn more about dates',
description: 'Link text that leads to documentation about the Dates experience.',
},
});
export default messages;

View File

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

View File

@@ -1,5 +1,5 @@
import React, { useEffect, useState } from 'react';
import { getConfig, getExternalLinkUrl } from '@edx/frontend-platform';
import { getConfig } from '@edx/frontend-platform';
import { FormattedMessage, useIntl } from '@edx/frontend-platform/i18n';
import { Form, Hyperlink } from '@openedx/paragon';
import PropTypes from 'prop-types';
@@ -93,7 +93,7 @@ const BbbSettings = ({
<span data-testid="free-plan-message">
{intl.formatMessage(messages.freePlanMessage)}
<Hyperlink
destination={getExternalLinkUrl('https://bigbluebutton.org/privacy-policy/')}
destination="https://bigbluebutton.org/privacy-policy/"
target="_blank"
rel="noopener noreferrer"
showLaunchIcon

View File

@@ -4,16 +4,21 @@ import {
getByRole,
getAllByRole,
waitForElementToBeRemoved,
initializeMocks,
} from 'CourseAuthoring/testUtils';
} from '@testing-library/react';
import ReactDOM from 'react-dom';
import { Routes, Route, MemoryRouter } from 'react-router-dom';
import { initializeMockApp } from '@edx/frontend-platform';
import MockAdapter from 'axios-mock-adapter';
import { getAuthenticatedHttpClient } from '@edx/frontend-platform/auth';
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 'CourseAuthoring/store';
import { executeThunk } from 'CourseAuthoring/utils';
import PagesAndResourcesProvider from 'CourseAuthoring/pages-and-resources/PagesAndResourcesProvider';
import { CourseAuthoringProvider } from 'CourseAuthoring/CourseAuthoringContext';
import LiveSettings from './Settings';
import {
generateLiveConfigurationApiResponse,
@@ -35,20 +40,17 @@ ReactDOM.createPortal = jest.fn(node => node);
const renderComponent = () => {
const wrapper = render(
<CourseAuthoringProvider courseId={courseId}>
<PagesAndResourcesProvider courseId={courseId}>
<LiveSettings onClose={() => {}} />
</PagesAndResourcesProvider>
</CourseAuthoringProvider>,
{
path: liveSettingsUrl,
routerProps: {
initialEntries: [liveSettingsUrl],
},
params: {
courseId,
},
},
<IntlProvider locale="en">
<AppProvider store={store} wrapWithRouter={false}>
<PagesAndResourcesProvider courseId={courseId}>
<MemoryRouter initialEntries={[liveSettingsUrl]}>
<Routes>
<Route path={liveSettingsUrl} element={<PageWrap><LiveSettings onClose={() => {}} /></PageWrap>} />
</Routes>
</MemoryRouter>
</PagesAndResourcesProvider>
</AppProvider>
</IntlProvider>,
);
container = wrapper.container;
};
@@ -72,9 +74,16 @@ const mockStore = async ({
describe('BBB Settings', () => {
beforeEach(async () => {
const mocks = initializeMocks({ initialState });
store = mocks.reduxStore;
axiosMock = mocks.axiosMock;
initializeMockApp({
authenticatedUser: {
userId: 3,
username: 'abc123',
administrator: false,
roles: [],
},
});
store = initializeStore(initialState);
axiosMock = new MockAdapter(getAuthenticatedHttpClient());
});
test('Plan dropdown to be visible and enabled in UI', async () => {

View File

@@ -1,4 +1,3 @@
// oxlint-disable unicorn/no-thenable
import React, { useEffect } from 'react';
import { useDispatch, useSelector } from 'react-redux';
import { camelCase } from 'lodash';
@@ -12,7 +11,6 @@ import AppSettingsModal from 'CourseAuthoring/pages-and-resources/app-settings-m
import { useModel } from 'CourseAuthoring/generic/model-store';
import Loading from 'CourseAuthoring/generic/Loading';
import { RequestStatus } from 'CourseAuthoring/data/constants';
import { useCourseAuthoringContext } from 'CourseAuthoring/CourseAuthoringContext';
import { fetchLiveData, saveLiveConfiguration, saveLiveConfigurationAsDraft } from './data/thunks';
import { selectApp } from './data/slice';
@@ -27,7 +25,7 @@ const LiveSettings = ({
const intl = useIntl();
const navigate = useNavigate();
const dispatch = useDispatch();
const { courseId } = useCourseAuthoringContext();
const courseId = useSelector(state => state.courseDetail.courseId);
const availableProviders = useSelector((state) => state.live.appIds);
const {
piiSharingAllowed, selectedAppId, enabled, status,
@@ -73,7 +71,6 @@ const LiveSettings = ({
};
const handleSettingsSave = async (values) => {
// oxlint-disable-next-line @typescript-eslint/await-thenable - this dispatch() IS returning a promise.
await dispatch(saveLiveConfiguration(courseId, values, navigate));
};

View File

@@ -8,14 +8,20 @@ import {
queryByText,
getByRole,
waitForElementToBeRemoved,
initializeMocks,
} from 'CourseAuthoring/testUtils';
} from '@testing-library/react';
import ReactDOM from 'react-dom';
import { Routes, Route, MemoryRouter } from 'react-router-dom';
import { initializeMockApp } from '@edx/frontend-platform';
import MockAdapter from 'axios-mock-adapter';
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 'CourseAuthoring/store';
import { executeThunk } from 'CourseAuthoring/utils';
import PagesAndResourcesProvider from 'CourseAuthoring/pages-and-resources/PagesAndResourcesProvider';
import { CourseAuthoringProvider } from 'CourseAuthoring/CourseAuthoringContext';
import LiveSettings from './Settings';
import {
generateLiveConfigurationApiResponse,
@@ -38,20 +44,17 @@ ReactDOM.createPortal = jest.fn(node => node);
const renderComponent = () => {
const wrapper = render(
<PagesAndResourcesProvider courseId={courseId}>
<CourseAuthoringProvider>
<LiveSettings onClose={() => {}} />
</CourseAuthoringProvider>
</PagesAndResourcesProvider>,
{
path: liveSettingsUrl,
routerProps: {
initialEntries: [liveSettingsUrl],
},
params: {
courseId,
},
},
<IntlProvider locale="en">
<AppProvider store={store} wrapWithRouter={false}>
<PagesAndResourcesProvider courseId={courseId}>
<MemoryRouter initialEntries={[liveSettingsUrl]}>
<Routes>
<Route path={liveSettingsUrl} element={<PageWrap><LiveSettings onClose={() => {}} /></PageWrap>} />
</Routes>
</MemoryRouter>
</PagesAndResourcesProvider>
</AppProvider>
</IntlProvider>,
);
container = wrapper.container;
};
@@ -74,11 +77,16 @@ const mockStore = async ({
describe('LiveSettings', () => {
beforeEach(async () => {
const mocks = initializeMocks({
initialState,
initializeMockApp({
authenticatedUser: {
userId: 3,
username: 'abc123',
administrator: false,
roles: [],
},
});
store = mocks.reduxStore;
axiosMock = mocks.axiosMock;
store = initializeStore(initialState);
axiosMock = new MockAdapter(getAuthenticatedHttpClient());
});
test('Live Configuration modal is visible', async () => {

View File

@@ -3,14 +3,19 @@ import {
queryByTestId,
getByRole,
waitForElementToBeRemoved,
initializeMocks,
} from 'CourseAuthoring/testUtils';
} from '@testing-library/react';
import ReactDOM from 'react-dom';
import { Routes, Route, MemoryRouter } from 'react-router-dom';
import { initializeMockApp } from '@edx/frontend-platform';
import MockAdapter from 'axios-mock-adapter';
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 'CourseAuthoring/store';
import { executeThunk } from 'CourseAuthoring/utils';
import PagesAndResourcesProvider from 'CourseAuthoring/pages-and-resources/PagesAndResourcesProvider';
import { CourseAuthoringProvider } from 'CourseAuthoring/CourseAuthoringContext';
import LiveSettings from './Settings';
import {
generateLiveConfigurationApiResponse,
@@ -33,20 +38,17 @@ ReactDOM.createPortal = jest.fn(node => node);
const renderComponent = () => {
const wrapper = render(
<CourseAuthoringProvider courseId={courseId}>
<PagesAndResourcesProvider courseId={courseId}>
<LiveSettings onClose={() => {}} />
</PagesAndResourcesProvider>
</CourseAuthoringProvider>,
{
path: liveSettingsUrl,
routerProps: {
initialEntries: [liveSettingsUrl],
},
params: {
courseId,
},
},
<IntlProvider locale="en">
<AppProvider store={store} wrapWithRouter={false}>
<PagesAndResourcesProvider courseId={courseId}>
<MemoryRouter initialEntries={[liveSettingsUrl]}>
<Routes>
<Route path={liveSettingsUrl} element={<PageWrap><LiveSettings onClose={() => {}} /></PageWrap>} />
</Routes>
</MemoryRouter>
</PagesAndResourcesProvider>
</AppProvider>
</IntlProvider>,
);
container = wrapper.container;
};
@@ -69,9 +71,16 @@ const mockStore = async ({
describe('Zoom Settings', () => {
beforeEach(async () => {
const mocks = initializeMocks({ initialState });
store = mocks.reduxStore;
axiosMock = mocks.axiosMock;
initializeMockApp({
authenticatedUser: {
userId: 3,
username: 'abc123',
administrator: false,
roles: [],
},
});
store = initializeStore(initialState);
axiosMock = new MockAdapter(getAuthenticatedHttpClient());
});
test('LTI fields are visible when pii sharing is enabled', async () => {

View File

@@ -48,9 +48,8 @@ const ORASettings = ({ onClose }) => {
event.preventDefault();
success = success && await handleSettingsSave(formValues);
setSaveError(!success);
await setSaveError(!success);
if ((initialFormValues.enableFlexiblePeerGrade !== formValues.enableFlexiblePeerGrade) && success) {
// oxlint-disable-next-line @typescript-eslint/await-thenable - this dispatch() IS returning a promise.
success = await dispatch(updateModel({
modelType: 'courseApps',
model: {

View File

@@ -128,7 +128,7 @@ describe('ORASettings', () => {
await mockStore({ apiStatus: 200, enabled: true });
renderComponent();
const checkbox = screen.getByRole('checkbox', { name: /Flex Peer Grading/ });
const checkbox = await screen.getByRole('checkbox', { name: /Flex Peer Grading/ });
expect(checkbox).toBeChecked();
await waitFor(() => {

View File

@@ -22,7 +22,6 @@ 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 { useCourseAuthoringContext } from 'CourseAuthoring/CourseAuthoringContext';
import messages from './messages';
@@ -33,6 +32,7 @@ const ProctoringSettings = ({ onClose }) => {
proctoringProvider: false,
escalationEmail: '',
allowOptingOut: false,
createZendeskTickets: false,
};
const [formValues, setFormValues] = useState(initialFormValues);
const [loading, setLoading] = useState(true);
@@ -41,7 +41,6 @@ const ProctoringSettings = ({ onClose }) => {
const [loadingPermissionError, setLoadingPermissionError] = useState(false);
const [allowLtiProviders, setAllowLtiProviders] = useState(false);
const [availableProctoringProviders, setAvailableProctoringProviders] = useState([]);
const [requiresEscalationEmailProviders, setRequiresEscalationEmailProviders] = useState([]);
const [ltiProctoringProviders, setLtiProctoringProviders] = useState([]);
const [courseStartDate, setCourseStartDate] = useState('');
const [saveSuccess, setSaveSuccess] = useState(false);
@@ -66,7 +65,7 @@ const ProctoringSettings = ({ onClose }) => {
}
const { courseId } = useContext(PagesAndResourcesContext);
const { courseDetails } = useCourseAuthoringContext();
const courseDetails = useModel('courseDetails', courseId);
const org = courseDetails?.org;
const appInfo = useModel('courseApps', 'proctoring');
const alertRef = React.createRef();
@@ -79,14 +78,18 @@ const ProctoringSettings = ({ onClose }) => {
const value = target.type === 'checkbox' ? target.checked : target.value;
const { name } = target;
if (['allowOptingOut'].includes(name)) {
if (['allowOptingOut', 'createZendeskTickets'].includes(name)) {
// Form.Radio expects string values, so convert back to a boolean here
setFormValues({ ...formValues, [name]: value === 'true' });
} else if (name === 'proctoringProvider') {
const newFormValues = { ...formValues, proctoringProvider: value };
if (requiresEscalationEmailProviders.includes(value)) {
setFormValues({ ...newFormValues });
if (value === 'proctortrack') {
setFormValues({ ...newFormValues, createZendeskTickets: false });
setShowEscalationEmail(true);
} else if (value === 'software_secure') {
setFormValues({ ...newFormValues, createZendeskTickets: true });
setShowEscalationEmail(false);
} else if (isLtiProvider(value)) {
setFormValues(newFormValues);
setShowEscalationEmail(true);
@@ -113,13 +116,14 @@ const ProctoringSettings = ({ onClose }) => {
enable_proctored_exams: formValues.enableProctoredExams,
// lti providers are managed outside edx-platform, lti_external indicates this
proctoring_provider: isLtiProviderSelected ? 'lti_external' : selectedProvider,
create_zendesk_tickets: formValues.createZendeskTickets,
},
};
if (isEdxStaff) {
studioDataToPostBack.proctored_exam_settings.allow_proctoring_opt_out = formValues.allowOptingOut;
}
if (requiresEscalationEmailProviders.includes(formValues.proctoringProvider)) {
if (formValues.proctoringProvider === 'proctortrack') {
studioDataToPostBack.proctored_exam_settings.proctoring_escalation_email = formValues.escalationEmail === '' ? null : formValues.escalationEmail;
}
@@ -156,7 +160,7 @@ const ProctoringSettings = ({ onClose }) => {
event.preventDefault();
const isLtiProviderSelected = isLtiProvider(formValues.proctoringProvider);
if (
(requiresEscalationEmailProviders.includes(formValues.proctoringProvider) || isLtiProviderSelected)
(formValues.proctoringProvider === 'proctortrack' || isLtiProviderSelected)
&& !EmailValidator.validate(formValues.escalationEmail)
&& !(formValues.escalationEmail === '' && !formValues.enableProctoredExams)
) {
@@ -383,6 +387,29 @@ const ProctoringSettings = ({ onClose }) => {
</Form.Group>
</fieldset>
)}
{/* CREATE ZENDESK TICKETS */}
{ isEdxStaff && formValues.enableProctoredExams && !isLtiProviderSelected && (
<fieldset aria-describedby="createZendeskTicketsText">
<Form.Group controlId="formCreateZendeskTickets">
<Form.Label as="legend" className="font-weight-bold">
{intl.formatMessage(messages['authoring.proctoring.createzendesk.label'])}
</Form.Label>
<Form.RadioSet
name="createZendeskTickets"
value={formValues.createZendeskTickets.toString()}
onChange={handleChange}
>
<Form.Radio value="true" data-testid="createZendeskTicketsYes">
{intl.formatMessage(messages['authoring.proctoring.yes'])}
</Form.Radio>
<Form.Radio value="false" data-testid="createZendeskTicketsNo">
{intl.formatMessage(messages['authoring.proctoring.no'])}
</Form.Radio>
</Form.RadioSet>
</Form.Group>
</fieldset>
)}
</>
);
}
@@ -500,7 +527,6 @@ const ProctoringSettings = ({ onClose }) => {
setSubmissionInProgress(false);
setCourseStartDate(settingsResponse.data.course_start_date);
setAvailableProctoringProviders(settingsResponse.data.available_proctoring_providers);
setRequiresEscalationEmailProviders(settingsResponse.data.requires_escalation_email_providers);
// The list of providers returned by studio settings are the default behavior. If lti_external
// is available as an option display the list of LTI providers returned by the exam service.
@@ -528,11 +554,10 @@ const ProctoringSettings = ({ onClose }) => {
selectedProvider = proctoredExamSettings.proctoring_provider;
}
const requiresEscalationEmailProvidersList = settingsResponse.data.requires_escalation_email_providers;
const isEscalationEmailRequired = requiresEscalationEmailProvidersList.includes(selectedProvider);
const isProctortrack = selectedProvider === 'proctortrack';
const ltiProviderSelected = proctoringProvidersLti.some(p => p.name === selectedProvider);
if (isEscalationEmailRequired || ltiProviderSelected) {
if (isProctortrack || ltiProviderSelected) {
setShowEscalationEmail(true);
}
@@ -545,6 +570,7 @@ const ProctoringSettings = ({ onClose }) => {
proctoringProvider: selectedProvider,
enableProctoredExams: proctoredExamSettings.enable_proctored_exams,
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.
// In order to keep our email input component controlled, we use the empty string as the default
// and perform this conversion during GETs and POSTs.

View File

@@ -1,15 +1,18 @@
import React from 'react';
import {
render, screen, cleanup, waitFor, fireEvent, act,
initializeMocks,
} from 'CourseAuthoring/testUtils';
} from '@testing-library/react';
import MockAdapter from 'axios-mock-adapter';
import { mergeConfig } from '@edx/frontend-platform';
import { initializeMockApp, mergeConfig } from '@edx/frontend-platform';
import { getAuthenticatedHttpClient } from '@edx/frontend-platform/auth';
import { IntlProvider } from '@edx/frontend-platform/i18n';
import { AppProvider } from '@edx/frontend-platform/react';
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 { CourseAuthoringProvider } from 'CourseAuthoring/CourseAuthoringContext';
import { getCourseDetailsUrl } from 'CourseAuthoring/data/api';
import ProctoredExamSettings from './Settings';
const courseId = 'course-v1%3AedX%2BDemoX%2BDemo_Course';
@@ -17,57 +20,56 @@ const defaultProps = {
courseId,
onClose: () => {},
};
let store;
const renderComponent = children => (
<CourseAuthoringProvider courseId={defaultProps.courseId}>
const intlWrapper = children => (
<AppProvider store={store}>
<PagesAndResourcesProvider courseId={defaultProps.courseId}>
{children}
<IntlProvider locale="en">
{children}
</IntlProvider>
</PagesAndResourcesProvider>
</CourseAuthoringProvider>
</AppProvider>
);
let axiosMock;
describe('ProctoredExamSettings', () => {
/**
* @param {boolean} isAdmin
* @param {string | undefined} org
*/
function setupApp(isAdmin = true, org = undefined) {
mergeConfig({
EXAMS_BASE_URL: 'http://exams.testing.co',
}, 'CourseAuthoringConfig');
const user = {
userId: 3,
username: 'abc123',
administrator: isAdmin,
roles: [],
};
const mocks = initializeMocks({
user,
initialState: {
models: {
courseApps: {
proctoring: {},
initializeMockApp({
authenticatedUser: {
userId: 3,
username: 'abc123',
administrator: isAdmin,
roles: [],
},
});
store = initializeStore({
models: {
courseApps: {
proctoring: {},
},
courseDetails: {
[courseId]: {
start: Date(),
},
},
...(org ? { courseDetails: { [courseId]: { org } } } : {}),
},
});
axiosMock = mocks.axiosMock;
axiosMock
.onGet(getCourseDetailsUrl(courseId, user.username))
.reply(200, {
courseId,
name: 'Course Test',
start: Date(),
...(org ? { org } : {}),
});
axiosMock.onGet(`${ExamsApiService.getExamsBaseUrl()}/api/v1/providers`)
.reply(200, [{ name: 'test_lti', verbose_name: 'LTI Provider' }]);
if (org) {
axiosMock.onGet(`${ExamsApiService.getExamsBaseUrl()}/api/v1/providers?org=${org}`)
.reply(200, [{ name: 'test_lti', verbose_name: 'LTI Provider' }]);
}
axiosMock = new MockAdapter(getAuthenticatedHttpClient());
axiosMock.onGet(
`${ExamsApiService.getExamsBaseUrl()}/api/v1/providers${org ? `?org=${org}` : ''}`,
).reply(200, [
{
name: 'test_lti',
verbose_name: 'LTI Provider',
},
]);
axiosMock.onGet(
`${ExamsApiService.getExamsBaseUrl()}/api/v1/configs/course_id/${defaultProps.courseId}`,
).reply(200, {
@@ -82,20 +84,54 @@ describe('ProctoredExamSettings', () => {
allow_proctoring_opt_out: false,
proctoring_provider: 'mockproc',
proctoring_escalation_email: 'test@example.com',
create_zendesk_tickets: true,
},
available_proctoring_providers: ['software_secure', 'mockproc', 'lti_external'],
requires_escalation_email_providers: ['test_lti'],
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 () => {
await act(async () => render(renderComponent(<ProctoredExamSettings {...defaultProps} />)));
await act(async () => render(intlWrapper(<ProctoredExamSettings {...defaultProps} />)));
});
it('Updates Zendesk ticket field if proctortrack is provider', async () => {
await waitFor(() => {
screen.getByDisplayValue('mockproc');
});
const selectElement = screen.getByDisplayValue('mockproc');
fireEvent.change(selectElement, { target: { value: 'proctortrack' } });
const zendeskTicketInput = screen.getByTestId('createZendeskTicketsNo');
expect(zendeskTicketInput.checked).toEqual(true);
});
it('Updates Zendesk ticket field if software_secure is provider', async () => {
await waitFor(() => {
screen.getByDisplayValue('mockproc');
});
const selectElement = screen.getByDisplayValue('mockproc');
fireEvent.change(selectElement, { target: { value: 'software_secure' } });
const zendeskTicketInput = screen.getByTestId('createZendeskTicketsYes');
expect(zendeskTicketInput.checked).toEqual(true);
});
it('Does not update zendesk ticket field for any other provider', async () => {
await waitFor(() => {
screen.getByDisplayValue('mockproc');
});
const selectElement = screen.getByDisplayValue('mockproc');
fireEvent.change(selectElement, { target: { value: 'mockproc' } });
const zendeskTicketInput = screen.getByTestId('createZendeskTicketsYes');
expect(zendeskTicketInput.checked).toEqual(true);
});
it('Hides all other fields when enabledProctorExam is false when first loaded', async () => {
@@ -109,13 +145,13 @@ describe('ProctoredExamSettings', () => {
allow_proctoring_opt_out: false,
proctoring_provider: 'mockproc',
proctoring_escalation_email: 'test@example.com',
create_zendesk_tickets: true,
},
available_proctoring_providers: ['software_secure', 'mockproc'],
requires_escalation_email_providers: [],
available_proctoring_providers: ['software_secure', 'proctortrack', 'mockproc'],
course_start_date: '2070-01-01T00:00:00Z',
});
await act(async () => render(renderComponent(<ProctoredExamSettings {...defaultProps} />)));
await act(async () => render(intlWrapper(<ProctoredExamSettings {...defaultProps} />)));
await waitFor(() => {
screen.getByText('Proctored exams');
});
@@ -124,6 +160,8 @@ describe('ProctoredExamSettings', () => {
expect(screen.queryByText('Allow Opting Out of Proctored Exams')).toBeNull();
expect(screen.queryByDisplayValue('mockproc')).toBeNull();
expect(screen.queryByTestId('escalationEmail')).toBeNull();
expect(screen.queryByTestId('createZendeskTicketsYes')).toBeNull();
expect(screen.queryByTestId('createZendeskTicketsNo')).toBeNull();
});
it('Hides all other fields when enableProctoredExams toggled to false', async () => {
@@ -133,6 +171,8 @@ describe('ProctoredExamSettings', () => {
expect(screen.queryByText('Allow opting out of proctored exams')).toBeDefined();
expect(screen.queryByDisplayValue('mockproc')).toBeDefined();
expect(screen.queryByTestId('escalationEmail')).toBeDefined();
expect(screen.queryByTestId('createZendeskTicketsYes')).toBeDefined();
expect(screen.queryByTestId('createZendeskTicketsNo')).toBeDefined();
let enabledProctoredExamCheck = screen.getAllByLabelText('Proctored exams', { exact: false })[0];
expect(enabledProctoredExamCheck.checked).toEqual(true);
@@ -142,6 +182,8 @@ describe('ProctoredExamSettings', () => {
expect(screen.queryByText('Allow opting out of proctored exams')).toBeNull();
expect(screen.queryByDisplayValue('mockproc')).toBeNull();
expect(screen.queryByTestId('escalationEmail')).toBeNull();
expect(screen.queryByTestId('createZendeskTicketsYes')).toBeNull();
expect(screen.queryByTestId('createZendeskTicketsNo')).toBeNull();
});
it('Hides unsupported fields when lti provider is selected', async () => {
@@ -151,11 +193,13 @@ describe('ProctoredExamSettings', () => {
const selectElement = screen.getByDisplayValue('mockproc');
fireEvent.change(selectElement, { target: { value: 'test_lti' } });
expect(screen.queryByTestId('allowOptingOutRadio')).toBeNull();
expect(screen.queryByTestId('createZendeskTicketsYes')).toBeNull();
expect(screen.queryByTestId('createZendeskTicketsNo')).toBeNull();
});
});
describe('Validation with invalid escalation email', () => {
const proctoringProvidersRequiringEscalationEmail = ['test_lti'];
const proctoringProvidersRequiringEscalationEmail = ['proctortrack', 'test_lti'];
beforeEach(async () => {
axiosMock.onGet(
@@ -164,21 +208,14 @@ describe('ProctoredExamSettings', () => {
proctored_exam_settings: {
enable_proctored_exams: true,
allow_proctoring_opt_out: false,
proctoring_provider: 'lti_external',
proctoring_provider: 'proctortrack',
proctoring_escalation_email: 'test@example.com',
create_zendesk_tickets: true,
},
available_proctoring_providers: ['software_secure', 'mockproc', 'lti_external'],
requires_escalation_email_providers: ['test_lti'],
available_proctoring_providers: ['software_secure', 'proctortrack', 'mockproc', 'lti_external'],
course_start_date: '2070-01-01T00:00:00Z',
});
axiosMock.onGet(
`${ExamsApiService.getExamsBaseUrl()}/api/v1/configs/course_id/${defaultProps.courseId}`,
).reply(200, {
provider: 'test_lti',
escalation_email: 'test@example.com',
});
axiosMock.onPatch(
ExamsApiService.getExamConfigurationUrl(defaultProps.courseId),
).reply(204, {});
@@ -187,13 +224,13 @@ describe('ProctoredExamSettings', () => {
StudioApiService.getProctoredExamSettingsUrl(defaultProps.courseId),
).reply(200, {});
await act(async () => render(renderComponent(<ProctoredExamSettings {...defaultProps} />)));
await act(async () => render(intlWrapper(<ProctoredExamSettings {...defaultProps} />)));
});
proctoringProvidersRequiringEscalationEmail.forEach(provider => {
it(`Creates an alert when no proctoring escalation email is provided with ${provider} selected`, async () => {
await waitFor(() => {
screen.getByDisplayValue('LTI Provider');
screen.getByDisplayValue('proctortrack');
});
const selectEscalationEmailElement = screen.getByDisplayValue('test@example.com');
fireEvent.change(selectEscalationEmailElement, { target: { value: '' } });
@@ -214,10 +251,10 @@ describe('ProctoredExamSettings', () => {
it(`Creates an alert when invalid proctoring escalation email is provided with ${provider} selected`, async () => {
await waitFor(() => {
screen.getByDisplayValue('LTI Provider');
screen.getByDisplayValue('proctortrack');
});
const selectElement = screen.getByDisplayValue('LTI Provider');
const selectElement = screen.getByDisplayValue('proctortrack');
fireEvent.change(selectElement, { target: { value: provider } });
const selectEscalationEmailElement = screen.getByDisplayValue('test@example.com');
@@ -240,7 +277,7 @@ describe('ProctoredExamSettings', () => {
it('Creates an alert when invalid proctoring escalation email is provided with proctoring disabled', async () => {
await waitFor(() => {
screen.getByDisplayValue('LTI Provider');
screen.getByDisplayValue('proctortrack');
});
const selectEscalationEmailElement = screen.getByDisplayValue('test@example.com');
fireEvent.change(selectEscalationEmailElement, { target: { value: 'foo.bar' } });
@@ -258,7 +295,7 @@ describe('ProctoredExamSettings', () => {
it('Has no error when empty proctoring escalation email is provided with proctoring disabled', async () => {
await waitFor(() => {
screen.getByDisplayValue('LTI Provider');
screen.getByDisplayValue('proctortrack');
});
const selectEscalationEmailElement = screen.getByDisplayValue('test@example.com');
fireEvent.change(selectEscalationEmailElement, { target: { value: '' } });
@@ -281,7 +318,7 @@ describe('ProctoredExamSettings', () => {
it(`Has no error when valid proctoring escalation email is provided with ${provider} selected`, async () => {
await waitFor(() => {
screen.getByDisplayValue('LTI Provider');
screen.getByDisplayValue('proctortrack');
});
const selectEscalationEmailElement = screen.getByDisplayValue('test@example.com');
fireEvent.change(selectEscalationEmailElement, { target: { value: 'foo@bar.com' } });
@@ -302,9 +339,9 @@ describe('ProctoredExamSettings', () => {
it(`Escalation email field hidden when proctoring backend is not ${provider}`, async () => {
await waitFor(() => {
screen.getByDisplayValue('LTI Provider');
screen.getByDisplayValue('proctortrack');
});
const proctoringBackendSelect = screen.getByDisplayValue('LTI Provider');
const proctoringBackendSelect = screen.getByDisplayValue('proctortrack');
const selectEscalationEmailElement = screen.getByTestId('escalationEmail');
expect(selectEscalationEmailElement.value).toEqual('test@example.com');
fireEvent.change(proctoringBackendSelect, { target: { value: 'software_secure' } });
@@ -313,13 +350,13 @@ describe('ProctoredExamSettings', () => {
it(`Escalation email Field Show when proctoring backend is switched back to ${provider}`, async () => {
await waitFor(() => {
screen.getByDisplayValue('LTI Provider');
screen.getByDisplayValue('proctortrack');
});
const proctoringBackendSelect = screen.getByDisplayValue('LTI Provider');
const proctoringBackendSelect = screen.getByDisplayValue('proctortrack');
let selectEscalationEmailElement = screen.getByTestId('escalationEmail');
fireEvent.change(proctoringBackendSelect, { target: { value: 'software_secure' } });
expect(screen.queryByTestId('escalationEmail')).toBeNull();
fireEvent.change(proctoringBackendSelect, { target: { value: provider } });
fireEvent.change(proctoringBackendSelect, { target: { value: 'proctortrack' } });
expect(screen.queryByTestId('escalationEmail')).toBeDefined();
selectEscalationEmailElement = screen.getByTestId('escalationEmail');
expect(selectEscalationEmailElement.value).toEqual('test@example.com');
@@ -327,7 +364,7 @@ describe('ProctoredExamSettings', () => {
it('Submits form when "Enter" key is hit in the escalation email field', async () => {
await waitFor(() => {
screen.getByDisplayValue('LTI Provider');
screen.getByDisplayValue('proctortrack');
});
const selectEscalationEmailElement = screen.getByDisplayValue('test@example.com');
fireEvent.change(selectEscalationEmailElement, { target: { value: '' } });
@@ -345,9 +382,9 @@ describe('ProctoredExamSettings', () => {
allow_proctoring_opt_out: false,
proctoring_provider: 'mockproc',
proctoring_escalation_email: 'test@example.com',
create_zendesk_tickets: true,
},
available_proctoring_providers: ['software_secure', 'mockproc'],
requires_escalation_email_providers: [],
available_proctoring_providers: ['software_secure', 'proctortrack', 'mockproc'],
course_start_date: '2099-01-01T00:00:00Z',
};
@@ -357,9 +394,9 @@ describe('ProctoredExamSettings', () => {
allow_proctoring_opt_out: false,
proctoring_provider: 'mockproc',
proctoring_escalation_email: 'test@example.com',
create_zendesk_tickets: true,
},
available_proctoring_providers: ['software_secure', 'mockproc'],
requires_escalation_email_providers: [],
available_proctoring_providers: ['software_secure', 'proctortrack', 'mockproc'],
course_start_date: '2013-01-01T00:00:00Z',
};
@@ -371,8 +408,8 @@ describe('ProctoredExamSettings', () => {
const isAdmin = false;
setupApp(isAdmin);
mockCourseData(mockGetPastCourseData);
await act(async () => render(renderComponent(<ProctoredExamSettings {...defaultProps} />)));
const providerOption = screen.getByTestId('software_secure');
await act(async () => render(intlWrapper(<ProctoredExamSettings {...defaultProps} />)));
const providerOption = screen.getByTestId('proctortrack');
expect(providerOption.hasAttribute('disabled')).toEqual(true);
});
@@ -380,8 +417,8 @@ describe('ProctoredExamSettings', () => {
const isAdmin = false;
setupApp(isAdmin);
mockCourseData(mockGetFutureCourseData);
await act(async () => render(renderComponent(<ProctoredExamSettings {...defaultProps} />)));
const providerOption = screen.getByTestId('software_secure');
await act(async () => render(intlWrapper(<ProctoredExamSettings {...defaultProps} />)));
const providerOption = screen.getByTestId('proctortrack');
expect(providerOption.hasAttribute('disabled')).toEqual(false);
});
@@ -390,8 +427,8 @@ describe('ProctoredExamSettings', () => {
const org = 'test-org';
setupApp(isAdmin, org);
mockCourseData(mockGetFutureCourseData);
await act(async () => render(renderComponent(<ProctoredExamSettings {...defaultProps} />)));
const providerOption = screen.getByTestId('software_secure');
await act(async () => render(intlWrapper(<ProctoredExamSettings {...defaultProps} />)));
const providerOption = screen.getByTestId('proctortrack');
expect(providerOption.hasAttribute('disabled')).toEqual(false);
});
@@ -399,8 +436,8 @@ describe('ProctoredExamSettings', () => {
const isAdmin = true;
setupApp(isAdmin);
mockCourseData(mockGetPastCourseData);
await act(async () => render(renderComponent(<ProctoredExamSettings {...defaultProps} />)));
const providerOption = screen.getByTestId('software_secure');
await act(async () => render(intlWrapper(<ProctoredExamSettings {...defaultProps} />)));
const providerOption = screen.getByTestId('proctortrack');
expect(providerOption.hasAttribute('disabled')).toEqual(false);
});
@@ -408,18 +445,18 @@ describe('ProctoredExamSettings', () => {
const isAdmin = true;
setupApp(isAdmin);
mockCourseData(mockGetFutureCourseData);
await act(async () => render(renderComponent(<ProctoredExamSettings {...defaultProps} />)));
const providerOption = screen.getByTestId('software_secure');
await act(async () => render(intlWrapper(<ProctoredExamSettings {...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', 'mockproc'],
available_proctoring_providers: ['lti_external', 'proctortrack', 'mockproc'],
};
mockCourseData(courseData);
await act(async () => render(renderComponent(<ProctoredExamSettings {...defaultProps} />)));
await act(async () => render(intlWrapper(<ProctoredExamSettings {...defaultProps} />)));
await waitFor(() => {
screen.getByDisplayValue('mockproc');
});
@@ -429,10 +466,10 @@ describe('ProctoredExamSettings', () => {
it('Includes lti proctoring provider options when lti_external is allowed by studio', async () => {
const courseData = {
...mockGetFutureCourseData,
available_proctoring_providers: ['lti_external', 'mockproc'],
available_proctoring_providers: ['lti_external', 'proctortrack', 'mockproc'],
};
mockCourseData(courseData);
await act(async () => render(renderComponent(<ProctoredExamSettings {...defaultProps} />)));
await act(async () => render(intlWrapper(<ProctoredExamSettings {...defaultProps} />)));
await waitFor(() => {
screen.getByDisplayValue('mockproc');
});
@@ -445,7 +482,7 @@ describe('ProctoredExamSettings', () => {
const isAdmin = true;
setupApp(isAdmin);
mockCourseData(mockGetFutureCourseData);
await act(async () => render(renderComponent(<ProctoredExamSettings {...defaultProps} />)));
await act(async () => render(intlWrapper(<ProctoredExamSettings {...defaultProps} />)));
await waitFor(() => {
screen.getByDisplayValue('mockproc');
});
@@ -459,20 +496,18 @@ describe('ProctoredExamSettings', () => {
EXAMS_BASE_URL: null,
}, 'CourseAuthoringConfig');
await act(async () => render(renderComponent(<ProctoredExamSettings {...defaultProps} />)));
await act(async () => render(intlWrapper(<ProctoredExamSettings {...defaultProps} />)));
await waitFor(() => {
screen.getByDisplayValue('mockproc');
});
// (1) for studio settings
// (2) waffle flags
// (3) for course details
expect(axiosMock.history.get.length).toBe(3);
// 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', 'mockproc'];
courseData.available_proctoring_providers = ['lti_external', 'proctortrack', 'mockproc'];
courseData.proctored_exam_settings.proctoring_provider = 'lti_external';
mockCourseData(courseData);
axiosMock.onGet(
@@ -480,7 +515,7 @@ describe('ProctoredExamSettings', () => {
).reply(200, {
provider: 'test_lti',
});
await act(async () => render(renderComponent(<ProctoredExamSettings {...defaultProps} />)));
await act(async () => render(intlWrapper(<ProctoredExamSettings {...defaultProps} />)));
await waitFor(() => {
screen.getByText('Proctoring provider');
});
@@ -491,22 +526,24 @@ describe('ProctoredExamSettings', () => {
});
describe('Toggles field visibility based on user permissions', () => {
it('Hides opting out for non edX staff', async () => {
it('Hides opting out and zendesk tickets for non edX staff', async () => {
setupApp(false);
await act(async () => render(renderComponent(<ProctoredExamSettings {...defaultProps} />)));
await act(async () => render(intlWrapper(<ProctoredExamSettings {...defaultProps} />)));
expect(screen.queryByTestId('allowOptingOutYes')).toBeNull();
expect(screen.queryByTestId('createZendeskTicketsYes')).toBeNull();
});
it('Shows opting out for edX staff', async () => {
it('Shows opting out and zendesk tickets for edX staff', async () => {
setupApp(true);
await act(async () => render(renderComponent(<ProctoredExamSettings {...defaultProps} />)));
await act(async () => render(intlWrapper(<ProctoredExamSettings {...defaultProps} />)));
expect(screen.queryByTestId('allowOptingOutYes')).not.toBeNull();
expect(screen.queryByTestId('createZendeskTicketsYes')).not.toBeNull();
});
});
describe('Connection states', () => {
it('Shows the spinner before the connection is complete', async () => {
render(renderComponent(<ProctoredExamSettings {...defaultProps} />));
render(intlWrapper(<ProctoredExamSettings {...defaultProps} />));
const spinner = await screen.findByRole('status');
expect(spinner.textContent).toEqual('Loading...');
});
@@ -516,7 +553,7 @@ describe('ProctoredExamSettings', () => {
StudioApiService.getProctoredExamSettingsUrl(defaultProps.courseId),
).reply(500);
await act(async () => render(renderComponent(<ProctoredExamSettings {...defaultProps} />)));
await act(async () => render(intlWrapper(<ProctoredExamSettings {...defaultProps} />)));
const connectionError = screen.getByTestId('connectionErrorAlert');
expect(connectionError.textContent).toEqual(
expect.stringContaining('We encountered a technical error when loading this page.'),
@@ -528,7 +565,7 @@ describe('ProctoredExamSettings', () => {
`${ExamsApiService.getExamsBaseUrl()}/api/v1/providers`,
).reply(500);
await act(async () => render(renderComponent(<ProctoredExamSettings {...defaultProps} />)));
await act(async () => render(intlWrapper(<ProctoredExamSettings {...defaultProps} />)));
const connectionError = screen.getByTestId('connectionErrorAlert');
expect(connectionError.textContent).toEqual(
expect.stringContaining('We encountered a technical error when loading this page.'),
@@ -540,7 +577,7 @@ describe('ProctoredExamSettings', () => {
StudioApiService.getProctoredExamSettingsUrl(defaultProps.courseId),
).reply(403);
await act(async () => render(renderComponent(<ProctoredExamSettings {...defaultProps} />)));
await act(async () => render(intlWrapper(<ProctoredExamSettings {...defaultProps} />)));
const permissionError = screen.getByTestId('permissionDeniedAlert');
expect(permissionError.textContent).toEqual(
expect.stringContaining('You are not authorized to view this page'),
@@ -559,7 +596,7 @@ describe('ProctoredExamSettings', () => {
});
it('Disable button while submitting', async () => {
await act(async () => render(renderComponent(<ProctoredExamSettings {...defaultProps} />)));
await act(async () => render(intlWrapper(<ProctoredExamSettings {...defaultProps} />)));
let submitButton = screen.getByTestId('submissionButton');
expect(screen.queryByTestId('saveInProgress')).toBeFalsy();
fireEvent.click(submitButton);
@@ -568,30 +605,15 @@ describe('ProctoredExamSettings', () => {
expect(submitButton).toHaveAttribute('disabled');
});
it('Makes API call successfully with proctoring_escalation_email if test_lti', async () => {
// Setup mock to include test_lti as available provider
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',
},
available_proctoring_providers: ['software_secure', 'mockproc', 'lti_external'],
requires_escalation_email_providers: ['test_lti'],
course_start_date: '2070-01-01T00:00:00Z',
});
await act(async () => render(renderComponent(<ProctoredExamSettings {...defaultProps} />)));
// Make a change to the provider to test_lti and set the email
it('Makes API call successfully with proctoring_escalation_email if proctortrack', async () => {
await act(async () => render(intlWrapper(<ProctoredExamSettings {...defaultProps} />)));
// Make a change to the provider to proctortrack and set the email
const selectElement = screen.getByDisplayValue('mockproc');
fireEvent.change(selectElement, { target: { value: 'test_lti' } });
fireEvent.change(selectElement, { target: { value: 'proctortrack' } });
const escalationEmail = screen.getByTestId('escalationEmail');
expect(escalationEmail.value).toEqual('test@example.com');
fireEvent.change(escalationEmail, { target: { value: 'test_lti@example.com' } });
expect(escalationEmail.value).toEqual('test_lti@example.com');
fireEvent.change(escalationEmail, { target: { value: 'proctortrack@example.com' } });
expect(escalationEmail.value).toEqual('proctortrack@example.com');
const submitButton = screen.getByTestId('submissionButton');
fireEvent.click(submitButton);
expect(axiosMock.history.post.length).toBe(1);
@@ -599,15 +621,11 @@ describe('ProctoredExamSettings', () => {
proctored_exam_settings: {
enable_proctored_exams: true,
allow_proctoring_opt_out: false,
proctoring_provider: 'lti_external',
proctoring_escalation_email: 'test_lti@example.com',
proctoring_provider: 'proctortrack',
proctoring_escalation_email: 'proctortrack@example.com',
create_zendesk_tickets: false,
},
});
expect(axiosMock.history.patch.length).toBe(1);
expect(JSON.parse(axiosMock.history.patch[0].data)).toEqual({
provider: 'test_lti',
escalation_email: 'test_lti@example.com',
});
await waitFor(() => {
const errorAlert = screen.getByTestId('saveSuccess');
@@ -618,10 +636,10 @@ describe('ProctoredExamSettings', () => {
});
});
it('Makes API call successfully without proctoring_escalation_email if not requiring escalation email', async () => {
await act(async () => render(renderComponent(<ProctoredExamSettings {...defaultProps} />)));
it('Makes API call successfully without proctoring_escalation_email if not proctortrack', async () => {
await act(async () => render(intlWrapper(<ProctoredExamSettings {...defaultProps} />)));
// make sure we have not selected a provider requiring escalation email
// make sure we have not selected proctortrack as the proctoring provider
expect(screen.getByDisplayValue('mockproc')).toBeDefined();
const submitButton = screen.getByTestId('submissionButton');
@@ -632,6 +650,7 @@ describe('ProctoredExamSettings', () => {
enable_proctored_exams: true,
allow_proctoring_opt_out: false,
proctoring_provider: 'mockproc',
create_zendesk_tickets: true,
},
});
@@ -645,7 +664,7 @@ describe('ProctoredExamSettings', () => {
});
it('Successfully updates exam configuration and studio provider is set to "lti_external" for lti providers', async () => {
await act(async () => render(renderComponent(<ProctoredExamSettings {...defaultProps} />)));
await act(async () => render(intlWrapper(<ProctoredExamSettings {...defaultProps} />)));
// Make a change to the provider to test_lti and set the email
const selectElement = screen.getByDisplayValue('mockproc');
fireEvent.change(selectElement, { target: { value: 'test_lti' } });
@@ -672,7 +691,7 @@ describe('ProctoredExamSettings', () => {
enable_proctored_exams: true,
allow_proctoring_opt_out: false,
proctoring_provider: 'lti_external',
proctoring_escalation_email: 'test_lti@example.com',
create_zendesk_tickets: true,
},
});
@@ -686,7 +705,7 @@ describe('ProctoredExamSettings', () => {
});
it('Sets exam service provider to null if a non-lti provider is selected', async () => {
await act(async () => render(renderComponent(<ProctoredExamSettings {...defaultProps} />)));
await act(async () => render(intlWrapper(<ProctoredExamSettings {...defaultProps} />)));
const submitButton = screen.getByTestId('submissionButton');
fireEvent.click(submitButton);
// update exam service config
@@ -702,6 +721,7 @@ describe('ProctoredExamSettings', () => {
enable_proctored_exams: true,
allow_proctoring_opt_out: false,
proctoring_provider: 'mockproc',
create_zendesk_tickets: true,
},
});
@@ -723,13 +743,13 @@ describe('ProctoredExamSettings', () => {
allow_proctoring_opt_out: false,
proctoring_provider: 'mockproc',
proctoring_escalation_email: 'test@example.com',
create_zendesk_tickets: true,
},
available_proctoring_providers: ['software_secure', 'mockproc'],
requires_escalation_email_providers: [],
available_proctoring_providers: ['software_secure', 'proctortrack', 'mockproc'],
course_start_date: '2070-01-01T00:00:00Z',
});
await act(async () => render(renderComponent(<ProctoredExamSettings {...defaultProps} />)));
await act(async () => render(intlWrapper(<ProctoredExamSettings {...defaultProps} />)));
const submitButton = screen.getByTestId('submissionButton');
fireEvent.click(submitButton);
// does not update exam service config
@@ -741,6 +761,7 @@ describe('ProctoredExamSettings', () => {
enable_proctored_exams: true,
allow_proctoring_opt_out: false,
proctoring_provider: 'mockproc',
create_zendesk_tickets: true,
},
});
@@ -758,7 +779,7 @@ describe('ProctoredExamSettings', () => {
StudioApiService.getProctoredExamSettingsUrl(defaultProps.courseId),
).reply(500);
await act(async () => render(renderComponent(<ProctoredExamSettings {...defaultProps} />)));
await act(async () => render(intlWrapper(<ProctoredExamSettings {...defaultProps} />)));
const submitButton = screen.getByTestId('submissionButton');
fireEvent.click(submitButton);
expect(axiosMock.history.post.length).toBe(1);
@@ -776,7 +797,7 @@ describe('ProctoredExamSettings', () => {
`${ExamsApiService.getExamsBaseUrl()}/api/v1/configs/course_id/${defaultProps.courseId}`,
).reply(500, 'error');
await act(async () => render(renderComponent(<ProctoredExamSettings {...defaultProps} />)));
await act(async () => render(intlWrapper(<ProctoredExamSettings {...defaultProps} />)));
const submitButton = screen.getByTestId('submissionButton');
fireEvent.click(submitButton);
expect(axiosMock.history.post.length).toBe(1);
@@ -794,7 +815,7 @@ describe('ProctoredExamSettings', () => {
`${ExamsApiService.getExamsBaseUrl()}/api/v1/configs/course_id/${defaultProps.courseId}`,
).reply(403, 'error');
await act(async () => render(renderComponent(<ProctoredExamSettings {...defaultProps} />)));
await act(async () => render(intlWrapper(<ProctoredExamSettings {...defaultProps} />)));
const submitButton = screen.getByTestId('submissionButton');
fireEvent.click(submitButton);
expect(axiosMock.history.post.length).toBe(1);
@@ -813,7 +834,7 @@ describe('ProctoredExamSettings', () => {
StudioApiService.getProctoredExamSettingsUrl(defaultProps.courseId),
).reply(500);
await act(async () => render(renderComponent(<ProctoredExamSettings {...defaultProps} />)));
await act(async () => render(intlWrapper(<ProctoredExamSettings {...defaultProps} />)));
const submitButton = screen.getByTestId('submissionButton');
fireEvent.click(submitButton);
expect(axiosMock.history.post.length).toBe(1);
@@ -840,5 +861,27 @@ describe('ProctoredExamSettings', () => {
expect(document.activeElement).toEqual(successAlert);
});
});
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);
await act(async () => render(intlWrapper(<ProctoredExamSettings {...defaultProps} />)));
// Make a change to the proctoring provider
const selectElement = screen.getByDisplayValue('mockproc');
fireEvent.change(selectElement, { target: { value: 'proctortrack' } });
const submitButton = screen.getByTestId('submissionButton');
fireEvent.click(submitButton);
expect(axiosMock.history.post.length).toBe(1);
expect(JSON.parse(axiosMock.history.post[0].data)).toEqual({
proctored_exam_settings: {
enable_proctored_exams: true,
proctoring_provider: 'proctortrack',
proctoring_escalation_email: 'test@example.com',
create_zendesk_tickets: false,
},
});
});
});
});

View File

@@ -81,6 +81,11 @@ const messages = defineMessages({
defaultMessage: 'Allow learners to opt out of proctoring on proctored exams',
description: 'Label for radio selection allowing proctored exam opt out',
},
'authoring.proctoring.createzendesk.label': {
id: 'authoring.proctoring.createzendesk.label',
defaultMessage: 'Create Zendesk tickets for suspicious attempts',
description: 'Label for Zendesk ticket creation radio select.',
},
'authoring.proctoring.error.single': {
id: 'authoring.proctoring.error.single',
defaultMessage: 'There is 1 error in this form.',

View File

@@ -107,7 +107,6 @@ const TeamSettings = ({
)
.when('enabled', {
is: true,
// oxlint-disable-next-line unicorn/no-thenable
then: Yup.array().min(1),
})
.default([])

View File

@@ -1,5 +1,4 @@
import { useIntl } from '@edx/frontend-platform/i18n';
import { getExternalLinkUrl } from '@edx/frontend-platform';
import {
ActionRow,
Alert,
@@ -239,10 +238,8 @@ const SettingsModal = ({
const values = { ...rest, enabled: enabled ? checked === 'true' : undefined };
if (enabled) {
// oxlint-disable-next-line @typescript-eslint/await-thenable - this dispatch() IS returning a promise.
success = await dispatch(updateXpertSettings(courseId, values));
} else {
// oxlint-disable-next-line @typescript-eslint/await-thenable - this dispatch() IS returning a promise.
success = await dispatch(removeXpertSettings(courseId));
}
@@ -279,7 +276,7 @@ const SettingsModal = ({
<div className="py-1">
<Hyperlink
className="text-primary-500"
destination={getExternalLinkUrl('https://openai.com/api-data-privacy')}
destination="https://openai.com/api-data-privacy"
target="_blank"
rel="noreferrer noopener"
>

View File

@@ -1,172 +0,0 @@
import { getConfig } from '@edx/frontend-platform';
import {
createContext, useContext, useMemo, useState,
} from 'react';
import { getAuthenticatedUser } from '@edx/frontend-platform/auth';
import { useCreateCourseBlock } from '@src/course-outline/data/apiHooks';
import { useSelector } from 'react-redux';
import { useNavigate } from 'react-router';
import { getOutlineIndexData } from '@src/course-outline/data/selectors';
import { useToggleWithValue } from '@src/hooks';
import { SelectionState, type UnitXBlock, type XBlock } from '@src/data/types';
import { CourseDetailsData } from './data/api';
import { useCourseDetails, useWaffleFlags } from './data/apiHooks';
import { RequestStatusType } from './data/constants';
type ModalState = {
value?: XBlock | UnitXBlock;
subsectionId?: string;
sectionId?: string;
};
export type CourseAuthoringContextData = {
/** The ID of the current course */
courseId: string;
courseUsageKey: string;
courseDetails?: CourseDetailsData;
courseDetailStatus: RequestStatusType;
canChangeProviders: boolean;
handleAddAndOpenUnit: ReturnType<typeof useCreateCourseBlock>;
handleAddBlock: ReturnType<typeof useCreateCourseBlock>;
openUnitPage: (locator: string) => void;
getUnitUrl: (locator: string) => string;
isUnlinkModalOpen: boolean;
currentUnlinkModalData?: ModalState;
openUnlinkModal: (value: ModalState) => void;
closeUnlinkModal: () => void;
isPublishModalOpen: boolean;
currentPublishModalData?: ModalState;
openPublishModal: (value: ModalState) => void;
closePublishModal: () => void;
currentSelection?: SelectionState;
setCurrentSelection: React.Dispatch<React.SetStateAction<SelectionState | undefined>>;
};
/**
* Course Authoring Context.
* Always available when we're in the context of a single course.
*
* Get this using `useCourseAuthoringContext()`
*
*/
const CourseAuthoringContext = createContext<CourseAuthoringContextData | undefined>(undefined);
type CourseAuthoringProviderProps = {
children?: React.ReactNode;
courseId: string;
};
export const CourseAuthoringProvider = ({
children,
courseId,
}: CourseAuthoringProviderProps) => {
const navigate = useNavigate();
const waffleFlags = useWaffleFlags();
const { data: courseDetails, status: courseDetailStatus } = useCourseDetails(courseId);
const canChangeProviders = getAuthenticatedUser().administrator || new Date(courseDetails?.start ?? 0) > new Date();
const { courseStructure } = useSelector(getOutlineIndexData);
const { id: courseUsageKey } = courseStructure || {};
const [
isUnlinkModalOpen,
currentUnlinkModalData,
openUnlinkModal,
closeUnlinkModal,
] = useToggleWithValue<ModalState>();
const [
isPublishModalOpen,
currentPublishModalData,
openPublishModal,
closePublishModal,
] = useToggleWithValue<ModalState>();
/**
* This will hold the state of current item that is being operated on,
* For example:
* - the details of container that is being edited.
* - the details of container of which see more dropdown is open.
* It is mostly used in modals which should be soon be replaced with its equivalent in sidebar.
*/
const [currentSelection, setCurrentSelection] = useState<SelectionState | undefined>();
const getUnitUrl = (locator: string) => {
if (getConfig().ENABLE_UNIT_PAGE === 'true' && waffleFlags.useNewUnitPage) {
// instanbul ignore next
return `/course/${courseId}/container/${locator}`;
}
return `${getConfig().STUDIO_BASE_URL}/container/${locator}`;
};
/**
* Open the unit page for a given locator.
*/
const openUnitPage = async (locator: string) => {
const url = getUnitUrl(locator);
if (getConfig().ENABLE_UNIT_PAGE === 'true' && waffleFlags.useNewUnitPage) {
// instanbul ignore next
navigate(url);
} else {
window.location.assign(url);
}
};
/**
* import a unit block from library and redirect user to this unit page.
*/
const handleAddAndOpenUnit = useCreateCourseBlock(courseId, openUnitPage);
const handleAddBlock = useCreateCourseBlock(courseId);
const context = useMemo<CourseAuthoringContextData>(() => ({
courseId,
courseUsageKey,
courseDetails,
courseDetailStatus,
canChangeProviders,
handleAddBlock,
handleAddAndOpenUnit,
getUnitUrl,
openUnitPage,
isUnlinkModalOpen,
openUnlinkModal,
closeUnlinkModal,
currentUnlinkModalData,
isPublishModalOpen,
currentPublishModalData,
openPublishModal,
closePublishModal,
currentSelection,
setCurrentSelection,
}), [
courseId,
courseUsageKey,
courseDetails,
courseDetailStatus,
canChangeProviders,
handleAddBlock,
handleAddAndOpenUnit,
getUnitUrl,
openUnitPage,
isUnlinkModalOpen,
openUnlinkModal,
closeUnlinkModal,
currentUnlinkModalData,
isPublishModalOpen,
currentPublishModalData,
openPublishModal,
closePublishModal,
currentSelection,
setCurrentSelection,
]);
return (
<CourseAuthoringContext.Provider value={context}>
{children}
</CourseAuthoringContext.Provider>
);
};
export function useCourseAuthoringContext(): CourseAuthoringContextData {
const ctx = useContext(CourseAuthoringContext);
if (ctx === undefined) {
/* istanbul ignore next */
throw new Error('useCourseAuthoringContext() was used in a component without a <CourseAuthoringProvider> ancestor.');
}
return ctx;
}

View File

@@ -1,4 +1,5 @@
import React, { useEffect } from 'react';
import PropTypes from 'prop-types';
import { useDispatch, useSelector } from 'react-redux';
import {
@@ -6,31 +7,34 @@ import {
} from 'react-router-dom';
import { StudioFooterSlot } from '@edx/frontend-component-footer';
import Header from './header';
import { fetchCourseDetail } from './data/thunks';
import { useModel } from './generic/model-store';
import NotFoundAlert from './generic/NotFoundAlert';
import PermissionDeniedAlert from './generic/PermissionDeniedAlert';
import { fetchOnlyStudioHomeData } from './studio-home/data/thunks';
import { getCourseAppsApiStatus } from './pages-and-resources/data/selectors';
import { RequestStatus } from './data/constants';
import Loading from './generic/Loading';
import { useCourseAuthoringContext } from './CourseAuthoringContext';
interface Props {
children?: React.ReactNode;
}
const CourseAuthoringPage = ({ children }: Props) => {
const CourseAuthoringPage = ({ courseId, children }) => {
const dispatch = useDispatch();
useEffect(() => {
dispatch(fetchCourseDetail(courseId));
}, [courseId]);
useEffect(() => {
dispatch(fetchOnlyStudioHomeData());
}, []);
const { courseId, courseDetails, courseDetailStatus } = useCourseAuthoringContext();
const courseNumber = courseDetails?.number;
const courseOrg = courseDetails?.org;
const courseTitle = courseDetails?.name;
const inProgress = courseDetailStatus === RequestStatus.IN_PROGRESS || courseDetailStatus === RequestStatus.PENDING;
const courseDetail = useModel('courseDetails', courseId);
const courseNumber = courseDetail ? courseDetail.number : null;
const courseOrg = courseDetail ? courseDetail.org : null;
const courseTitle = courseDetail ? courseDetail.name : courseId;
const courseAppsApiStatus = useSelector(getCourseAppsApiStatus);
const courseDetailStatus = useSelector(state => state.courseDetail.status);
const inProgress = courseDetailStatus === RequestStatus.IN_PROGRESS;
const { pathname } = useLocation();
const isEditor = pathname.includes('/editor');
@@ -57,9 +61,6 @@ const CourseAuthoringPage = ({ children }: Props) => {
org={courseOrg}
title={courseTitle}
contextId={courseId}
containerProps={{
size: 'fluid',
}}
/>
)
)}
@@ -69,4 +70,13 @@ const CourseAuthoringPage = ({ children }: Props) => {
);
};
CourseAuthoringPage.propTypes = {
children: PropTypes.node,
courseId: PropTypes.string.isRequired,
};
CourseAuthoringPage.defaultProps = {
children: null,
};
export default CourseAuthoringPage;

View File

@@ -4,9 +4,9 @@ import CourseAuthoringPage from './CourseAuthoringPage';
import PagesAndResources from './pages-and-resources/PagesAndResources';
import { executeThunk } from './utils';
import { fetchCourseApps } from './pages-and-resources/data/thunks';
import { fetchCourseDetail } from './data/thunks';
import { getApiWaffleFlagsUrl } from './data/api';
import { initializeMocks, render } from './testUtils';
import { CourseAuthoringProvider } from './CourseAuthoringContext';
const courseId = 'course-v1:edX+TestX+Test_Course';
let mockPathname = '/evilguy/';
@@ -19,12 +19,6 @@ jest.mock('react-router-dom', () => ({
let axiosMock;
let store;
const renderComponent = children => render(
<CourseAuthoringProvider courseId={courseId}>
{children}
</CourseAuthoringProvider>,
);
beforeEach(async () => {
const mocks = initializeMocks();
store = mocks.reduxStore;
@@ -41,13 +35,14 @@ describe('Editor Pages Load no header', () => {
axiosMock.onGet(`${courseAppsApiUrl}/${courseId}`).reply(200, {
response: { status: 200 },
});
await executeThunk(fetchCourseApps(courseId), store.dispatch);
};
test('renders no loading wheel on editor pages', async () => {
mockPathname = '/editor/';
await mockStoreSuccess();
const wrapper = renderComponent(
<CourseAuthoringPage>
<PagesAndResources />
const wrapper = render(
<CourseAuthoringPage courseId={courseId}>
<PagesAndResources courseId={courseId} />
</CourseAuthoringPage>
,
);
@@ -56,9 +51,9 @@ describe('Editor Pages Load no header', () => {
test('renders loading wheel on non editor pages', async () => {
mockPathname = '/evilguy/';
await mockStoreSuccess();
const wrapper = renderComponent(
<CourseAuthoringPage>
<PagesAndResources />
const wrapper = render(
<CourseAuthoringPage courseId={courseId}>
<PagesAndResources courseId={courseId} />
</CourseAuthoringPage>
,
);
@@ -75,6 +70,7 @@ describe('Course authoring page', () => {
).reply(404, {
response: { status: 404 },
});
await executeThunk(fetchCourseDetail(courseId), store.dispatch);
};
const mockStoreError = async () => {
axiosMock.onGet(
@@ -82,10 +78,11 @@ describe('Course authoring page', () => {
).reply(500, {
response: { status: 500 },
});
await executeThunk(fetchCourseDetail(courseId), store.dispatch);
};
test('renders not found page on non-existent course key', async () => {
await mockStoreNotFound();
const wrapper = renderComponent(<CourseAuthoringPage />);
const wrapper = render(<CourseAuthoringPage courseId={courseId} />);
expect(await wrapper.findByTestId('notFoundAlert')).toBeInTheDocument();
});
test('does not render not found page on other kinds of error', async () => {
@@ -95,8 +92,8 @@ describe('Course authoring page', () => {
// IN_PROGRESS but also not NOT_FOUND or DENIED- then check that the not
// found alert is not present.
const contentTestId = 'courseAuthoringPageContent';
const wrapper = renderComponent(
<CourseAuthoringPage>
const wrapper = render(
<CourseAuthoringPage courseId={courseId}>
<div data-testid={contentTestId} />
</CourseAuthoringPage>
,
@@ -117,7 +114,7 @@ describe('Course authoring page', () => {
mockPathname = '/editor/';
await mockStoreDenied();
const wrapper = renderComponent(<CourseAuthoringPage />);
const wrapper = render(<CourseAuthoringPage courseId={courseId} />);
expect(await wrapper.findByTestId('permissionDeniedAlert')).toBeInTheDocument();
});
});

View File

@@ -0,0 +1,153 @@
import React from 'react';
import {
Navigate, Routes, Route, useParams,
} from 'react-router-dom';
import { getConfig } from '@edx/frontend-platform';
import { PageWrap } from '@edx/frontend-platform/react';
import { Textbooks } from './textbooks';
import CourseAuthoringPage from './CourseAuthoringPage';
import { PagesAndResources } from './pages-and-resources';
import EditorContainer from './editors/EditorContainer';
import VideoSelectorContainer from './selectors/VideoSelectorContainer';
import CustomPages from './custom-pages';
import { FilesPage, VideosPage } from './files-and-videos';
import { AdvancedSettings } from './advanced-settings';
import { CourseOutline } from './course-outline';
import ScheduleAndDetails from './schedule-and-details';
import { GradingSettings } from './grading-settings';
import CourseTeam from './course-team/CourseTeam';
import { CourseUpdates } from './course-updates';
import { CourseUnit, SubsectionUnitRedirect } from './course-unit';
import { Certificates } from './certificates';
import CourseExportPage from './export-page/CourseExportPage';
import CourseOptimizerPage from './optimizer-page/CourseOptimizerPage';
import CourseImportPage from './import-page/CourseImportPage';
import { DECODED_ROUTES } from './constants';
import CourseChecklist from './course-checklist';
import GroupConfigurations from './group-configurations';
import { CourseLibraries } from './course-libraries';
import { IframeProvider } from './generic/hooks/context/iFrameContext';
/**
* As of this writing, these routes are mounted at a path prefixed with the following:
*
* /course/:courseId
*
* Meaning that their absolute paths look like:
*
* /course/:courseId/course-pages
* /course/:courseId/proctored-exam-settings
* /course/:courseId/editor/:blockType/:blockId
*
* This component and CourseAuthoringPage should maybe be combined once we no longer need to have
* CourseAuthoringPage split out for use in LegacyProctoringRoute. Once that route is removed, we
* 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 = () => {
const { courseId } = useParams();
return (
<CourseAuthoringPage courseId={courseId}>
<Routes>
<Route
path="/"
element={<PageWrap><CourseOutline courseId={courseId} /></PageWrap>}
/>
<Route
path="course_info"
element={<PageWrap><CourseUpdates courseId={courseId} /></PageWrap>}
/>
<Route
path="libraries"
element={<PageWrap><CourseLibraries courseId={courseId} /></PageWrap>}
/>
<Route
path="assets"
element={<PageWrap><FilesPage courseId={courseId} /></PageWrap>}
/>
<Route
path="videos"
element={getConfig().ENABLE_VIDEO_UPLOAD_PAGE_LINK_IN_CONTENT_DROPDOWN === 'true' ? <PageWrap><VideosPage courseId={courseId} /></PageWrap> : null}
/>
<Route
path="pages-and-resources/*"
element={<PageWrap><PagesAndResources courseId={courseId} /></PageWrap>}
/>
<Route
path="proctored-exam-settings"
element={<Navigate replace to={`/course/${courseId}/pages-and-resources`} />}
/>
<Route
path="custom-pages/*"
element={<PageWrap><CustomPages courseId={courseId} /></PageWrap>}
/>
<Route
path="/subsection/:subsectionId"
element={<PageWrap><SubsectionUnitRedirect courseId={courseId} /></PageWrap>}
/>
{DECODED_ROUTES.COURSE_UNIT.map((path) => (
<Route
key={path}
path={path}
element={<PageWrap><IframeProvider><CourseUnit courseId={courseId} /></IframeProvider></PageWrap>}
/>
))}
<Route
path="editor/course-videos/:blockId"
element={<PageWrap><VideoSelectorContainer courseId={courseId} /></PageWrap>}
/>
<Route
path="editor/:blockType/:blockId?"
element={<PageWrap><EditorContainer learningContextId={courseId} /></PageWrap>}
/>
<Route
path="settings/details"
element={<PageWrap><ScheduleAndDetails courseId={courseId} /></PageWrap>}
/>
<Route
path="settings/grading"
element={<PageWrap><GradingSettings courseId={courseId} /></PageWrap>}
/>
<Route
path="course_team"
element={<PageWrap><CourseTeam courseId={courseId} /></PageWrap>}
/>
<Route
path="group_configurations"
element={<PageWrap><GroupConfigurations courseId={courseId} /></PageWrap>}
/>
<Route
path="settings/advanced"
element={<PageWrap><AdvancedSettings courseId={courseId} /></PageWrap>}
/>
<Route
path="import"
element={<PageWrap><CourseImportPage courseId={courseId} /></PageWrap>}
/>
<Route
path="export"
element={<PageWrap><CourseExportPage courseId={courseId} /></PageWrap>}
/>
<Route
path="optimizer"
element={<PageWrap><CourseOptimizerPage courseId={courseId} /></PageWrap>}
/>
<Route
path="checklists"
element={<PageWrap><CourseChecklist courseId={courseId} /></PageWrap>}
/>
<Route
path="certificates"
element={getConfig().ENABLE_CERTIFICATE_PAGE === 'true' ? <PageWrap><Certificates courseId={courseId} /></PageWrap> : null}
/>
<Route
path="textbooks"
element={<PageWrap><Textbooks courseId={courseId} /></PageWrap>}
/>
</Routes>
</CourseAuthoringPage>
);
};
export default CourseAuthoringRoutes;

View File

@@ -48,11 +48,7 @@ jest.mock('./custom-pages/CustomPages', () => (props) => {
describe('<CourseAuthoringRoutes>', () => {
beforeEach(async () => {
const user = {
userId: 1,
username: 'username',
};
const { axiosMock } = initializeMocks({ user });
const { axiosMock } = initializeMocks();
axiosMock
.onGet(getApiWaffleFlagsUrl(courseId))
.reply(200, {});
@@ -65,7 +61,11 @@ describe('<CourseAuthoringRoutes>', () => {
);
await waitFor(() => {
expect(screen.getByText(pagesAndResourcesMockText)).toBeVisible();
expect(mockComponentFn).toHaveBeenCalled();
expect(mockComponentFn).toHaveBeenCalledWith(
expect.objectContaining({
courseId,
}),
);
});
});
@@ -93,7 +93,11 @@ describe('<CourseAuthoringRoutes>', () => {
await waitFor(() => {
expect(screen.queryByText(videoSelectorContainerMockText)).toBeInTheDocument();
expect(screen.queryByText(pagesAndResourcesMockText)).not.toBeInTheDocument();
expect(mockComponentFn).toHaveBeenCalled();
expect(mockComponentFn).toHaveBeenCalledWith(
expect.objectContaining({
courseId,
}),
);
});
});
});

View File

@@ -1,186 +0,0 @@
import {
Navigate, Routes, Route, useParams,
} from 'react-router-dom';
import { getConfig } from '@edx/frontend-platform';
import { PageWrap } from '@edx/frontend-platform/react';
import { Textbooks } from './textbooks';
import CourseAuthoringPage from './CourseAuthoringPage';
import { PagesAndResources } from './pages-and-resources';
import EditorContainer from './editors/EditorContainer';
import VideoSelectorContainer from './selectors/VideoSelectorContainer';
import CustomPages from './custom-pages';
import { FilesPage, VideosPage } from './files-and-videos';
import { AdvancedSettings } from './advanced-settings';
import {
CourseOutline,
OutlineSidebarProvider,
OutlineSidebarPagesProvider,
} from './course-outline';
import ScheduleAndDetails from './schedule-and-details';
import { GradingSettings } from './grading-settings';
import CourseTeam from './course-team/CourseTeam';
import { CourseUpdates } from './course-updates';
import { CourseUnit, SubsectionUnitRedirect } from './course-unit';
import { Certificates } from './certificates';
import CourseExportPage from './export-page/CourseExportPage';
import CourseOptimizerPage from './optimizer-page/CourseOptimizerPage';
import CourseImportPage from './import-page/CourseImportPage';
import { DECODED_ROUTES } from './constants';
import CourseChecklist from './course-checklist';
import GroupConfigurations from './group-configurations';
import { CourseLibraries } from './course-libraries';
import { IframeProvider } from './generic/hooks/context/iFrameContext';
import { CourseAuthoringProvider } from './CourseAuthoringContext';
import { CourseImportProvider } from './import-page/CourseImportContext';
import { CourseExportProvider } from './export-page/CourseExportContext';
/**
* As of this writing, these routes are mounted at a path prefixed with the following:
*
* /course/:courseId
*
* Meaning that their absolute paths look like:
*
* /course/:courseId/course-pages
* /course/:courseId/proctored-exam-settings
* /course/:courseId/editor/:blockType/:blockId
*
* This component and CourseAuthoringPage should maybe be combined once we no longer need to have
* CourseAuthoringPage split out for use in LegacyProctoringRoute. Once that route is removed, we
* 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 = () => {
const { courseId } = useParams();
if (courseId === undefined) {
// istanbul ignore next - This shouldn't be possible; it's just here to satisfy the type checker.
throw new Error('Error: route is missing courseId.');
}
return (
<CourseAuthoringProvider courseId={courseId}>
<CourseAuthoringPage>
<Routes>
<Route
path="/"
element={(
<PageWrap>
<OutlineSidebarPagesProvider>
<OutlineSidebarProvider>
<CourseOutline />
</OutlineSidebarProvider>
</OutlineSidebarPagesProvider>
</PageWrap>
)}
/>
<Route
path="course_info"
element={<PageWrap><CourseUpdates /></PageWrap>}
/>
<Route
path="libraries"
element={<PageWrap><CourseLibraries /></PageWrap>}
/>
<Route
path="assets"
element={<PageWrap><FilesPage /></PageWrap>}
/>
<Route
path="videos"
element={getConfig().ENABLE_VIDEO_UPLOAD_PAGE_LINK_IN_CONTENT_DROPDOWN === 'true' ? <PageWrap><VideosPage /></PageWrap> : null}
/>
<Route
path="pages-and-resources/*"
element={<PageWrap><PagesAndResources /></PageWrap>}
/>
<Route
path="proctored-exam-settings"
element={<Navigate replace to={`/course/${courseId}/pages-and-resources`} />}
/>
<Route
path="custom-pages/*"
element={<PageWrap><CustomPages /></PageWrap>}
/>
<Route
path="/subsection/:subsectionId"
element={<PageWrap><SubsectionUnitRedirect /></PageWrap>}
/>
{DECODED_ROUTES.COURSE_UNIT.map((path) => (
<Route
key={path}
path={path}
element={<PageWrap><IframeProvider><CourseUnit /></IframeProvider></PageWrap>}
/>
))}
<Route
path="editor/course-videos/:blockId"
element={<PageWrap><VideoSelectorContainer /></PageWrap>}
/>
<Route
path="editor/:blockType/:blockId?"
element={<PageWrap><EditorContainer learningContextId={courseId} /></PageWrap>}
/>
<Route
path="settings/details"
element={<PageWrap><ScheduleAndDetails /></PageWrap>}
/>
<Route
path="settings/grading"
element={<PageWrap><GradingSettings /></PageWrap>}
/>
<Route
path="course_team"
element={<PageWrap><CourseTeam /></PageWrap>}
/>
<Route
path="group_configurations"
element={<PageWrap><GroupConfigurations /></PageWrap>}
/>
<Route
path="settings/advanced"
element={<PageWrap><AdvancedSettings /></PageWrap>}
/>
<Route
path="import"
element={(
<PageWrap>
<CourseImportProvider>
<CourseImportPage />
</CourseImportProvider>
</PageWrap>
)}
/>
<Route
path="export"
element={(
<PageWrap>
<CourseExportProvider>
<CourseExportPage />
</CourseExportProvider>
</PageWrap>
)}
/>
<Route
path="optimizer"
element={<PageWrap><CourseOptimizerPage /></PageWrap>}
/>
<Route
path="checklists"
element={<PageWrap><CourseChecklist /></PageWrap>}
/>
<Route
path="certificates"
element={getConfig().ENABLE_CERTIFICATE_PAGE === 'true' ? <PageWrap><Certificates /></PageWrap> : null}
/>
<Route
path="textbooks"
element={<PageWrap><Textbooks /></PageWrap>}
/>
</Routes>
</CourseAuthoringPage>
</CourseAuthoringProvider>
);
};
export default CourseAuthoringRoutes;

View File

@@ -1,3 +1,5 @@
import React from 'react';
import PropTypes from 'prop-types';
import { FormattedMessage } from '@edx/frontend-platform/i18n';
import { Hyperlink, MailtoLink, Stack } from '@openedx/paragon';
@@ -6,9 +8,6 @@ import messages from './messages';
const AccessibilityBody = ({
communityAccessibilityLink,
email,
}: {
communityAccessibilityLink: string,
email: string,
}) => (
<div className="mt-5">
<header>
@@ -91,4 +90,9 @@ const AccessibilityBody = ({
</div>
);
AccessibilityBody.propTypes = {
communityAccessibilityLink: PropTypes.string.isRequired,
email: PropTypes.string.isRequired,
};
export default AccessibilityBody;

View File

@@ -0,0 +1,46 @@
import {
render,
screen,
} from '@testing-library/react';
import { AppProvider } from '@edx/frontend-platform/react';
import { IntlProvider } from '@edx/frontend-platform/i18n';
import { initializeMockApp } from '@edx/frontend-platform';
import initializeStore from '../../store';
import AccessibilityBody from './index';
let store;
const renderComponent = () => {
render(
<IntlProvider locale="en">
<AppProvider store={store}>
<AccessibilityBody
communityAccessibilityLink="http://example.com"
email="example@example.com"
/>
</AppProvider>
</IntlProvider>,
);
};
describe('<AccessibilityBody />', () => {
describe('renders', () => {
beforeEach(async () => {
initializeMockApp({
authenticatedUser: {
userId: 3,
username: 'abc123',
administrator: false,
roles: [],
},
});
store = initializeStore({});
});
it('contains links', () => {
renderComponent();
expect(screen.getAllByTestId('email-element')).toHaveLength(2);
expect(screen.getAllByTestId('accessibility-page-link')).toHaveLength(1);
});
});
});

View File

@@ -1,29 +0,0 @@
import {
initializeMocks,
render,
screen,
} from '@src/testUtils';
import AccessibilityBody from './index';
const renderComponent = () => {
render(
<AccessibilityBody
communityAccessibilityLink="http://example.com"
email="example@example.com"
/>,
);
};
describe('<AccessibilityBody />', () => {
describe('renders', () => {
beforeEach(async () => {
initializeMocks();
});
it('contains links', () => {
renderComponent();
expect(screen.getAllByTestId('email-element')).toHaveLength(2);
expect(screen.getAllByTestId('accessibility-page-link')).toHaveLength(1);
});
});
});

View File

@@ -1,3 +1,5 @@
import React from 'react';
import PropTypes from 'prop-types';
import {
FormattedMessage, FormattedDate, FormattedTime, useIntl,
} from '@edx/frontend-platform/i18n';
@@ -5,22 +7,26 @@ import {
ActionRow, Alert, Form, Stack, StatefulButton,
} from '@openedx/paragon';
import { STATEFUL_BUTTON_STATES } from '@src/constants';
import { RequestStatus } from '../../data/constants';
import { STATEFUL_BUTTON_STATES } from '../../constants';
import submitAccessibilityForm from '../data/thunks';
import useAccessibility from './hooks';
import messages from './messages';
const AccessibilityForm = ({ accessibilityEmail }: { accessibilityEmail: string }) => {
const AccessibilityForm = ({
accessibilityEmail,
}) => {
const intl = useIntl();
const {
errors,
values,
isFormFilled,
mutation,
dispatch,
handleBlur,
handleChange,
hasErrorField,
savingStatus,
} = useAccessibility({ name: '', email: '', message: '' });
} = useAccessibility({ name: '', email: '', message: '' }, intl);
const formFields = [
{
@@ -49,7 +55,7 @@ const AccessibilityForm = ({ accessibilityEmail }: { accessibilityEmail: string
};
const handleSubmit = () => {
mutation.mutateAsync(values).catch(() => {});
dispatch(submitAccessibilityForm(values));
};
const start = new Date('Mon Jan 29 2018 13:00:00 GMT (UTC)');
@@ -60,7 +66,7 @@ const AccessibilityForm = ({ accessibilityEmail }: { accessibilityEmail: string
<h2 className="my-4">
<FormattedMessage {...messages.accessibilityPolicyFormHeader} />
</h2>
{savingStatus === 'success' && (
{savingStatus === RequestStatus.SUCCESSFUL && (
<Alert variant="success">
<Stack gap={2}>
<div className="mb-2">
@@ -80,7 +86,7 @@ const AccessibilityForm = ({ accessibilityEmail }: { accessibilityEmail: string
</Stack>
</Alert>
)}
{savingStatus === 'error' && (
{savingStatus === RequestStatus.FAILED && (
<Alert variant="danger">
<div data-testid="rate-limit-alert">
<FormattedMessage
@@ -119,7 +125,7 @@ const AccessibilityForm = ({ accessibilityEmail }: { accessibilityEmail: string
onClick={handleSubmit}
disabled={!isFormFilled}
state={
savingStatus === 'pending'
savingStatus === RequestStatus.IN_PROGRESS
? STATEFUL_BUTTON_STATES.pending
: STATEFUL_BUTTON_STATES.default
}
@@ -130,4 +136,8 @@ const AccessibilityForm = ({ accessibilityEmail }: { accessibilityEmail: string
);
};
AccessibilityForm.propTypes = {
accessibilityEmail: PropTypes.string.isRequired,
};
export default AccessibilityForm;

View File

@@ -1,31 +1,56 @@
import {
initializeMocks,
render,
screen,
} from '@src/testUtils';
} from '@testing-library/react';
import userEvent from '@testing-library/user-event';
import { initializeMockApp } from '@edx/frontend-platform';
import MockAdapter from 'axios-mock-adapter';
import { getAuthenticatedHttpClient } from '@edx/frontend-platform/auth';
import { AppProvider } from '@edx/frontend-platform/react';
import { IntlProvider } from '@edx/frontend-platform/i18n';
import initializeStore from '../../store';
import { RequestStatus } from '../../data/constants';
import AccessibilityForm from './index';
import { getZendeskrUrl } from '../data/api';
import messages from './messages';
let axiosMock;
let store;
const defaultProps = {
accessibilityEmail: 'accessibilityTest@test.com',
};
const initialState = {
accessibilityPage: {
savingStatus: '',
},
};
const renderComponent = () => {
render(
<AccessibilityForm {...defaultProps} />,
<IntlProvider locale="en">
<AppProvider store={store}>
<AccessibilityForm {...defaultProps} />
</AppProvider>
</IntlProvider>,
);
};
describe('<AccessibilityPolicyForm />', () => {
beforeEach(async () => {
const mocks = initializeMocks();
axiosMock = mocks.axiosMock;
initializeMockApp({
authenticatedUser: {
userId: 3,
username: 'abc123',
administrator: false,
roles: [],
},
});
store = initializeStore(initialState);
axiosMock = new MockAdapter(getAuthenticatedHttpClient());
});
describe('renders', () => {
@@ -61,23 +86,14 @@ describe('<AccessibilityPolicyForm />', () => {
submitButton = screen.getByText(messages.accessibilityPolicyFormSubmitLabel.defaultMessage);
});
it('renders in progress state', async () => {
axiosMock.onPost(getZendeskrUrl()).reply(
() => new Promise(() => {
// always in pending
}),
);
await user.click(submitButton);
expect(screen.getByRole('button', { name: /submitting/i })).toBeInTheDocument();
});
it('shows correct success message', async () => {
axiosMock.onPost(getZendeskrUrl()).reply(200);
await user.click(submitButton);
const { savingStatus } = store.getState().accessibilityPage;
expect(savingStatus).toEqual(RequestStatus.SUCCESSFUL);
expect(screen.getAllByRole('alert')).toHaveLength(1);
expect(screen.getByText(messages.accessibilityPolicyFormSuccess.defaultMessage)).toBeVisible();
@@ -92,6 +108,9 @@ describe('<AccessibilityPolicyForm />', () => {
await user.click(submitButton);
const { savingStatus } = store.getState().accessibilityPage;
expect(savingStatus).toEqual(RequestStatus.FAILED);
expect(screen.getAllByRole('alert')).toHaveLength(1);
expect(screen.getByTestId('rate-limit-alert')).toBeVisible();

View File

@@ -1,14 +1,14 @@
import { useEffect, useState } from 'react';
import { useIntl } from '@edx/frontend-platform/i18n';
import { useDispatch, useSelector } from 'react-redux';
import { useFormik } from 'formik';
import * as Yup from 'yup';
import { RequestStatus } from '../../data/constants';
import messages from './messages';
import { useSubmitAccessibilityForm } from '../data/apiHooks';
import { AccessibilityFormData } from '../data/api';
const useAccessibility = (initialValues: AccessibilityFormData) => {
const intl = useIntl();
const useAccessibility = (initialValues, intl) => {
const dispatch = useDispatch();
const savingStatus = useSelector(state => state.accessibilityPage.savingStatus);
const [isFormFilled, setFormFilled] = useState(false);
const validationSchema = Yup.object().shape({
name: Yup.string().required(
@@ -29,27 +29,29 @@ const useAccessibility = (initialValues: AccessibilityFormData) => {
enableReinitialize: true,
validateOnBlur: false,
validationSchema,
/* istanbul ignore next */
onSubmit: () => {},
});
const mutation = useSubmitAccessibilityForm(handleReset);
useEffect(() => {
setFormFilled(Object.values(values).every((i) => i));
}, [values]);
useEffect(() => {
if (savingStatus === RequestStatus.SUCCESSFUL) {
handleReset();
}
}, [savingStatus]);
const hasErrorField = (fieldName) => !!errors[fieldName] && !!touched[fieldName];
return {
errors,
values,
isFormFilled,
mutation,
dispatch,
handleBlur,
handleChange,
hasErrorField,
savingStatus: mutation.status,
savingStatus,
};
};

View File

@@ -1,5 +1,5 @@
import React from 'react';
import { useIntl } from '@edx/frontend-platform/i18n';
import { getExternalLinkUrl } from '@edx/frontend-platform';
import { Helmet } from 'react-helmet';
import { Container } from '@openedx/paragon';
import { StudioFooterSlot } from '@edx/frontend-component-footer';
@@ -23,12 +23,9 @@ const AccessibilityPage = () => {
</title>
</Helmet>
<Header isHiddenMainMenu />
<Container size="xl" className="px-4">
<Container size="xl" classNamae="px-4">
<AccessibilityBody
{...{
email: ACCESSIBILITY_EMAIL,
communityAccessibilityLink: getExternalLinkUrl(COMMUNITY_ACCESSIBILITY_LINK),
}}
{...{ email: ACCESSIBILITY_EMAIL, communityAccessibilityLink: COMMUNITY_ACCESSIBILITY_LINK }}
/>
<AccessibilityForm accessibilityEmail={ACCESSIBILITY_EMAIL} />
</Container>
@@ -37,4 +34,6 @@ const AccessibilityPage = () => {
);
};
AccessibilityPage.propTypes = {};
export default AccessibilityPage;

View File

@@ -1,3 +1,4 @@
// @ts-check
import { initializeMocks, render, screen } from '../testUtils';
import AccessibilityPage from './index';

View File

@@ -8,20 +8,12 @@ ensureConfig([
export const getApiBaseUrl = () => getConfig().STUDIO_BASE_URL;
export const getZendeskrUrl = () => `${getApiBaseUrl()}/zendesk_proxy/v0`;
export interface AccessibilityFormData {
name: string;
email: string;
message: string;
}
/**
* Posts the form data to zendesk endpoint
* @param {string} courseId
* @returns {Promise<[{}]>}
*/
export async function postAccessibilityForm({
name,
email,
message,
}: AccessibilityFormData) {
export async function postAccessibilityForm({ name, email, message }) {
const data = {
name,
tags: ['studio_a11y'],

View File

@@ -1,12 +0,0 @@
import { useMutation } from '@tanstack/react-query';
import { AccessibilityFormData, postAccessibilityForm } from './api';
/**
* Mutation to submit accessibility form
*/
export const useSubmitAccessibilityForm = (handleSuccess: (e: any) => void) => (
useMutation({
mutationFn: (formData: AccessibilityFormData) => postAccessibilityForm(formData),
onSuccess: handleSuccess,
})
);

View File

@@ -0,0 +1,23 @@
/* eslint-disable no-param-reassign */
import { createSlice } from '@reduxjs/toolkit';
const slice = createSlice({
name: 'accessibilityPage',
initialState: {
savingStatus: '',
},
reducers: {
updateSavingStatus: (state, { payload }) => {
state.savingStatus = payload.status;
},
},
});
export const {
updateLoadingStatus,
updateSavingStatus,
} = slice.actions;
export const {
reducer,
} = slice;

View File

@@ -0,0 +1,24 @@
import { RequestStatus } from '../../data/constants';
import { postAccessibilityForm } from './api';
import { updateSavingStatus } from './slice';
function submitAccessibilityForm({ email, name, message }) {
return async (dispatch) => {
dispatch(updateSavingStatus({ status: RequestStatus.IN_PROGRESS }));
try {
await postAccessibilityForm({ email, name, message });
dispatch(updateSavingStatus({ status: RequestStatus.SUCCESSFUL }));
} catch (error) {
/* istanbul ignore else */
if (error.response && error.response.status === 429) {
dispatch(updateSavingStatus({ status: RequestStatus.FAILED }));
} else {
/* istanbul ignore next */
dispatch(updateSavingStatus({ status: RequestStatus.SUCCESSFUL }));
}
}
};
}
export default submitAccessibilityForm;

View File

@@ -1,73 +1,59 @@
import { useEffect, useState } from 'react';
import { Helmet } from 'react-helmet';
import React, { useEffect, useState } from 'react';
import PropTypes from 'prop-types';
import { useDispatch, useSelector } from 'react-redux';
import {
Container, Button, Layout, StatefulButton, TransitionReplace,
} from '@openedx/paragon';
import { CheckCircle, Info, Warning } from '@openedx/paragon/icons';
import { FormattedMessage, useIntl } from '@edx/frontend-platform/i18n';
import { useCourseAuthoringContext } from '@src/CourseAuthoringContext';
import { useWaffleFlags } from '@src/data/apiHooks';
import { useUserPermissions } from '@src/authz/data/apiHooks';
import { COURSE_PERMISSIONS } from '@src/authz/constants';
import PermissionDeniedAlert from 'CourseAuthoring/generic/PermissionDeniedAlert';
import AlertProctoringError from '@src/generic/AlertProctoringError';
import { LoadingSpinner } from '@src/generic/Loading';
import InternetConnectionAlert from '@src/generic/internet-connection-alert';
import { parseArrayOrObjectValues } from '@src/utils';
import { RequestStatus } from '@src/data/constants';
import SubHeader from '@src/generic/sub-header/SubHeader';
import AlertMessage from '@src/generic/alert-message';
import getPageHeadTitle from '@src/generic/utils';
import Placeholder from '@src/editors/Placeholder';
import Placeholder from '../editors/Placeholder';
import AlertProctoringError from '../generic/AlertProctoringError';
import { useModel } from '../generic/model-store';
import InternetConnectionAlert from '../generic/internet-connection-alert';
import { parseArrayOrObjectValues } from '../utils';
import { RequestStatus } from '../data/constants';
import SubHeader from '../generic/sub-header/SubHeader';
import AlertMessage from '../generic/alert-message';
import { fetchCourseAppSettings, updateCourseAppSetting, fetchProctoringExamErrors } from './data/thunks';
import {
getCourseAppSettings, getSavingStatus, getProctoringExamErrors, getSendRequestErrors, getLoadingStatus,
} from './data/selectors';
import SettingCard from './setting-card/SettingCard';
import SettingsSidebar from './settings-sidebar/SettingsSidebar';
import validateAdvancedSettingsData from './utils';
import messages from './messages';
import ModalError from './modal-error/ModalError';
import { useCourseAdvancedSettings, useProctoringExamErrors, useUpdateCourseAdvancedSettings } from './data/apiHooks';
import getPageHeadTitle from '../generic/utils';
const AdvancedSettings = () => {
const AdvancedSettings = ({ courseId }) => {
const intl = useIntl();
const dispatch = useDispatch();
const [saveSettingsPrompt, showSaveSettingsPrompt] = useState(false);
const [showDeprecated, setShowDeprecated] = useState(false);
const [errorModal, showErrorModal] = useState(false);
const [editedSettings, setEditedSettings] = useState({});
const [errorFields, setErrorFields] = useState([]);
const [showSuccessAlert, setShowSuccessAlert] = useState(false);
const [isQueryPending, setIsQueryPending] = useState(false);
const [isEditableState, setIsEditableState] = useState(false);
const [hasInternetConnectionError, setInternetConnectionError] = useState(false);
const { courseId, courseDetails } = useCourseAuthoringContext();
const courseDetails = useModel('courseDetails', courseId);
document.title = getPageHeadTitle(courseDetails?.name, intl.formatMessage(messages.headingTitle));
const waffleFlags = useWaffleFlags(courseId);
const isAuthzEnabled = waffleFlags.enableAuthzCourseAuthoring;
const { isLoading: isLoadingUserPermissions, data: userPermissions } = useUserPermissions({
canManageAdvancedSettings: {
action: COURSE_PERMISSIONS.MANAGE_ADVANCED_SETTINGS,
scope: courseId,
},
}, isAuthzEnabled);
useEffect(() => {
dispatch(fetchCourseAppSettings(courseId));
dispatch(fetchProctoringExamErrors(courseId));
}, [courseId]);
const {
data: advancedSettingsData = {},
isPending: isPendingSettingsStatus,
failureReason: settingsStatusError,
} = useCourseAdvancedSettings(courseId);
const advancedSettingsData = useSelector(getCourseAppSettings);
const savingStatus = useSelector(getSavingStatus);
const proctoringExamErrors = useSelector(getProctoringExamErrors);
const settingsWithSendErrors = useSelector(getSendRequestErrors) || {};
const loadingSettingsStatus = useSelector(getLoadingStatus);
const {
data: proctoringExamErrors = {},
} = useProctoringExamErrors(courseId);
const updateMutation = useUpdateCourseAdvancedSettings(courseId);
const {
isPending: isQueryPending,
isSuccess: isQuerySuccess,
error: queryError,
} = updateMutation;
const isLoading = isPendingSettingsStatus || (isAuthzEnabled && isLoadingUserPermissions);
const isLoading = loadingSettingsStatus === RequestStatus.IN_PROGRESS;
const updateSettingsButtonState = {
labels: {
default: intl.formatMessage(messages.buttonSaveText),
@@ -75,34 +61,30 @@ const AdvancedSettings = () => {
},
disabledStates: ['pending'],
};
const {
proctoringErrors,
mfeProctoredExamSettingsUrl,
} = proctoringExamErrors;
useEffect(() => {
if (isQuerySuccess) {
if (savingStatus === RequestStatus.SUCCESSFUL) {
setIsQueryPending(false);
setShowSuccessAlert(true);
setIsEditableState(false);
setTimeout(() => setShowSuccessAlert(false), 15000);
window.scrollTo({ top: 0, behavior: 'smooth' });
showSaveSettingsPrompt(false);
} else if (queryError && !hasInternetConnectionError) {
// @ts-ignore
setErrorFields(queryError?.response?.data ?? []);
} else if (savingStatus === RequestStatus.FAILED && !hasInternetConnectionError) {
setErrorFields(settingsWithSendErrors);
showErrorModal(true);
}
}, [isQuerySuccess, queryError]);
}, [savingStatus]);
if (isLoading) {
return (
<div className="row justify-content-center m-6">
<LoadingSpinner />
</div>
);
// eslint-disable-next-line react/jsx-no-useless-fragment
return <></>;
}
if (settingsStatusError?.response?.status === 403) {
if (loadingSettingsStatus === RequestStatus.DENIED) {
return (
<div className="row justify-content-center m-6">
<Placeholder />
@@ -124,42 +106,31 @@ const AdvancedSettings = () => {
const handleUpdateAdvancedSettingsData = () => {
const isValid = validateAdvancedSettingsData(editedSettings, setErrorFields, setEditedSettings);
if (isValid) {
setShowSuccessAlert(false);
updateMutation.mutate(parseArrayOrObjectValues(editedSettings));
setIsQueryPending(true);
} else {
showSaveSettingsPrompt(false);
showErrorModal(!errorModal);
}
};
/* istanbul ignore next */
const handleInternetConnectionFailed = () => {
setInternetConnectionError(true);
showSaveSettingsPrompt(false);
setShowSuccessAlert(false);
};
const handleQueryProcessing = () => {
setShowSuccessAlert(false);
dispatch(updateCourseAppSetting(courseId, parseArrayOrObjectValues(editedSettings)));
};
const handleManuallyChangeClick = (setToState) => {
showErrorModal(setToState);
showSaveSettingsPrompt(true);
};
// Show permission denied alert when authz is enabled and user doesn't have permission
const authzIsEnabledAndNoPermission = isAuthzEnabled
&& !isLoadingUserPermissions
&& !userPermissions?.canManageAdvancedSettings;
if (authzIsEnabledAndNoPermission) {
return <PermissionDeniedAlert />;
}
return (
<>
<Helmet>
<title>
{getPageHeadTitle(courseDetails?.name ?? '', intl.formatMessage(messages.headingTitle))}
</title>
</Helmet>
<Container size="xl" className="advanced-settings px-4">
<div className="setting-header mt-5">
{(proctoringErrors?.length > 0) && (
@@ -169,11 +140,7 @@ const AdvancedSettings = () => {
aria-hidden="true"
aria-labelledby={intl.formatMessage(messages.alertProctoringAriaLabelledby)}
aria-describedby={intl.formatMessage(messages.alertProctoringDescribedby)}
>
{/* Empty children to satisfy the type checker */}
{/* eslint-disable-next-line react/jsx-no-useless-fragment */}
<></>
</AlertProctoringError>
/>
)}
<TransitionReplace>
{showSuccessAlert ? (
@@ -226,8 +193,8 @@ const AdvancedSettings = () => {
defaultMessage="{visibility} deprecated settings"
values={{
visibility:
showDeprecated ? intl.formatMessage(messages.deprecatedButtonHideText)
: intl.formatMessage(messages.deprecatedButtonShowText),
showDeprecated ? intl.formatMessage(messages.deprecatedButtonHideText)
: intl.formatMessage(messages.deprecatedButtonShowText),
}}
/>
</Button>
@@ -269,8 +236,9 @@ const AdvancedSettings = () => {
<div className="alert-toast">
{isQueryPending && (
<InternetConnectionAlert
isFailed={Boolean(queryError)}
isFailed={savingStatus === RequestStatus.FAILED}
isQueryPending={isQueryPending}
onQueryProcessing={handleQueryProcessing}
onInternetConnectionFailed={handleInternetConnectionFailed}
/>
)}
@@ -281,18 +249,18 @@ const AdvancedSettings = () => {
aria-describedby={intl.formatMessage(messages.alertWarningAriaDescribedby)}
role="dialog"
actions={[
!isQueryPending ? (
!isQueryPending && (
<Button variant="tertiary" onClick={handleResetSettingsValues}>
{intl.formatMessage(messages.buttonCancelText)}
</Button>
) : /* istanbul ignore next */ null,
),
<StatefulButton
key="statefulBtn"
onClick={handleUpdateAdvancedSettingsData}
state={isQueryPending ? RequestStatus.PENDING : 'default'}
{...updateSettingsButtonState}
/>,
].filter((action): action is JSX.Element => action !== null)}
].filter(Boolean)}
variant="warning"
icon={Warning}
title={intl.formatMessage(messages.alertWarning)}
@@ -310,4 +278,8 @@ const AdvancedSettings = () => {
);
};
AdvancedSettings.propTypes = {
courseId: PropTypes.string.isRequired,
};
export default AdvancedSettings;

View File

@@ -0,0 +1,144 @@
import {
render as baseRender,
fireEvent,
initializeMocks,
waitFor,
} from '../testUtils';
import { executeThunk } from '../utils';
import { advancedSettingsMock } from './__mocks__';
import { getCourseAdvancedSettingsApiUrl } from './data/api';
import { updateCourseAppSetting } from './data/thunks';
import AdvancedSettings from './AdvancedSettings';
import messages from './messages';
let axiosMock;
let store;
const mockPathname = '/foo-bar';
const courseId = '123';
// Mock the TextareaAutosize component
jest.mock('react-textarea-autosize', () => jest.fn((props) => (
<textarea
{...props}
onFocus={() => {}}
onBlur={() => {}}
/>
)));
const render = () => baseRender(
<AdvancedSettings courseId={courseId} />,
{ path: mockPathname },
);
describe('<AdvancedSettings />', () => {
beforeEach(() => {
const mocks = initializeMocks();
store = mocks.reduxStore;
axiosMock = mocks.axiosMock;
axiosMock
.onGet(`${getCourseAdvancedSettingsApiUrl(courseId)}?fetch_all=0`)
.reply(200, advancedSettingsMock);
});
it('should render without errors', async () => {
const { getByText } = render();
await waitFor(() => {
expect(getByText(messages.headingSubtitle.defaultMessage)).toBeInTheDocument();
const advancedSettingsElement = getByText(messages.headingTitle.defaultMessage, {
selector: 'h2.sub-header-title',
});
expect(advancedSettingsElement).toBeInTheDocument();
expect(getByText(messages.policy.defaultMessage)).toBeInTheDocument();
expect(getByText(/Do not modify these policies unless you are familiar with their purpose./i)).toBeInTheDocument();
});
});
it('should render setting element', async () => {
const { getByText, queryByText } = render();
await waitFor(() => {
const advancedModuleListTitle = getByText(/Advanced Module List/i);
expect(advancedModuleListTitle).toBeInTheDocument();
expect(queryByText('Certificate web/html view enabled')).toBeNull();
});
});
it('should change to onСhange', async () => {
const { getByLabelText } = render();
await waitFor(() => {
const textarea = getByLabelText(/Advanced Module List/i);
expect(textarea).toBeInTheDocument();
fireEvent.change(textarea, { target: { value: '[1, 2, 3]' } });
expect(textarea.value).toBe('[1, 2, 3]');
});
});
it('should display a warning alert', async () => {
const { getByLabelText, getByText } = render();
await waitFor(() => {
const textarea = getByLabelText(/Advanced Module List/i);
fireEvent.change(textarea, { target: { value: '[3, 2, 1]' } });
expect(getByText(messages.buttonCancelText.defaultMessage)).toBeInTheDocument();
expect(getByText(messages.buttonSaveText.defaultMessage)).toBeInTheDocument();
expect(getByText(messages.alertWarning.defaultMessage)).toBeInTheDocument();
expect(getByText(messages.alertWarningDescriptions.defaultMessage)).toBeInTheDocument();
});
});
it('should display a tooltip on clicking on the icon', async () => {
const { getByLabelText, getByText } = render();
await waitFor(() => {
const button = getByLabelText(/Show help text/i);
fireEvent.click(button);
expect(getByText(/Enter the names of the advanced modules to use in your course./i)).toBeInTheDocument();
});
});
it('should change deprecated button text ', async () => {
const { getByText } = render();
await waitFor(() => {
const showDeprecatedItemsBtn = getByText(/Show Deprecated Settings/i);
expect(showDeprecatedItemsBtn).toBeInTheDocument();
fireEvent.click(showDeprecatedItemsBtn);
expect(getByText(/Hide Deprecated Settings/i)).toBeInTheDocument();
});
expect(getByText('Certificate web/html view enabled')).toBeInTheDocument();
});
it('should reset to default value on click on Cancel button', async () => {
const { getByLabelText, getByText } = render();
let textarea;
await waitFor(() => {
textarea = getByLabelText(/Advanced Module List/i);
});
fireEvent.change(textarea, { target: { value: '[3, 2, 1]' } });
expect(textarea.value).toBe('[3, 2, 1]');
fireEvent.click(getByText(messages.buttonCancelText.defaultMessage));
expect(textarea.value).toBe('[]');
});
it('should update the textarea value and display the updated value after clicking "Change manually"', async () => {
const { getByLabelText, getByText } = render();
let textarea;
await waitFor(() => {
textarea = getByLabelText(/Advanced Module List/i);
});
fireEvent.change(textarea, { target: { value: '[3, 2, 1,' } });
expect(textarea.value).toBe('[3, 2, 1,');
fireEvent.click(getByText('Save changes'));
fireEvent.click(getByText('Change manually'));
expect(textarea.value).toBe('[3, 2, 1,');
});
it('should show success alert after save', async () => {
const { getByLabelText, getByText } = render();
let textarea;
await waitFor(() => {
textarea = getByLabelText(/Advanced Module List/i);
});
fireEvent.change(textarea, { target: { value: '[3, 2, 1]' } });
expect(textarea.value).toBe('[3, 2, 1]');
axiosMock
.onPatch(`${getCourseAdvancedSettingsApiUrl(courseId)}`)
.reply(200, {
...advancedSettingsMock,
advancedModules: {
...advancedSettingsMock.advancedModules,
value: [3, 2, 1],
},
});
fireEvent.click(getByText('Save changes'));
await executeThunk(updateCourseAppSetting(courseId, [3, 2, 1]), store.dispatch);
expect(getByText('Your policy changes have been saved.')).toBeInTheDocument();
});
});

View File

@@ -1,190 +0,0 @@
import userEvent from '@testing-library/user-event';
import { CourseAuthoringProvider } from '@src/CourseAuthoringContext';
import { useUserPermissions } from '@src/authz/data/apiHooks';
import { mockWaffleFlags } from '@src/data/apiHooks.mock';
import {
render as baseRender,
fireEvent,
initializeMocks,
screen,
} from '@src/testUtils';
import { advancedSettingsMock } from './__mocks__';
import { getCourseAdvancedSettingsApiUrl } from './data/api';
import AdvancedSettings from './AdvancedSettings';
import messages from './messages';
let axiosMock;
const mockPathname = '/foo-bar';
const courseId = '123';
// Mock the TextareaAutosize component
jest.mock('react-textarea-autosize', () => jest.fn((props) => (
<textarea
{...props}
onFocus={() => { }}
/>
)));
jest.mock('@src/authz/data/apiHooks', () => ({
useUserPermissions: jest.fn(),
}));
const render = () => baseRender(
<CourseAuthoringProvider courseId={courseId}>
<AdvancedSettings />
</CourseAuthoringProvider>,
{ path: mockPathname },
);
describe('<AdvancedSettings />', () => {
beforeEach(() => {
const mocks = initializeMocks();
axiosMock = mocks.axiosMock;
axiosMock
.onGet(`${getCourseAdvancedSettingsApiUrl(courseId)}?fetch_all=0`)
.reply(200, advancedSettingsMock);
jest.mocked(useUserPermissions).mockReturnValue({
isLoading: false,
data: { canManageAdvancedSettings: true },
} as unknown as ReturnType<typeof useUserPermissions>);
});
it('should render placeholder when settings fetch returns 403', async () => {
axiosMock
.onGet(`${getCourseAdvancedSettingsApiUrl(courseId)}?fetch_all=0`)
.reply(403);
render();
expect(await screen.findByText(/Under Construction/i)).toBeInTheDocument();
});
it('should render without errors', async () => {
render();
expect(await screen.findByText(messages.headingSubtitle.defaultMessage)).toBeInTheDocument();
expect(screen.getByText(messages.headingTitle.defaultMessage, {
selector: 'h2.sub-header-title',
})).toBeInTheDocument();
expect(screen.getByText(messages.policy.defaultMessage)).toBeInTheDocument();
expect(screen.getByText(/Do not modify these policies unless you are familiar with their purpose./i)).toBeInTheDocument();
});
it('should render setting element', async () => {
render();
expect(await screen.findByText(/Advanced Module List/i)).toBeInTheDocument();
expect(screen.queryByText('Certificate web/html view enabled')).toBeNull();
});
it('should change to onСhange', async () => {
render();
const textarea = await screen.findByLabelText(/Advanced Module List/i);
expect(textarea).toBeInTheDocument();
fireEvent.change(textarea, { target: { value: '[1, 2, 3]' } });
expect(textarea).toHaveValue('[1, 2, 3]');
});
it('should display a warning alert', async () => {
render();
const textarea = await screen.findByLabelText(/Advanced Module List/i);
fireEvent.change(textarea, { target: { value: '[3, 2, 1]' } });
expect(screen.getByText(messages.buttonCancelText.defaultMessage)).toBeInTheDocument();
expect(screen.getByText(messages.buttonSaveText.defaultMessage)).toBeInTheDocument();
expect(screen.getByText(messages.alertWarning.defaultMessage)).toBeInTheDocument();
expect(screen.getByText(messages.alertWarningDescriptions.defaultMessage)).toBeInTheDocument();
});
it('should display a tooltip on clicking on the icon', async () => {
const user = userEvent.setup();
render();
const button = await screen.findByLabelText(/Show help text/i);
await user.click(button);
expect(screen.getByText(/Enter the names of the advanced modules to use in your course./i)).toBeInTheDocument();
});
it('should change deprecated button text', async () => {
const user = userEvent.setup();
render();
const showDeprecatedItemsBtn = await screen.findByText(/Show Deprecated Settings/i);
expect(showDeprecatedItemsBtn).toBeInTheDocument();
await user.click(showDeprecatedItemsBtn);
expect(screen.getByText(/Hide Deprecated Settings/i)).toBeInTheDocument();
expect(screen.getByText('Certificate web/html view enabled')).toBeInTheDocument();
});
it('should reset to default value on click on Cancel button', async () => {
const user = userEvent.setup();
render();
const textarea = await screen.findByLabelText(/Advanced Module List/i);
fireEvent.change(textarea, { target: { value: '[3, 2, 1]' } });
expect(textarea).toHaveValue('[3, 2, 1]');
await user.click(screen.getByText(messages.buttonCancelText.defaultMessage));
expect(textarea).toHaveValue('[]');
});
it('should update the textarea value and display the updated value after clicking "Change manually"', async () => {
const user = userEvent.setup();
render();
const textarea = await screen.findByLabelText(/Advanced Module List/i);
fireEvent.change(textarea, { target: { value: '[3, 2, 1,' } });
fireEvent.blur(textarea);
expect(textarea).toHaveValue('[3, 2, 1,');
await user.click(screen.getByText('Save changes'));
await user.click(await screen.findByText('Change manually'));
expect(textarea).toHaveValue('[3, 2, 1,');
});
it('should show success alert after save', async () => {
const user = userEvent.setup();
render();
const textarea = await screen.findByLabelText(/Advanced Module List/i);
fireEvent.change(textarea, { target: { value: '[3, 2, 1]' } });
expect(textarea).toHaveValue('[3, 2, 1]');
axiosMock
.onPatch(`${getCourseAdvancedSettingsApiUrl(courseId)}`)
.reply(200, {
...advancedSettingsMock,
advancedModules: {
...advancedSettingsMock.advancedModules,
value: [3, 2, 1],
},
});
await user.click(screen.getByText('Save changes'));
expect(screen.getByText('Your policy changes have been saved.')).toBeInTheDocument();
});
it('should show error modal on save failure', async () => {
const user = userEvent.setup();
render();
const textarea = await screen.findByLabelText(/Advanced Module List/i);
fireEvent.change(textarea, { target: { value: '[3, 2, 1]' } });
axiosMock
.onPatch(`${getCourseAdvancedSettingsApiUrl(courseId)}`)
.reply(500);
await user.click(screen.getByText('Save changes'));
expect(await screen.findByText('Validation error while saving')).toBeInTheDocument();
});
it('should render without errors when authz.enable_course_authoring flag is enabled and the user is authorized', async () => {
mockWaffleFlags({ enableAuthzCourseAuthoring: true });
jest.mocked(useUserPermissions).mockReturnValue({
isLoading: false,
data: { canManageAdvancedSettings: true },
} as unknown as ReturnType<typeof useUserPermissions>);
render();
expect(await screen.findByText(messages.headingSubtitle.defaultMessage)).toBeInTheDocument();
expect(screen.getByText(messages.headingTitle.defaultMessage, {
selector: 'h2.sub-header-title',
})).toBeInTheDocument();
expect(screen.getByText(messages.policy.defaultMessage)).toBeInTheDocument();
expect(screen.getByText(/Do not modify these policies unless you are familiar with their purpose./i)).toBeInTheDocument();
});
it('should show permission alert when authz.enable_course_authoring flag is enabled and the user is not authorized', async () => {
mockWaffleFlags({ enableAuthzCourseAuthoring: true });
jest.mocked(useUserPermissions).mockReturnValue({
isLoading: false,
data: { canManageAdvancedSettings: false },
} as unknown as ReturnType<typeof useUserPermissions>);
render();
expect(await screen.findByTestId('permissionDeniedAlert')).toBeInTheDocument();
});
});

View File

@@ -1,4 +1,4 @@
export default {
module.exports = {
advancedModules: {
deprecated: false,
displayName: 'Advanced Module List',

View File

@@ -1,10 +1,11 @@
/* eslint-disable import/prefer-default-export */
import {
camelCaseObject,
getConfig,
} from '@edx/frontend-platform';
import { getAuthenticatedHttpClient } from '@edx/frontend-platform/auth';
import { camelCase } from 'lodash';
import { convertObjectToSnakeCase } from '@src/utils';
import { convertObjectToSnakeCase } from '../../utils';
const getApiBaseUrl = () => getConfig().STUDIO_BASE_URL;
export const getCourseAdvancedSettingsApiUrl = (courseId) => `${getApiBaseUrl()}/api/contentstore/v0/advanced_settings/${courseId}`;
@@ -12,8 +13,10 @@ const getProctoringErrorsApiUrl = () => `${getApiBaseUrl()}/api/contentstore/v1/
/**
* Get's advanced setting for a course.
* @param {string} courseId
* @returns {Promise<Object>}
*/
export async function getCourseAdvancedSettings(courseId: string): Promise<Record<string, any>> {
export async function getCourseAdvancedSettings(courseId) {
const { data } = await getAuthenticatedHttpClient()
.get(`${getCourseAdvancedSettingsApiUrl(courseId)}?fetch_all=0`);
const keepValues = {};
@@ -33,11 +36,11 @@ export async function getCourseAdvancedSettings(courseId: string): Promise<Recor
/**
* Updates advanced setting for a course.
* @param {string} courseId
* @param {object} settings
* @returns {Promise<Object>}
*/
export async function updateCourseAdvancedSettings(
courseId: string,
settings: Record<string, any>,
): Promise<Record<string, any>> {
export async function updateCourseAdvancedSettings(courseId, settings) {
const { data } = await getAuthenticatedHttpClient()
.patch(`${getCourseAdvancedSettingsApiUrl(courseId)}`, convertObjectToSnakeCase(settings));
const keepValues = {};
@@ -57,8 +60,10 @@ export async function updateCourseAdvancedSettings(
/**
* Gets proctoring exam errors.
* @param {string} courseId
* @returns {Promise<Object>}
*/
export async function getProctoringExamErrors(courseId: string): Promise<Record<string, any>> {
export async function getProctoringExamErrors(courseId) {
const { data } = await getAuthenticatedHttpClient().get(`${getProctoringErrorsApiUrl()}${courseId}`);
const keepValues = {};
Object.keys(data).forEach((key) => {
@@ -72,6 +77,5 @@ export async function getProctoringExamErrors(courseId: string): Promise<Record<
value: keepValues[key]?.value,
};
});
return formattedData;
}

View File

@@ -1,56 +0,0 @@
/* eslint-disable import/no-extraneous-dependencies */
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query';
import { AxiosError } from 'axios';
import {
getCourseAdvancedSettings,
getProctoringExamErrors,
updateCourseAdvancedSettings,
} from './api';
export const advancedSettingsQueryKeys = {
all: ['advancedSettings'],
/** Base key for advanced settings specific to a courseId */
courseAdvancedSettings: (courseId: string) => [...advancedSettingsQueryKeys.all, courseId],
/** Key for proctoring exam errors specific to a courseId */
proctoringExamErrors: (courseId: string) => [...advancedSettingsQueryKeys.all, courseId, 'proctoringErrors'],
};
const sortSettingsByDisplayName = (settings: Record<string, any>): Record<string, any> => (
Object.fromEntries(Object.entries(settings).sort(
([, v1], [, v2]) => v1.displayName.localeCompare(v2.displayName),
))
);
/**
* Fetches the advanced settings for a course, sorted alphabetically by display name.
*/
export const useCourseAdvancedSettings = (courseId: string) => (
useQuery<Record<string, any>, AxiosError>({
queryKey: advancedSettingsQueryKeys.courseAdvancedSettings(courseId),
queryFn: () => getCourseAdvancedSettings(courseId),
select: sortSettingsByDisplayName,
})
);
/**
* Fetches the proctoring exam errors for a course.
*/
export const useProctoringExamErrors = (courseId: string) => (
useQuery({
queryKey: advancedSettingsQueryKeys.proctoringExamErrors(courseId),
queryFn: () => getProctoringExamErrors(courseId),
})
);
/**
* Returns a mutation to update the advanced settings for a course.
*/
export const useUpdateCourseAdvancedSettings = (courseId: string) => {
const queryClient = useQueryClient();
return useMutation<Record<string, any>, AxiosError, Record<string, any>>({
mutationFn: (settings: Record<string, any>) => updateCourseAdvancedSettings(courseId, settings),
onSettled: () => {
queryClient.invalidateQueries({ queryKey: advancedSettingsQueryKeys.courseAdvancedSettings(courseId) });
},
});
};

View File

@@ -0,0 +1,5 @@
export const getLoadingStatus = (state) => state.advancedSettings.loadingStatus;
export const getCourseAppSettings = state => state.advancedSettings.courseAppSettings;
export const getSavingStatus = (state) => state.advancedSettings.savingStatus;
export const getProctoringExamErrors = (state) => state.advancedSettings.proctoringErrors;
export const getSendRequestErrors = (state) => state.advancedSettings.sendRequestErrors.developer_message;

View File

@@ -0,0 +1,48 @@
/* eslint-disable no-param-reassign */
import { createSlice } from '@reduxjs/toolkit';
import { RequestStatus } from '../../data/constants';
const slice = createSlice({
name: 'advancedSettings',
initialState: {
loadingStatus: RequestStatus.IN_PROGRESS,
savingStatus: '',
courseAppSettings: {},
proctoringErrors: {},
sendRequestErrors: {},
},
reducers: {
updateLoadingStatus: (state, { payload }) => {
state.loadingStatus = payload.status;
},
updateSavingStatus: (state, { payload }) => {
state.savingStatus = payload.status;
},
fetchCourseAppsSettingsSuccess: (state, { payload }) => {
Object.assign(state.courseAppSettings, payload);
},
updateCourseAppsSettingsSuccess: (state, { payload }) => {
Object.assign(state.courseAppSettings, payload);
},
getDataSendErrors: (state, { payload }) => {
Object.assign(state.sendRequestErrors, payload);
},
fetchProctoringExamErrorsSuccess: (state, { payload }) => {
Object.assign(state.proctoringErrors, payload);
},
},
});
export const {
updateLoadingStatus,
updateSavingStatus,
getDataSendErrors,
fetchCourseAppsSettingsSuccess,
updateCourseAppsSettingsSuccess,
fetchProctoringExamErrorsSuccess,
} = slice.actions;
export const {
reducer,
} = slice;

View File

@@ -0,0 +1,85 @@
import { RequestStatus } from '../../data/constants';
import {
getCourseAdvancedSettings,
updateCourseAdvancedSettings,
getProctoringExamErrors,
} from './api';
import {
fetchCourseAppsSettingsSuccess,
updateCourseAppsSettingsSuccess,
updateLoadingStatus,
updateSavingStatus,
fetchProctoringExamErrorsSuccess,
getDataSendErrors,
} from './slice';
export function fetchCourseAppSettings(courseId) {
return async (dispatch) => {
dispatch(updateLoadingStatus({ status: RequestStatus.IN_PROGRESS }));
try {
const settingValues = await getCourseAdvancedSettings(courseId);
const sortedDisplayName = [];
Object.values(settingValues).forEach(value => {
const { displayName } = value;
sortedDisplayName.push(displayName);
});
const sortedSettingValues = {};
sortedDisplayName.sort().forEach((displayName => {
Object.entries(settingValues).forEach(([key, value]) => {
if (value.displayName === displayName) {
sortedSettingValues[key] = value;
}
});
}));
dispatch(fetchCourseAppsSettingsSuccess(sortedSettingValues));
dispatch(updateLoadingStatus({ status: RequestStatus.SUCCESSFUL }));
} catch (error) {
if (error.response && error.response.status === 403) {
dispatch(updateLoadingStatus({ courseId, status: RequestStatus.DENIED }));
} else {
dispatch(updateLoadingStatus({ courseId, status: RequestStatus.FAILED }));
}
}
};
}
export function updateCourseAppSetting(courseId, settings) {
return async (dispatch) => {
dispatch(updateSavingStatus({ status: RequestStatus.IN_PROGRESS }));
try {
const settingValues = await updateCourseAdvancedSettings(courseId, settings);
dispatch(updateCourseAppsSettingsSuccess(settingValues));
dispatch(updateSavingStatus({ status: RequestStatus.SUCCESSFUL }));
return true;
} catch (error) {
let errorData;
try {
const { customAttributes: { httpErrorResponseData } } = error;
errorData = JSON.parse(httpErrorResponseData);
} catch {
errorData = {};
}
dispatch(getDataSendErrors(errorData));
dispatch(updateSavingStatus({ status: RequestStatus.FAILED }));
return false;
}
};
}
export function fetchProctoringExamErrors(courseId) {
return async (dispatch) => {
dispatch(updateSavingStatus({ status: RequestStatus.IN_PROGRESS }));
try {
const settingValues = await getProctoringExamErrors(courseId);
dispatch(fetchProctoringExamErrorsSuccess(settingValues));
return true;
} catch {
dispatch(updateSavingStatus({ status: RequestStatus.FAILED }));
return false;
}
};
}

View File

@@ -1,20 +0,0 @@
export const CONTENT_LIBRARY_PERMISSIONS = {
DELETE_LIBRARY: 'content_libraries.delete_library',
MANAGE_LIBRARY_TAGS: 'content_libraries.manage_library_tags',
VIEW_LIBRARY: 'content_libraries.view_library',
EDIT_LIBRARY_CONTENT: 'content_libraries.edit_library_content',
PUBLISH_LIBRARY_CONTENT: 'content_libraries.publish_library_content',
REUSE_LIBRARY_CONTENT: 'content_libraries.reuse_library_content',
CREATE_LIBRARY_COLLECTION: 'content_libraries.create_library_collection',
EDIT_LIBRARY_COLLECTION: 'content_libraries.edit_library_collection',
DELETE_LIBRARY_COLLECTION: 'content_libraries.delete_library_collection',
MANAGE_LIBRARY_TEAM: 'content_libraries.manage_library_team',
VIEW_LIBRARY_TEAM: 'content_libraries.view_library_team',
};
export const COURSE_PERMISSIONS = {
MANAGE_ADVANCED_SETTINGS: 'courses.manage_advanced_settings',
};

View File

@@ -1,41 +0,0 @@
import { getAuthenticatedHttpClient } from '@edx/frontend-platform/auth';
import {
PermissionValidationAnswer,
PermissionValidationQuery,
PermissionValidationRequestItem,
PermissionValidationResponseItem,
} from '@src/authz/types';
import { getApiUrl } from './utils';
export const validateUserPermissions = async (
query: PermissionValidationQuery,
): Promise<PermissionValidationAnswer> => {
// Convert the validations query object into an array for the API request
const request: PermissionValidationRequestItem[] = Object.values(query);
const { data }: { data: PermissionValidationResponseItem[] } = await getAuthenticatedHttpClient().post(
getApiUrl('/api/authz/v1/permissions/validate/me'),
request,
);
// Convert the API response back into the expected answer format
const result: PermissionValidationAnswer = {};
data.forEach((item: { action: string; scope?: string; allowed: boolean }) => {
const key = Object.keys(query).find(
(k) => query[k].action === item.action
&& query[k].scope === item.scope,
);
if (key) {
result[key] = item.allowed;
}
});
// Fill any missing keys with false
Object.keys(query).forEach((key) => {
if (!(key in result)) {
result[key] = false;
}
});
return result;
};

View File

@@ -1,168 +0,0 @@
import { act, ReactNode } from 'react';
import { renderHook, waitFor } from '@testing-library/react';
import { QueryClient, QueryClientProvider } from '@tanstack/react-query';
import { getAuthenticatedHttpClient } from '@edx/frontend-platform/auth';
import { useUserPermissions } from './apiHooks';
jest.mock('@edx/frontend-platform/auth', () => ({
getAuthenticatedHttpClient: jest.fn(),
}));
const createWrapper = () => {
const queryClient = new QueryClient({
defaultOptions: {
queries: {
retry: false,
},
},
});
const wrapper = ({ children }: { children: ReactNode }) => (
<QueryClientProvider client={queryClient}>{children}</QueryClientProvider>
);
return wrapper;
};
const singlePermission = {
canRead: {
action: 'example.read',
scope: 'lib:example-org:test-lib',
},
};
const mockValidSinglePermission = [
{ action: 'example.read', scope: 'lib:example-org:test-lib', allowed: true },
];
const mockInvalidSinglePermission = [
{ action: 'example.read', scope: 'lib:example-org:test-lib', allowed: false },
];
const mockEmptyPermissions = [
// No permissions returned
];
const multiplePermissions = {
canRead: {
action: 'example.read',
scope: 'lib:example-org:test-lib',
},
canWrite: {
action: 'example.write',
scope: 'lib:example-org:test-lib',
},
};
const mockValidMultiplePermissions = [
{ action: 'example.read', scope: 'lib:example-org:test-lib', allowed: true },
{ action: 'example.write', scope: 'lib:example-org:test-lib', allowed: true },
];
const mockInvalidMultiplePermissions = [
{ action: 'example.read', scope: 'lib:example-org:test-lib', allowed: false },
{ action: 'example.write', scope: 'lib:example-org:test-lib', allowed: false },
];
describe('useUserPermissions', () => {
beforeEach(() => {
jest.clearAllMocks();
});
it('returns allowed true when permission is valid', async () => {
getAuthenticatedHttpClient.mockReturnValue({
post: jest.fn().mockResolvedValueOnce({ data: mockValidSinglePermission }),
});
const { result } = renderHook(() => useUserPermissions(singlePermission), {
wrapper: createWrapper(),
});
await waitFor(() => expect(result.current).toBeDefined());
await waitFor(() => expect(result.current.data).toBeDefined());
expect(getAuthenticatedHttpClient).toHaveBeenCalled();
expect(result.current.data!.canRead).toBe(true);
});
it('returns allowed false when permission is invalid', async () => {
getAuthenticatedHttpClient.mockReturnValue({
post: jest.fn().mockResolvedValue({ data: mockInvalidSinglePermission }),
});
const { result } = renderHook(() => useUserPermissions(singlePermission), {
wrapper: createWrapper(),
});
await waitFor(() => expect(result.current).toBeDefined());
await waitFor(() => expect(result.current.data).toBeDefined());
expect(getAuthenticatedHttpClient).toHaveBeenCalled();
expect(result.current.data!.canRead).toBe(false);
});
it('returns allowed true when multiple permissions are valid', async () => {
getAuthenticatedHttpClient.mockReturnValue({
post: jest.fn().mockResolvedValueOnce({ data: mockValidMultiplePermissions }),
});
const { result } = renderHook(() => useUserPermissions(multiplePermissions), {
wrapper: createWrapper(),
});
await waitFor(() => expect(result.current).toBeDefined());
await waitFor(() => expect(result.current.data).toBeDefined());
expect(getAuthenticatedHttpClient).toHaveBeenCalled();
expect(result.current.data!.canRead).toBe(true);
expect(result.current.data!.canWrite).toBe(true);
});
it('returns allowed false when multiple permissions are invalid', async () => {
getAuthenticatedHttpClient.mockReturnValue({
post: jest.fn().mockResolvedValue({ data: mockInvalidMultiplePermissions }),
});
const { result } = renderHook(() => useUserPermissions(multiplePermissions), {
wrapper: createWrapper(),
});
await waitFor(() => expect(result.current).toBeDefined());
await waitFor(() => expect(result.current.data).toBeDefined());
expect(getAuthenticatedHttpClient).toHaveBeenCalled();
expect(result.current.data!.canRead).toBe(false);
expect(result.current.data!.canWrite).toBe(false);
});
it('returns allowed false when the permission is not included in the server response', async () => {
getAuthenticatedHttpClient.mockReturnValue({
post: jest.fn().mockResolvedValue({ data: mockEmptyPermissions }),
});
const { result } = renderHook(() => useUserPermissions(singlePermission), {
wrapper: createWrapper(),
});
await waitFor(() => expect(result.current).toBeDefined());
await waitFor(() => expect(result.current.data).toBeDefined());
expect(getAuthenticatedHttpClient).toHaveBeenCalled();
expect(result.current.data!.canRead).toBe(false);
});
it('handles error when the API call fails', async () => {
const mockError = new Error('API Error');
getAuthenticatedHttpClient.mockReturnValue({
post: jest.fn().mockRejectedValue(new Error('API Error')),
});
try {
act(() => {
renderHook(() => useUserPermissions(singlePermission), {
wrapper: createWrapper(),
});
});
} catch (error) {
expect(error).toEqual(mockError); // Check for the expected error
}
});
});

View File

@@ -1,37 +0,0 @@
import { skipToken, useQuery } from '@tanstack/react-query';
import { PermissionValidationAnswer, PermissionValidationQuery } from '@src/authz/types';
import { validateUserPermissions } from './api';
const adminConsoleQueryKeys = {
all: ['authz'],
permissions: (permissions: PermissionValidationQuery) => [...adminConsoleQueryKeys.all, 'validatePermissions', permissions] as const,
};
/**
* React Query hook to validate if the current user has permissions over a certain object in the instance.
* It helps to:
* - Determine whether the current user can access certain object.
* - Provide role-based rendering logic for UI components.
*
* @param permissions - A key/value map of objects and actions to validate.
* The key is an arbitrary string to identify the permission check,
* and the value is an object containing the action and optional scope.
*
* @example
* const { isLoading, data } = useUserPermissions({
* canRead: {
* action: "content_libraries.view_library",
* scope: "lib:OpenedX:CSPROB"
* }
* });
* if (data.canRead) { ... }
*
*/
export const useUserPermissions = (
permissions: PermissionValidationQuery,
enabled: boolean = true,
) => useQuery<PermissionValidationAnswer, Error>({
queryKey: adminConsoleQueryKeys.permissions(permissions),
queryFn: enabled ? () => validateUserPermissions(permissions) : skipToken,
retry: false,
});

View File

@@ -1,4 +0,0 @@
import { getConfig } from '@edx/frontend-platform';
export const getApiUrl = (path: string) => `${getConfig().LMS_BASE_URL}${path || ''}`;
export const getStudioApiUrl = (path: string) => `${getConfig().STUDIO_BASE_URL}${path || ''}`;

View File

@@ -1,16 +0,0 @@
export interface PermissionValidationRequestItem {
action: string;
scope?: string;
}
export interface PermissionValidationResponseItem extends PermissionValidationRequestItem {
allowed: boolean;
}
export interface PermissionValidationQuery {
[permissionKey: string]: PermissionValidationRequestItem;
}
export interface PermissionValidationAnswer {
[permissionKey: string]: boolean;
}

View File

@@ -1,6 +1,6 @@
import { Helmet } from 'react-helmet';
import PropTypes from 'prop-types';
import { useCourseAuthoringContext } from '@src/CourseAuthoringContext';
import Placeholder from '../editors/Placeholder';
import { RequestStatus } from '../data/constants';
import Loading from '../generic/Loading';
@@ -21,8 +21,7 @@ const MODE_COMPONENTS = {
[MODE_STATES.editAll]: CertificateEditForm,
};
const Certificates = () => {
const { courseId } = useCourseAuthoringContext();
const Certificates = ({ courseId }) => {
const {
certificates, componentMode, isLoading, loadingStatus, pageHeadTitle, hasCertificateModes,
} = useCertificates({ courseId });
@@ -51,4 +50,8 @@ const Certificates = () => {
);
};
Certificates.propTypes = {
courseId: PropTypes.string.isRequired,
};
export default Certificates;

View File

@@ -1,7 +1,6 @@
// @ts-check
import userEvent from '@testing-library/user-event';
import { CourseAuthoringProvider } from '@src/CourseAuthoringContext';
import { initializeMocks, render, waitFor } from '../testUtils';
import { RequestStatus } from '../data/constants';
import { executeThunk } from '../utils';
@@ -15,11 +14,7 @@ let axiosMock;
let store;
const courseId = 'course-123';
const renderComponent = (props) => render(
<CourseAuthoringProvider courseId={courseId}>
<Certificates {...props} />
</CourseAuthoringProvider>,
);
const renderComponent = (props) => render(<Certificates courseId={courseId} {...props} />);
describe('Certificates', () => {
beforeEach(async () => {

View File

@@ -1,4 +1,4 @@
export default [
module.exports = [
{
id: 1,
courseTitle: 'Course Title 1',

View File

@@ -1,4 +1,4 @@
export default {
module.exports = {
certificateActivationHandlerUrl: '/certificates/activation/course-v1:org+101+101/',
certificateWebViewUrl: '//certificates/course/course-v1:org+101+101?preview=honor',
certificates: [

View File

@@ -1,4 +1,4 @@
export default [
module.exports = [
{
id: '1', name: 'John Doe', title: 'CEO', organization: 'Company', signatureImagePath: '/path/to/signature1.png',
},

View File

@@ -7,7 +7,7 @@ const CertificateSection = ({
<section {...rest}>
<Stack className="justify-content-between mb-2.5" direction="horizontal">
<h2 className="lead section-title mb-0">{title}</h2>
{actions}
{actions && actions}
</Stack>
<hr className="mt-0 mb-4" />
<div>

View File

@@ -25,7 +25,6 @@ const useCertificatesList = (courseId) => {
}));
const handleSubmit = async (values) => {
// oxlint-disable-next-line @typescript-eslint/await-thenable - this dispatch() IS returning a promise.
await dispatch(updateCourseCertificate(courseId, values));
setEditModes({});
dispatch(setMode(MODE_STATES.view));

View File

@@ -5,15 +5,17 @@ import { prepareCertificatePayload } from '../utils';
const getApiBaseUrl = () => getConfig().STUDIO_BASE_URL;
export const getCertificatesApiUrl = (courseId: string) => `${getApiBaseUrl()}/api/contentstore/v1/certificates/${courseId}`;
export const getCertificateApiUrl = (courseId: string) => `${getApiBaseUrl()}/certificates/${courseId}`;
export const getUpdateCertificateApiUrl = (courseId: string, certificateId: number) => `${getCertificateApiUrl(courseId)}/${certificateId}`;
export const getUpdateCertificateActiveStatusApiUrl = (path: string) => `${getApiBaseUrl()}${path}`;
export const getCertificatesApiUrl = (courseId) => `${getApiBaseUrl()}/api/contentstore/v1/certificates/${courseId}`;
export const getCertificateApiUrl = (courseId) => `${getApiBaseUrl()}/certificates/${courseId}`;
export const getUpdateCertificateApiUrl = (courseId, certificateId) => `${getCertificateApiUrl(courseId)}/${certificateId}`;
export const getUpdateCertificateActiveStatusApiUrl = (path) => `${getApiBaseUrl()}${path}`;
/**
* Gets certificates for a course.
* @param {string} courseId
* @returns {Promise<Object>}
*/
export async function getCertificates(courseId: string): Promise<Record<string, any>> {
export async function getCertificates(courseId) {
const { data } = await getAuthenticatedHttpClient()
.get(getCertificatesApiUrl(courseId));
@@ -22,11 +24,12 @@ export async function getCertificates(courseId: string): Promise<Record<string,
/**
* Create course certificate.
* @param {string} courseId
* @param {object} certificatesData
* @returns {Promise<Object>}
*/
export async function createCertificate(
courseId: string,
certificatesData: Record<string, any>,
): Promise<Record<string, any>> {
export async function createCertificate(courseId, certificatesData) {
const { data } = await getAuthenticatedHttpClient()
.post(
getCertificateApiUrl(courseId),
@@ -38,11 +41,11 @@ export async function createCertificate(
/**
* Update course certificate.
* @param {string} courseId
* @param {object} certificateData
* @returns {Promise<Object>}
*/
export async function updateCertificate(
courseId: string,
certificateData: Record<string, any>,
): Promise<Record<string, any>> {
export async function updateCertificate(courseId, certificateData) {
const { data } = await getAuthenticatedHttpClient()
.post(
getUpdateCertificateApiUrl(courseId, certificateData.id),
@@ -54,8 +57,11 @@ export async function updateCertificate(
/**
* Delete course certificate.
* @param {string} courseId
* @param {object} certificateId
* @returns {Promise<Object>}
*/
export async function deleteCertificate(courseId: string, certificateId: number): Promise<Record<string, any>> {
export async function deleteCertificate(courseId, certificateId) {
const { data } = await getAuthenticatedHttpClient()
.delete(
getUpdateCertificateApiUrl(courseId, certificateId),
@@ -65,8 +71,11 @@ export async function deleteCertificate(courseId: string, certificateId: number)
/**
* Activate/deactivate course certificate.
* @param {string} courseId
* @param {object} activationStatus
* @returns {Promise<Object>}
*/
export async function updateActiveStatus(path: string, activationStatus: unknown): Promise<Record<string, any>> {
export async function updateActiveStatus(path, activationStatus) {
const body = {
is_active: activationStatus,
};

View File

@@ -4,7 +4,7 @@ export const MODE_STATES = {
view: 'view',
editAll: 'edit_all',
create: 'create',
} as const;
};
export const ACTIVATION_MESSAGES = {
activating: 'Activating',

View File

@@ -0,0 +1,22 @@
import { createSelector } from '@reduxjs/toolkit';
export const getLoadingStatus = (state) => state.certificates.loadingStatus;
export const getSavingStatus = (state) => state.certificates.savingStatus;
export const getSavingImageStatus = (state) => state.certificates.savingImageStatus;
export const getErrorMessage = (state) => state.certificates.errorMessage;
export const getSendRequestErrors = (state) => state.certificates.sendRequestErrors.developer_message;
export const getCertificates = state => state.certificates.certificatesData.certificates;
export const getHasCertificateModes = state => state.certificates.certificatesData.hasCertificateModes;
export const getCourseModes = state => state.certificates.certificatesData.courseModes;
export const getCertificateActivationUrl = state => state.certificates.certificatesData.certificateActivationHandlerUrl;
export const getCertificateWebViewUrl = state => state.certificates.certificatesData.certificateWebViewUrl;
export const getIsCertificateActive = state => state.certificates.certificatesData.isActive;
export const getComponentMode = state => state.certificates.componentMode;
export const getCourseNumber = state => state.certificates.certificatesData.courseNumber;
export const getCourseNumberOverride = state => state.certificates.certificatesData.courseNumberOverride;
export const getCourseTitle = state => state.certificates.certificatesData.courseTitle;
export const getHasCertificates = createSelector(
[getCertificates],
(certificates) => certificates && certificates.length > 0,
);

View File

@@ -1,25 +0,0 @@
/* eslint-disable max-len */
import { createSelector } from '@reduxjs/toolkit';
import type { DeprecatedReduxState } from '@src/store';
export const getLoadingStatus = (state: DeprecatedReduxState) => state.certificates.loadingStatus;
export const getSavingStatus = (state: DeprecatedReduxState) => state.certificates.savingStatus;
export const getSavingImageStatus = (state: DeprecatedReduxState) => state.certificates.savingImageStatus;
export const getErrorMessage = (state: DeprecatedReduxState) => state.certificates.errorMessage;
// Commenting this one out as it seems to be unused:
// export const getSendRequestErrors = (state: DeprecatedReduxState) => state.certificates.sendRequestErrors.developer_message;
export const getCertificates = (state: DeprecatedReduxState) => state.certificates.certificatesData.certificates;
export const getHasCertificateModes = (state: DeprecatedReduxState) => state.certificates.certificatesData.hasCertificateModes;
export const getCourseModes = (state: DeprecatedReduxState) => state.certificates.certificatesData.courseModes;
export const getCertificateActivationUrl = (state: DeprecatedReduxState) => state.certificates.certificatesData.certificateActivationHandlerUrl;
export const getCertificateWebViewUrl = (state: DeprecatedReduxState) => state.certificates.certificatesData.certificateWebViewUrl;
export const getIsCertificateActive = (state: DeprecatedReduxState) => state.certificates.certificatesData.isActive;
export const getComponentMode = (state: DeprecatedReduxState) => state.certificates.componentMode;
export const getCourseNumber = (state: DeprecatedReduxState) => state.certificates.certificatesData.courseNumber;
export const getCourseNumberOverride = (state: DeprecatedReduxState) => state.certificates.certificatesData.courseNumberOverride;
export const getCourseTitle = (state: DeprecatedReduxState) => state.certificates.certificatesData.courseTitle;
export const getHasCertificates = createSelector(
[getCertificates],
(certificates) => certificates && certificates.length > 0,
);

View File

@@ -7,7 +7,7 @@ import { MODE_STATES } from './constants';
const slice = createSlice({
name: 'certificates',
initialState: {
certificatesData: {} as Record<string, any>,
certificatesData: {},
componentMode: MODE_STATES.noModes,
loadingStatus: RequestStatus.PENDING,
savingStatus: '',

View File

@@ -32,7 +32,7 @@ export function fetchCertificates(courseId) {
dispatch(fetchCertificatesSuccess(certificates));
dispatch(updateLoadingStatus({ status: RequestStatus.SUCCESSFUL }));
} catch (error: any) {
} catch (error) {
if (error.response && error.response.status === 403) {
dispatch(updateLoadingStatus({ courseId, status: RequestStatus.DENIED }));
} else {
@@ -84,8 +84,8 @@ export function deleteCourseCertificate(courseId, certificateId) {
dispatch(showProcessingNotification(NOTIFICATION_MESSAGES.deleting));
try {
await deleteCertificate(courseId, certificateId);
dispatch(deleteCertificateSuccess());
const certificatesValues = await deleteCertificate(courseId, certificateId);
dispatch(deleteCertificateSuccess(certificatesValues));
dispatch(updateSavingStatus({ status: RequestStatus.SUCCESSFUL }));
return true;
} catch (error) {

Some files were not shown because too many files have changed in this diff Show More