diff --git a/plugins/course-apps/ora_settings/Settings.jsx b/plugins/course-apps/ora_settings/Settings.jsx
index b3e3c0d287..f16d10f48b 100644
--- a/plugins/course-apps/ora_settings/Settings.jsx
+++ b/plugins/course-apps/ora_settings/Settings.jsx
@@ -1,69 +1,176 @@
-import React from 'react';
+import { useEffect, useState, useRef } from 'react';
import PropTypes from 'prop-types';
-import * as Yup from 'yup';
-import { injectIntl, intlShape } from '@edx/frontend-platform/i18n';
+import { useIntl } from '@edx/frontend-platform/i18n';
+import { useDispatch, useSelector } from 'react-redux';
-import { Hyperlink } from '@openedx/paragon';
-import { useModel } from 'CourseAuthoring/generic/model-store';
+import {
+ ActionRow, Alert, Badge, Form, Hyperlink, ModalDialog, StatefulButton,
+} from '@openedx/paragon';
+import { Info } from '@openedx/paragon/icons';
+import { updateModel, useModel } from 'CourseAuthoring/generic/model-store';
+import { RequestStatus } from 'CourseAuthoring/data/constants';
import FormSwitchGroup from 'CourseAuthoring/generic/FormSwitchGroup';
-import { useAppSetting } from 'CourseAuthoring/utils';
-import AppSettingsModal from 'CourseAuthoring/pages-and-resources/app-settings-modal/AppSettingsModal';
+import Loading from 'CourseAuthoring/generic/Loading';
+import PermissionDeniedAlert from 'CourseAuthoring/generic/PermissionDeniedAlert';
+import ConnectionErrorAlert from 'CourseAuthoring/generic/ConnectionErrorAlert';
+import { useAppSetting, useIsMobile } from 'CourseAuthoring/utils';
+import { getLoadingStatus, getSavingStatus } from 'CourseAuthoring/pages-and-resources/data/selectors';
+import { updateSavingStatus } from 'CourseAuthoring/pages-and-resources/data/slice';
+
import messages from './messages';
-const ORASettings = ({ intl, onClose }) => {
+const ORASettings = ({ onClose }) => {
+ const dispatch = useDispatch();
+ const { formatMessage } = useIntl();
+ const alertRef = useRef(null);
+ const updateSettingsRequestStatus = useSelector(getSavingStatus);
+ const loadingStatus = useSelector(getLoadingStatus);
+ const isMobile = useIsMobile();
+ const modalVariant = isMobile ? 'dark' : 'default';
const appId = 'ora_settings';
const appInfo = useModel('courseApps', appId);
+
const [enableFlexiblePeerGrade, saveSetting] = useAppSetting(
'forceOnFlexiblePeerOpenassessments',
);
+ const initialFormValues = { enableFlexiblePeerGrade };
+
+ const [formValues, setFormValues] = useState(initialFormValues);
+ const [saveError, setSaveError] = useState(false);
+
+ const submitButtonState = updateSettingsRequestStatus === RequestStatus.IN_PROGRESS ? 'pending' : 'default';
const handleSettingsSave = (values) => saveSetting(values.enableFlexiblePeerGrade);
- const title = (
-
-
{intl.formatMessage(messages.heading)}
-
-
- {intl.formatMessage(messages.ORASettingsHelpLink)}
-
-
-
- );
+ const handleSubmit = async (event) => {
+ let success = true;
+ event.preventDefault();
+
+ success = success && await handleSettingsSave(formValues);
+ await setSaveError(!success);
+ if ((initialFormValues.enableFlexiblePeerGrade !== formValues.enableFlexiblePeerGrade) && success) {
+ success = await dispatch(updateModel({
+ modelType: 'courseApps',
+ model: {
+ id: appId, enabled: formValues.enableFlexiblePeerGrade,
+ },
+ }));
+ }
+ !success && alertRef?.current.scrollIntoView(); // eslint-disable-line @typescript-eslint/no-unused-expressions
+ };
+
+ const handleChange = (e) => {
+ setFormValues({ enableFlexiblePeerGrade: e.target.checked });
+ };
+
+ useEffect(() => {
+ if (updateSettingsRequestStatus === RequestStatus.SUCCESSFUL) {
+ dispatch(updateSavingStatus({ status: '' }));
+ onClose();
+ }
+ }, [updateSettingsRequestStatus]);
+
+ const renderBody = () => {
+ switch (loadingStatus) {
+ case RequestStatus.SUCCESSFUL:
+ return (
+ <>
+ {saveError && (
+
+
+ {formatMessage(messages.errorSavingTitle)}
+
+ {formatMessage(messages.errorSavingMessage)}
+
+ )}
+
+ {formatMessage(messages.enableFlexPeerGradeLabel)}
+ {formValues.enableFlexiblePeerGrade && (
+
+ {formatMessage(messages.enabledBadgeLabel)}
+
+ )}
+
+ )}
+ helpText={(
+
+
{formatMessage(messages.enableFlexPeerGradeHelp)}
+
+
+ {formatMessage(messages.ORASettingsHelpLink)}
+
+
+
+ )}
+ onChange={handleChange}
+ checked={formValues.enableFlexiblePeerGrade}
+ />
+ >
+ );
+ case RequestStatus.DENIED:
+ return ;
+ case RequestStatus.FAILED:
+ return ;
+ default:
+ return ;
+ }
+ };
return (
-
- {({ values, handleChange, handleBlur }) => (
-
- )}
-
+
+
);
};
ORASettings.propTypes = {
- intl: intlShape.isRequired,
onClose: PropTypes.func.isRequired,
};
-export default injectIntl(ORASettings);
+export default ORASettings;
diff --git a/plugins/course-apps/ora_settings/Settings.test.jsx b/plugins/course-apps/ora_settings/Settings.test.jsx
index d74cab9e69..a037e0c438 100644
--- a/plugins/course-apps/ora_settings/Settings.test.jsx
+++ b/plugins/course-apps/ora_settings/Settings.test.jsx
@@ -1,33 +1,152 @@
-import { shallow } from '@edx/react-unit-test-utils';
+import {
+ render,
+ screen,
+ waitFor,
+ within,
+} 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 { getCourseAppsApiUrl, getCourseAdvancedSettingsApiUrl } from 'CourseAuthoring/pages-and-resources/data/api';
+import { fetchCourseApps, fetchCourseAppSettings } from 'CourseAuthoring/pages-and-resources/data/thunks';
import ORASettings from './Settings';
+import messages from './messages';
+import {
+ courseId,
+ inititalState,
+} from './factories/mockData';
+
+let axiosMock;
+let store;
+const oraSettingsUrl = `/course/${courseId}/pages-and-resources/live/settings`;
+
+// Modal creates a portal. Overriding ReactDOM.createPortal allows portals to be tested in jest.
+ReactDOM.createPortal = jest.fn(node => node);
+
+const renderComponent = () => (
+ render(
+
+
+
+
+
+ } />
+
+
+
+
+ ,
+ )
+);
-jest.mock('@edx/frontend-platform/i18n', () => ({
- ...jest.requireActual('@edx/frontend-platform/i18n'), // use actual for all non-hook parts
- injectIntl: (component) => component,
- intlShape: {},
-}));
-jest.mock('yup', () => ({
- boolean: jest.fn().mockReturnValue('Yub.boolean'),
-}));
-jest.mock('CourseAuthoring/generic/model-store', () => ({
- useModel: jest.fn().mockReturnValue({ documentationLinks: { learnMoreConfiguration: 'https://learnmore.test' } }),
-}));
-jest.mock('CourseAuthoring/generic/FormSwitchGroup', () => 'FormSwitchGroup');
-jest.mock('CourseAuthoring/utils', () => ({
- useAppSetting: jest.fn().mockReturnValue(['abitrary value', jest.fn().mockName('saveSetting')]),
-}));
-jest.mock('CourseAuthoring/pages-and-resources/app-settings-modal/AppSettingsModal', () => 'AppSettingsModal');
-
-const props = {
- onClose: jest.fn().mockName('onClose'),
- intl: {
- formatMessage: (message) => message.defaultMessage,
- },
+const mockStore = async ({
+ apiStatus,
+ enabled,
+}) => {
+ const settings = ['forceOnFlexiblePeerOpenassessments'];
+ const fetchCourseAppsUrl = `${getCourseAppsApiUrl()}/${courseId}`;
+ const fetchAdvancedSettingsUrl = `${getCourseAdvancedSettingsApiUrl()}/${courseId}`;
+
+ axiosMock.onGet(fetchCourseAppsUrl).reply(
+ 200,
+ [{
+ allowed_operations: { enable: false, configure: true },
+ description: 'setting',
+ documentation_links: { learnMoreConfiguration: '' },
+ enabled,
+ id: 'ora_settings',
+ name: 'Flexible Peer Grading for ORAs',
+ }],
+ );
+ axiosMock.onGet(fetchAdvancedSettingsUrl).reply(
+ apiStatus,
+ { force_on_flexible_peer_openassessments: { value: enabled } },
+ );
+
+ await executeThunk(fetchCourseApps(courseId), store.dispatch);
+ await executeThunk(fetchCourseAppSettings(courseId, settings), store.dispatch);
};
describe('ORASettings', () => {
- it('should render', () => {
- const wrapper = shallow();
- expect(wrapper.snapshot).toMatchSnapshot();
+ beforeEach(async () => {
+ initializeMockApp({
+ authenticatedUser: {
+ userId: 3,
+ username: 'abc123',
+ administrator: false,
+ roles: [],
+ },
+ });
+ store = initializeStore(inititalState);
+ axiosMock = new MockAdapter(getAuthenticatedHttpClient());
+ });
+
+ it('Flexible peer grading configuration modal is visible', async () => {
+ renderComponent();
+ expect(screen.getByRole('dialog')).toBeVisible();
+ });
+
+ it('Displays "Configure Flexible Peer Grading" heading', async () => {
+ renderComponent();
+ const headingElement = screen.getByText(messages.heading.defaultMessage);
+
+ expect(headingElement).toBeVisible();
+ });
+
+ it('Displays loading component', () => {
+ renderComponent();
+ const loadingElement = screen.getByRole('status');
+
+ expect(within(loadingElement).getByText('Loading...')).toBeInTheDocument();
+ });
+
+ it('Displays Connection Error Alert', async () => {
+ await mockStore({ apiStatus: 404, enabled: true });
+ renderComponent();
+ const errorAlert = screen.getByRole('alert');
+
+ expect(within(errorAlert).getByText('We encountered a technical error when loading this page.', { exact: false })).toBeVisible();
+ });
+
+ it('Displays Permissions Error Alert', async () => {
+ await mockStore({ apiStatus: 403, enabled: true });
+ renderComponent();
+ const errorAlert = screen.getByRole('alert');
+
+ expect(within(errorAlert).getByText('You are not authorized to view this page', { exact: false })).toBeVisible();
+ });
+
+ it('Displays title, helper text and badge when flexible peer grading button is enabled', async () => {
+ renderComponent();
+ await mockStore({ apiStatus: 200, enabled: true });
+
+ waitFor(() => {
+ const label = screen.getByText(messages.enableFlexPeerGradeLabel.defaultMessage);
+ const enableBadge = screen.getByTestId('enable-badge');
+
+ expect(label).toBeVisible();
+
+ expect(enableBadge).toHaveTextContent('Enabled');
+ });
+ });
+
+ it('Displays title, helper text and hides badge when flexible peer grading button is disabled', async () => {
+ renderComponent();
+ await mockStore({ apiStatus: 200, enabled: false });
+
+ const label = screen.getByText(messages.enableFlexPeerGradeLabel.defaultMessage);
+ const enableBadge = screen.queryByTestId('enable-badge');
+
+ expect(label).toBeVisible();
+
+ expect(enableBadge).toBeNull();
});
});
diff --git a/plugins/course-apps/ora_settings/__snapshots__/Settings.test.jsx.snap b/plugins/course-apps/ora_settings/__snapshots__/Settings.test.jsx.snap
deleted file mode 100644
index 676fae11a9..0000000000
--- a/plugins/course-apps/ora_settings/__snapshots__/Settings.test.jsx.snap
+++ /dev/null
@@ -1,47 +0,0 @@
-// Jest Snapshot v1, https://goo.gl/fbAQLP
-
-exports[`ORASettings should render 1`] = `
-
-
- Configure open response assessment
-
-
-
- Learn more about open response assessment settings
-
-
-
- }
- validationSchema={
- {
- "enableFlexiblePeerGrade": "Yub.boolean",
- }
- }
->
- [Function]
-
-`;
diff --git a/plugins/course-apps/ora_settings/factories/mockData.js b/plugins/course-apps/ora_settings/factories/mockData.js
new file mode 100644
index 0000000000..a86ccc6a13
--- /dev/null
+++ b/plugins/course-apps/ora_settings/factories/mockData.js
@@ -0,0 +1,32 @@
+export const courseId = 'course-v1:org+num+run';
+
+export const inititalState = {
+ courseDetail: {
+ courseId,
+ status: 'successful',
+ },
+ pagesAndResources: {
+ courseAppIds: ['ora_settings'],
+ loadingStatus: 'in-progress',
+ savingStatus: '',
+ courseAppsApiStatus: {},
+ courseAppSettings: {},
+ },
+ models: {
+ courseApps: {
+ ora_settings: {
+ id: 'ora_settings',
+ name: 'Flexible Peer Grading',
+ enabled: true,
+ description: 'Enable flexible peer grading',
+ allowedOperations: {
+ enable: false,
+ configure: true,
+ },
+ documentationLinks: {
+ learnMoreConfiguration: '',
+ },
+ },
+ },
+ },
+};
diff --git a/plugins/course-apps/ora_settings/messages.js b/plugins/course-apps/ora_settings/messages.js
index 7b05afa5d4..3b119b5660 100644
--- a/plugins/course-apps/ora_settings/messages.js
+++ b/plugins/course-apps/ora_settings/messages.js
@@ -3,19 +3,51 @@ import { defineMessages } from '@edx/frontend-platform/i18n';
const messages = defineMessages({
heading: {
id: 'course-authoring.pages-resources.ora.heading',
- defaultMessage: 'Configure open response assessment',
+ defaultMessage: 'Configure Flexible Peer Grading',
+ description: 'Title for the modal dialog header',
},
ORASettingsHelpLink: {
id: 'course-authoring.pages-resources.ora.flex-peer-grading.link',
defaultMessage: 'Learn more about open response assessment settings',
+ description: 'Descriptive text for the hyperlink to the docs site',
},
enableFlexPeerGradeLabel: {
id: 'course-authoring.pages-resources.ora.flex-peer-grading.label',
defaultMessage: 'Flex Peer Grading',
+ description: 'Label for form switch',
},
enableFlexPeerGradeHelp: {
id: 'course-authoring.pages-resources.ora.flex-peer-grading.help',
defaultMessage: 'Turn on Flexible Peer Grading for all open response assessments in the course with peer grading.',
+ description: 'Help text describing what happens when the switch is enabled',
+ },
+ enabledBadgeLabel: {
+ id: 'course-authoring.pages-resources.ora.flex-peer-grading.enabled-badge.label',
+ defaultMessage: 'Enabled',
+ description: 'Label for badge that show users that a setting is enabled',
+ },
+ cancelLabel: {
+ id: 'course-authoring.pages-resources.ora.flex-peer-grading.cancel-button.label',
+ defaultMessage: 'Cancel',
+ description: 'Label for button that cancels user changes',
+ },
+ saveLabel: {
+ id: 'course-authoring.pages-resources.ora.flex-peer-grading.save-button.label',
+ defaultMessage: 'Save',
+ description: 'Label for button that saves user changes',
+ },
+ pendingSaveLabel: {
+ id: 'course-authoring.pages-resources.ora.flex-peer-grading.pending-save-button.label',
+ defaultMessage: 'Saving',
+ description: 'Label for button that has pending api save calls',
+ },
+ errorSavingTitle: {
+ id: 'course-authoring.pages-resources.ora.flex-peer-grading.save-error.title',
+ defaultMessage: 'We couldn\'t apply your changes.',
+ },
+ errorSavingMessage: {
+ id: 'course-authoring.pages-resources.ora.flex-peer-grading.save-error.message',
+ defaultMessage: 'Please check your entries and try again.',
},
});
diff --git a/plugins/course-apps/ora_settings/package.json b/plugins/course-apps/ora_settings/package.json
index d6de338820..8cc4bf2243 100644
--- a/plugins/course-apps/ora_settings/package.json
+++ b/plugins/course-apps/ora_settings/package.json
@@ -8,6 +8,7 @@
"@openedx/paragon": "*",
"prop-types": "*",
"react": "*",
+ "react-redux": "*",
"yup": "*"
},
"peerDependenciesMeta": {
diff --git a/src/pages-and-resources/data/api.js b/src/pages-and-resources/data/api.js
index ec865f4fd2..92ddc751b2 100644
--- a/src/pages-and-resources/data/api.js
+++ b/src/pages-and-resources/data/api.js
@@ -1,4 +1,3 @@
-/* eslint-disable import/prefer-default-export */
import { snakeCase } from 'lodash/string';
import { camelCaseObject, ensureConfig, getConfig } from '@edx/frontend-platform';
@@ -9,8 +8,8 @@ ensureConfig([
], 'Course Apps API service');
const getApiBaseUrl = () => getConfig().STUDIO_BASE_URL;
-const getCourseAppsApiUrl = () => `${getApiBaseUrl()}/api/course_apps/v1/apps`;
-const getCourseAdvancedSettingsApiUrl = () => `${getApiBaseUrl()}/api/contentstore/v0/advanced_settings`;
+export const getCourseAppsApiUrl = () => `${getApiBaseUrl()}/api/course_apps/v1/apps`;
+export const getCourseAdvancedSettingsApiUrl = () => `${getApiBaseUrl()}/api/contentstore/v0/advanced_settings`;
/**
* Fetches the course apps installed for provided course
diff --git a/src/pages-and-resources/data/thunks.js b/src/pages-and-resources/data/thunks.js
index 06b45459d7..f6384f6d7c 100644
--- a/src/pages-and-resources/data/thunks.js
+++ b/src/pages-and-resources/data/thunks.js
@@ -78,7 +78,11 @@ export function fetchCourseAppSettings(courseId, settings) {
dispatch(fetchCourseAppsSettingsSuccess(settingValues));
dispatch(updateLoadingStatus({ status: RequestStatus.SUCCESSFUL }));
} catch (error) {
- dispatch(updateLoadingStatus({ status: RequestStatus.FAILED }));
+ if (error.response && error.response.status === 403) {
+ dispatch(updateLoadingStatus({ status: RequestStatus.DENIED }));
+ } else {
+ dispatch(updateLoadingStatus({ status: RequestStatus.FAILED }));
+ }
}
};
}