diff --git a/.env b/.env
index 035b99d22..410a9f750 100644
--- a/.env
+++ b/.env
@@ -3,15 +3,22 @@ ACCESS_TOKEN_COOKIE_NAME=null
BASE_URL=null
CREDENTIALS_BASE_URL=null
CSRF_TOKEN_API_PATH=null
+DISCOVERY_API_BASE_URL=
ECOMMERCE_BASE_URL=null
+FAVICON_URL=''
+FAVICON_URL=null
LANGUAGE_PREFERENCE_COOKIE_NAME=null
LMS_BASE_URL=null
LOGIN_URL=null
+LOGO_TRADEMARK_URL=''
+LOGO_URL=''
+LOGO_WHITE_URL=''
LOGOUT_URL=null
-FAVICON_URL=null
MARKETING_SITE_BASE_URL=null
ORDER_HISTORY_URL=null
+PUBLISHER_BASE_URL=
REFRESH_ACCESS_TOKEN_ENDPOINT=null
SEGMENT_KEY=null
SITE_NAME=null
+SUPPORT_URL=null
USER_INFO_COOKIE_NAME=null
diff --git a/.env.development b/.env.development
index 3634948f0..c2e8d56b2 100644
--- a/.env.development
+++ b/.env.development
@@ -3,17 +3,23 @@ ACCESS_TOKEN_COOKIE_NAME='edx-jwt-cookie-header-payload'
BASE_URL='localhost:2001'
CREDENTIALS_BASE_URL='http://localhost:18150'
CSRF_TOKEN_API_PATH='/csrf/api/v1/token'
+DISCOVERY_API_BASE_URL=
ECOMMERCE_BASE_URL='http://localhost:18130'
+FAVICON_URL='https://edx-cdn.org/v3/default/favicon.ico'
LANGUAGE_PREFERENCE_COOKIE_NAME='openedx-language-preference'
LMS_BASE_URL='http://localhost:18000'
LOGIN_URL='http://localhost:18000/login'
+LOGO_TRADEMARK_URL=https://edx-cdn.org/v3/default/logo-trademark.svg
+LOGO_URL=https://edx-cdn.org/v3/default/logo.svg
+LOGO_WHITE_URL=https://edx-cdn.org/v3/default/logo-white.svg
LOGOUT_URL='http://localhost:18000/logout'
-FAVICON_URL='https://edx-cdn.org/v3/default/favicon.ico'
MARKETING_SITE_BASE_URL='http://localhost:18000'
-STUDIO_BASE_URL='http://localhost:18010'
ORDER_HISTORY_URL='localhost:1996/orders'
PORT=2001
+PUBLISHER_BASE_URL=
REFRESH_ACCESS_TOKEN_ENDPOINT='http://localhost:18000/login_refresh'
SEGMENT_KEY=null
SITE_NAME='edX'
+STUDIO_BASE_URL='http://localhost:18010'
+SUPPORT_URL='https://support.edx.org'
USER_INFO_COOKIE_NAME='edx-user-info'
diff --git a/.env.test b/.env.test
index 1a50e5b85..0e50cf2d7 100644
--- a/.env.test
+++ b/.env.test
@@ -2,17 +2,24 @@ ACCESS_TOKEN_COOKIE_NAME='edx-jwt-cookie-header-payload'
BASE_URL='localhost:2001'
CREDENTIALS_BASE_URL='http://localhost:18150'
CSRF_TOKEN_API_PATH='/csrf/api/v1/token'
+DISCOVERY_API_BASE_URL='http://localhost:18381'
ECOMMERCE_BASE_URL='http://localhost:18130'
+FAVICON_URL='https://edx-cdn.org/v3/default/favicon.ico'
+FAVICON_URL=https://edx-cdn.org/v3/default/favicon.ico
LANGUAGE_PREFERENCE_COOKIE_NAME='openedx-language-preference'
LMS_BASE_URL='http://localhost:18000'
LOGIN_URL='http://localhost:18000/login'
+LOGO_TRADEMARK_URL=https://edx-cdn.org/v3/default/logo-trademark.svg
+LOGO_URL=https://edx-cdn.org/v3/default/logo.svg
+LOGO_WHITE_URL=https://edx-cdn.org/v3/default/logo-white.svg
LOGOUT_URL='http://localhost:18000/logout'
-FAVICON_URL='https://edx-cdn.org/v3/default/favicon.ico'
-STUDIO_BASE_URL='http://localhost:18010'
MARKETING_SITE_BASE_URL='http://localhost:18000'
ORDER_HISTORY_URL='localhost:1996/orders'
PORT=2001
+PUBLISHER_BASE_URL=
REFRESH_ACCESS_TOKEN_ENDPOINT='http://localhost:18000/login_refresh'
SEGMENT_KEY=null
SITE_NAME='edX'
+STUDIO_BASE_URL='http://localhost:18010'
+SUPPORT_URL='https://support.edx.org'
USER_INFO_COOKIE_NAME='edx-user-info'
diff --git a/src/generic/ConnectionErrorAlert.jsx b/src/generic/ConnectionErrorAlert.jsx
new file mode 100644
index 000000000..ee2daf5fc
--- /dev/null
+++ b/src/generic/ConnectionErrorAlert.jsx
@@ -0,0 +1,30 @@
+import React from 'react';
+import { injectIntl, intlShape, FormattedMessage } from '@edx/frontend-platform/i18n';
+import { Alert } from '@edx/paragon';
+import { getConfig } from '@edx/frontend-platform';
+
+import messages from '../messages';
+
+function ConnectionErrorAlert({ intl }) {
+ return (
+
+
+ {intl.formatMessage(messages.supportText)}
+
+ ),
+ }}
+ />
+
+ );
+}
+
+ConnectionErrorAlert.propTypes = {
+ intl: intlShape.isRequired,
+};
+
+export default injectIntl(ConnectionErrorAlert);
diff --git a/src/generic/PermissionDeniedAlert.jsx b/src/generic/PermissionDeniedAlert.jsx
new file mode 100644
index 000000000..3bef2d068
--- /dev/null
+++ b/src/generic/PermissionDeniedAlert.jsx
@@ -0,0 +1,16 @@
+import React from 'react';
+import { FormattedMessage } from '@edx/frontend-platform/i18n';
+import { Alert } from '@edx/paragon';
+
+function PermissionDeniedAlert() {
+ return (
+
+
+
+ );
+}
+
+export default PermissionDeniedAlert;
diff --git a/src/generic/SaveFormConnectionErrorAlert.jsx b/src/generic/SaveFormConnectionErrorAlert.jsx
new file mode 100644
index 000000000..67a0564d5
--- /dev/null
+++ b/src/generic/SaveFormConnectionErrorAlert.jsx
@@ -0,0 +1,30 @@
+import React from 'react';
+import { injectIntl, intlShape, FormattedMessage } from '@edx/frontend-platform/i18n';
+import { Alert } from '@edx/paragon';
+import { getConfig } from '@edx/frontend-platform';
+
+import messages from '../messages';
+
+function SaveFormConnectionErrorAlert({ intl }) {
+ return (
+
+
+ {intl.formatMessage(messages.supportText)}
+
+ ),
+ }}
+ />
+
+ );
+}
+
+SaveFormConnectionErrorAlert.propTypes = {
+ intl: intlShape.isRequired,
+};
+
+export default injectIntl(SaveFormConnectionErrorAlert);
diff --git a/src/index.jsx b/src/index.jsx
index 241dc6902..2a3f91a05 100755
--- a/src/index.jsx
+++ b/src/index.jsx
@@ -2,7 +2,7 @@ import 'core-js/stable';
import 'regenerator-runtime/runtime';
import {
- APP_INIT_ERROR, APP_READY, subscribe, initialize,
+ APP_INIT_ERROR, APP_READY, subscribe, initialize, mergeConfig,
} from '@edx/frontend-platform';
import { AppProvider, ErrorPage } from '@edx/frontend-platform/react';
import React from 'react';
@@ -53,6 +53,13 @@ subscribe(APP_INIT_ERROR, (error) => {
});
initialize({
+ handlers: {
+ config: () => {
+ mergeConfig({
+ SUPPORT_URL: process.env.SUPPORT_URL || null,
+ }, 'CourseAuthoringConfig');
+ },
+ },
messages: [
appMessages,
footerMessages,
diff --git a/src/messages.js b/src/messages.js
new file mode 100644
index 000000000..1620914b5
--- /dev/null
+++ b/src/messages.js
@@ -0,0 +1,15 @@
+import { defineMessages } from '@edx/frontend-platform/i18n';
+
+const messages = defineMessages({
+ connectionError: {
+ id: 'authoring.alert.error.connection',
+ defaultMessage: 'We encountered a technical error when loading this page. This might be a temporary issue, so please try again in a few minutes. If the problem persists, please go to the {supportLink} for help.',
+ description: 'Error message shown to users when there is a connectivity issue with the server.',
+ },
+ supportText: {
+ id: 'authoring.alert.support.text',
+ defaultMessage: 'Support Page',
+ },
+});
+
+export default messages;
diff --git a/src/pages-and-resources/discussions/DiscussionsSettings.jsx b/src/pages-and-resources/discussions/DiscussionsSettings.jsx
index dbc170498..b8e2385ed 100644
--- a/src/pages-and-resources/discussions/DiscussionsSettings.jsx
+++ b/src/pages-and-resources/discussions/DiscussionsSettings.jsx
@@ -5,7 +5,7 @@ import PropTypes from 'prop-types';
import {
useRouteMatch,
} from 'react-router';
-import { useDispatch } from 'react-redux';
+import { useDispatch, useSelector } from 'react-redux';
import { history } from '@edx/frontend-platform';
import { injectIntl, intlShape } from '@edx/frontend-platform/i18n';
@@ -20,6 +20,9 @@ import DiscussionsProvider from './DiscussionsProvider';
import { fetchApps } from './data/thunks';
import AppList from './app-list';
import AppConfigForm from './app-config-form';
+import { DENIED, FAILED } from './data/slice';
+import ConnectionErrorAlert from '../../generic/ConnectionErrorAlert';
+import PermissionDeniedAlert from '../../generic/PermissionDeniedAlert';
const SELECTION_STEP = 'selection';
const SETTINGS_STEP = 'settings';
@@ -27,6 +30,7 @@ const SETTINGS_STEP = 'settings';
function DiscussionsSettings({ courseId, intl }) {
const dispatch = useDispatch();
const { path: pagesAndResourcesPath } = useContext(PagesAndResourcesContext);
+ const { status } = useSelector(state => state.discussions);
useEffect(() => {
dispatch(fetchApps(courseId));
@@ -50,6 +54,32 @@ function DiscussionsSettings({ courseId, intl }) {
history.push(discussionsPath);
}, [discussionsPath]);
+ if (status === FAILED) {
+ return (
+
+
+
+ );
+ }
+
+ if (status === DENIED) {
+ return (
+
+
+
+ );
+ }
+
return (
diff --git a/src/pages-and-resources/discussions/DiscussionsSettings.test.jsx b/src/pages-and-resources/discussions/DiscussionsSettings.test.jsx
index 2ae175767..6a9707a24 100644
--- a/src/pages-and-resources/discussions/DiscussionsSettings.test.jsx
+++ b/src/pages-and-resources/discussions/DiscussionsSettings.test.jsx
@@ -1,14 +1,23 @@
import React from 'react';
import {
+ act,
queryByLabelText,
- queryByTestId, queryByText, render, screen, waitForElementToBeRemoved, act,
+ queryByTestId,
+ queryByText,
+ render,
+ screen,
+ waitForElementToBeRemoved,
+ queryByRole,
+ waitFor,
} from '@testing-library/react';
import userEvent from '@testing-library/user-event';
import { AppProvider, PageRoute } from '@edx/frontend-platform/react';
import { Switch } from 'react-router';
-import { history, initializeMockApp } from '@edx/frontend-platform';
+import {
+ getConfig, history, initializeMockApp, setConfig,
+} from '@edx/frontend-platform';
import MockAdapter from 'axios-mock-adapter';
import { getAuthenticatedHttpClient } from '@edx/frontend-platform/auth';
import DiscussionsSettings from './DiscussionsSettings';
@@ -23,6 +32,26 @@ let axiosMock;
let store;
let container;
+function renderComponent() {
+ const wrapper = render(
+
+
+
+
+
+
+
+
+ ,
+ );
+ container = wrapper.container;
+}
+
describe('DiscussionsSettings', () => {
beforeEach(() => {
initializeMockApp({
@@ -37,141 +66,242 @@ describe('DiscussionsSettings', () => {
store = initializeStore();
axiosMock = new MockAdapter(getAuthenticatedHttpClient());
- axiosMock.onGet(getAppsUrl(courseId)).reply(200, piazzaApiResponse);
-
// Leave the DiscussionsSettings route after the test.
history.push(`/course/${courseId}/pages-and-resources`);
-
- const wrapper = render(
-
-
-
-
-
-
-
-
- ,
- );
- container = wrapper.container;
});
- afterEach(() => {
+ describe('with successful network connections', () => {
+ beforeEach(() => {
+ axiosMock.onGet(getAppsUrl(courseId)).reply(200, piazzaApiResponse);
- });
-
- test('sets selection step from routes', async () => {
- history.push(`/course/${courseId}/pages-and-resources/discussions`);
-
- // This is an important line that ensures the spinner has been removed - and thus our main
- // content has been loaded - prior to proceeding with our expectations.
- await waitForElementToBeRemoved(screen.getByRole('status'));
-
- expect(queryByTestId(container, 'appList')).toBeInTheDocument();
- expect(queryByTestId(container, 'appConfigForm')).not.toBeInTheDocument();
- });
-
- test('sets settings step from routes', async () => {
- history.push(`/course/${courseId}/pages-and-resources/discussions/configure/piazza`);
-
- // This is an important line that ensures the spinner has been removed - and thus our main
- // content has been loaded - prior to proceeding with our expectations.
- await waitForElementToBeRemoved(screen.getByRole('status'));
-
- expect(queryByTestId(container, 'appList')).not.toBeInTheDocument();
- expect(queryByTestId(container, 'appConfigForm')).toBeInTheDocument();
- });
-
- test('successfully advances to settings step for lti', async () => {
- history.push(`/course/${courseId}/pages-and-resources/discussions`);
-
- // This is an important line that ensures the spinner has been removed - and thus our main
- // content has been loaded - prior to proceeding with our expectations.
- await waitForElementToBeRemoved(screen.getByRole('status'));
-
- userEvent.click(queryByLabelText(container, 'Select Piazza'));
- userEvent.click(queryByText(container, 'Next'));
-
- expect(queryByTestId(container, 'appList')).not.toBeInTheDocument();
- expect(queryByTestId(container, 'appConfigForm')).toBeInTheDocument();
- expect(queryByTestId(container, 'ltiConfigForm')).toBeInTheDocument();
- expect(queryByTestId(container, 'legacyConfigForm')).not.toBeInTheDocument();
- });
-
- test('successfully advances to settings step for legacy', async () => {
- history.push(`/course/${courseId}/pages-and-resources/discussions`);
-
- // This is an important line that ensures the spinner has been removed - and thus our main
- // content has been loaded - prior to proceeding with our expectations.
- await waitForElementToBeRemoved(screen.getByRole('status'));
-
- userEvent.click(queryByLabelText(container, 'Select edX Discussions'));
- userEvent.click(queryByText(container, 'Next'));
-
- expect(queryByTestId(container, 'appList')).not.toBeInTheDocument();
- expect(queryByTestId(container, 'appConfigForm')).toBeInTheDocument();
- expect(queryByTestId(container, 'ltiConfigForm')).not.toBeInTheDocument();
- expect(queryByTestId(container, 'legacyConfigForm')).toBeInTheDocument();
- });
-
- test('successfully goes back to first step', async () => {
- history.push(`/course/${courseId}/pages-and-resources/discussions/configure/piazza`);
-
- // This is an important line that ensures the spinner has been removed - and thus our main
- // content has been loaded - prior to proceeding with our expectations.
- await waitForElementToBeRemoved(screen.getByRole('status'));
-
- expect(queryByTestId(container, 'appConfigForm')).toBeInTheDocument();
-
- userEvent.click(queryByText(container, 'Back'));
-
- expect(queryByTestId(container, 'appList')).toBeInTheDocument();
- expect(queryByTestId(container, 'appConfigForm')).not.toBeInTheDocument();
- });
-
- test('successfully closes the modal', async () => {
- history.push(`/course/${courseId}/pages-and-resources/discussions`);
-
- // This is an important line that ensures the spinner has been removed - and thus our main
- // content has been loaded - prior to proceeding with our expectations.
- await waitForElementToBeRemoved(screen.getByRole('status'));
-
- expect(queryByTestId(container, 'appList')).toBeInTheDocument();
-
- userEvent.click(queryByLabelText(container, 'Close'));
-
- expect(queryByTestId(container, 'appList')).not.toBeInTheDocument();
- expect(queryByTestId(container, 'appConfigForm')).not.toBeInTheDocument();
-
- expect(window.location.pathname).toEqual(`/course/${courseId}/pages-and-resources`);
- });
-
- test('successfully submit the modal', async () => {
- history.push(`/course/${courseId}/pages-and-resources/discussions`);
-
- axiosMock.onPost(getAppsUrl(courseId)).reply(200, piazzaApiResponse);
-
- // This is an important line that ensures the spinner has been removed - and thus our main
- // content has been loaded - prior to proceeding with our expectations.
- await waitForElementToBeRemoved(screen.getByRole('status'));
-
- userEvent.click(queryByLabelText(container, 'Select Piazza'));
- userEvent.click(queryByText(container, 'Next'));
- // Apply causes an async action to take place
- act(() => {
- userEvent.click(queryByText(container, 'Apply'));
+ renderComponent();
});
- // This is an important line that ensures the Close button has been removed, which implies that
- // the full screen modal has been closed following our click of Apply. Once this has happened,
- // then it's safe to proceed with our expectations.
- await waitForElementToBeRemoved(screen.queryByLabelText('Close'));
+ test('sets selection step from routes', async () => {
+ history.push(`/course/${courseId}/pages-and-resources/discussions`);
- expect(window.location.pathname).toEqual(`/course/${courseId}/pages-and-resources`);
+ // This is an important line that ensures the spinner has been removed - and thus our main
+ // content has been loaded - prior to proceeding with our expectations.
+ await waitForElementToBeRemoved(screen.getByRole('status'));
+
+ expect(queryByTestId(container, 'appList')).toBeInTheDocument();
+ expect(queryByTestId(container, 'appConfigForm')).not.toBeInTheDocument();
+ });
+
+ test('sets settings step from routes', async () => {
+ history.push(`/course/${courseId}/pages-and-resources/discussions/configure/piazza`);
+
+ // This is an important line that ensures the spinner has been removed - and thus our main
+ // content has been loaded - prior to proceeding with our expectations.
+ await waitForElementToBeRemoved(screen.getByRole('status'));
+
+ expect(queryByTestId(container, 'appList')).not.toBeInTheDocument();
+ expect(queryByTestId(container, 'appConfigForm')).toBeInTheDocument();
+ });
+
+ test('successfully advances to settings step for lti', async () => {
+ history.push(`/course/${courseId}/pages-and-resources/discussions`);
+
+ // This is an important line that ensures the spinner has been removed - and thus our main
+ // content has been loaded - prior to proceeding with our expectations.
+ await waitForElementToBeRemoved(screen.getByRole('status'));
+
+ userEvent.click(queryByLabelText(container, 'Select Piazza'));
+ userEvent.click(queryByText(container, 'Next'));
+
+ expect(queryByTestId(container, 'appList')).not.toBeInTheDocument();
+ expect(queryByTestId(container, 'appConfigForm')).toBeInTheDocument();
+ expect(queryByTestId(container, 'ltiConfigForm')).toBeInTheDocument();
+ expect(queryByTestId(container, 'legacyConfigForm')).not.toBeInTheDocument();
+ });
+
+ test('successfully advances to settings step for legacy', async () => {
+ history.push(`/course/${courseId}/pages-and-resources/discussions`);
+
+ // This is an important line that ensures the spinner has been removed - and thus our main
+ // content has been loaded - prior to proceeding with our expectations.
+ await waitForElementToBeRemoved(screen.getByRole('status'));
+
+ userEvent.click(queryByLabelText(container, 'Select edX Discussions'));
+ userEvent.click(queryByText(container, 'Next'));
+
+ expect(queryByTestId(container, 'appList')).not.toBeInTheDocument();
+ expect(queryByTestId(container, 'appConfigForm')).toBeInTheDocument();
+ expect(queryByTestId(container, 'ltiConfigForm')).not.toBeInTheDocument();
+ expect(queryByTestId(container, 'legacyConfigForm')).toBeInTheDocument();
+ });
+
+ test('successfully goes back to first step', async () => {
+ history.push(`/course/${courseId}/pages-and-resources/discussions/configure/piazza`);
+
+ // This is an important line that ensures the spinner has been removed - and thus our main
+ // content has been loaded - prior to proceeding with our expectations.
+ await waitForElementToBeRemoved(screen.getByRole('status'));
+
+ expect(queryByTestId(container, 'appConfigForm')).toBeInTheDocument();
+
+ userEvent.click(queryByText(container, 'Back'));
+
+ expect(queryByTestId(container, 'appList')).toBeInTheDocument();
+ expect(queryByTestId(container, 'appConfigForm')).not.toBeInTheDocument();
+ });
+
+ test('successfully closes the modal', async () => {
+ history.push(`/course/${courseId}/pages-and-resources/discussions`);
+
+ // This is an important line that ensures the spinner has been removed - and thus our main
+ // content has been loaded - prior to proceeding with our expectations.
+ await waitForElementToBeRemoved(screen.getByRole('status'));
+
+ expect(queryByTestId(container, 'appList')).toBeInTheDocument();
+
+ userEvent.click(queryByLabelText(container, 'Close'));
+
+ expect(queryByTestId(container, 'appList')).not.toBeInTheDocument();
+ expect(queryByTestId(container, 'appConfigForm')).not.toBeInTheDocument();
+
+ expect(window.location.pathname).toEqual(`/course/${courseId}/pages-and-resources`);
+ });
+
+ test('successfully submit the modal', async () => {
+ history.push(`/course/${courseId}/pages-and-resources/discussions`);
+
+ axiosMock.onPost(getAppsUrl(courseId)).reply(200, piazzaApiResponse);
+
+ // This is an important line that ensures the spinner has been removed - and thus our main
+ // content has been loaded - prior to proceeding with our expectations.
+ await waitForElementToBeRemoved(screen.getByRole('status'));
+
+ userEvent.click(queryByLabelText(container, 'Select Piazza'));
+ userEvent.click(queryByText(container, 'Next'));
+ // Apply causes an async action to take place
+ act(() => {
+ userEvent.click(queryByText(container, 'Apply'));
+ });
+
+ // This is an important line that ensures the Close button has been removed, which implies that
+ // the full screen modal has been closed following our click of Apply. Once this has happened,
+ // then it's safe to proceed with our expectations.
+ await waitForElementToBeRemoved(screen.queryByLabelText('Close'));
+
+ expect(window.location.pathname).toEqual(`/course/${courseId}/pages-and-resources`);
+ });
+ });
+
+ describe('with network error fetchApps API requests', () => {
+ beforeEach(() => {
+ // Expedient way of getting SUPPORT_URL into config.
+ setConfig({
+ ...getConfig(),
+ SUPPORT_URL: 'http://support.edx.org',
+ });
+
+ axiosMock.onGet(getAppsUrl(courseId)).networkError();
+
+ renderComponent();
+ });
+
+ test('shows connection error alert', async () => {
+ history.push(`/course/${courseId}/pages-and-resources/discussions`);
+
+ // This is an important line that ensures the spinner has been removed - and thus our main
+ // content has been loaded - prior to proceeding with our expectations.
+ await waitForElementToBeRemoved(screen.getByRole('status'));
+
+ const alert = queryByRole(container, 'alert');
+ expect(alert).toBeInTheDocument();
+ expect(alert.textContent).toEqual(expect.stringContaining('We encountered a technical error when loading this page.'));
+ expect(alert.innerHTML).toEqual(expect.stringContaining(getConfig().SUPPORT_URL));
+ });
+ });
+
+ describe('with network error postAppConfig API requests', () => {
+ beforeEach(() => {
+ // Expedient way of getting SUPPORT_URL into config.
+ setConfig({
+ ...getConfig(),
+ SUPPORT_URL: 'http://support.edx.org',
+ });
+
+ axiosMock.onGet(getAppsUrl(courseId)).reply(200, piazzaApiResponse);
+ axiosMock.onPost(getAppsUrl(courseId)).networkError();
+ renderComponent();
+ });
+
+ test('shows connection error alert at top of form', async () => {
+ history.push(`/course/${courseId}/pages-and-resources/discussions/configure/piazza`);
+
+ // This is an important line that ensures the spinner has been removed - and thus our main
+ // content has been loaded - prior to proceeding with our expectations.
+ await waitForElementToBeRemoved(screen.getByRole('status'));
+
+ // Apply causes an async action to take place
+ act(() => {
+ userEvent.click(queryByText(container, 'Apply'));
+ });
+
+ await waitFor(() => expect(axiosMock.history.post.length).toBe(1));
+
+ expect(queryByTestId(container, 'appConfigForm')).toBeInTheDocument();
+
+ const alert = queryByRole(container, 'alert');
+ expect(alert).toBeInTheDocument();
+ expect(alert.textContent).toEqual(expect.stringContaining('We encountered a technical error when applying changes.'));
+ expect(alert.innerHTML).toEqual(expect.stringContaining(getConfig().SUPPORT_URL));
+ });
+ });
+
+ describe('with permission denied error for fetchApps API requests', () => {
+ beforeEach(() => {
+ axiosMock.onGet(getAppsUrl(courseId)).reply(403);
+
+ renderComponent();
+ });
+
+ test('shows permission denied alert', async () => {
+ history.push(`/course/${courseId}/pages-and-resources/discussions`);
+
+ // This is an important line that ensures the spinner has been removed - and thus our main
+ // content has been loaded - prior to proceeding with our expectations.
+ await waitForElementToBeRemoved(screen.getByRole('status'));
+
+ const alert = queryByRole(container, 'alert');
+ expect(alert).toBeInTheDocument();
+ expect(alert.textContent).toEqual(expect.stringContaining('You are not authorized to view this page.'));
+ });
+ });
+
+ describe('with permission denied error for postAppConfig API requests', () => {
+ beforeEach(() => {
+ axiosMock.onGet(getAppsUrl(courseId)).reply(200, piazzaApiResponse);
+ axiosMock.onPost(getAppsUrl(courseId)).reply(403);
+
+ renderComponent();
+ });
+
+ test('shows permission denied alert at top of form', async () => {
+ history.push(`/course/${courseId}/pages-and-resources/discussions/configure/piazza`);
+
+ // This is an important line that ensures the spinner has been removed - and thus our main
+ // content has been loaded - prior to proceeding with our expectations.
+ await waitForElementToBeRemoved(screen.getByRole('status'));
+
+ // Apply causes an async action to take place
+ act(() => {
+ userEvent.click(queryByText(container, 'Apply'));
+ });
+
+ await waitFor(() => expect(axiosMock.history.post.length).toBe(1));
+
+ expect(queryByTestId(container, 'appList')).not.toBeInTheDocument();
+ expect(queryByTestId(container, 'appConfigForm')).not.toBeInTheDocument();
+
+ // We don't technically leave the route in this case, though the modal is hidden.
+ expect(window.location.pathname).toEqual(`/course/${courseId}/pages-and-resources/discussions/configure/piazza`);
+
+ const alert = queryByRole(container, 'alert');
+ expect(alert).toBeInTheDocument();
+ expect(alert.textContent).toEqual(expect.stringContaining('You are not authorized to view this page.'));
+ });
});
});
diff --git a/src/pages-and-resources/discussions/app-config-form/AppConfigForm.jsx b/src/pages-and-resources/discussions/app-config-form/AppConfigForm.jsx
index 1bdce34ab..fcba84aea 100644
--- a/src/pages-and-resources/discussions/app-config-form/AppConfigForm.jsx
+++ b/src/pages-and-resources/discussions/app-config-form/AppConfigForm.jsx
@@ -5,12 +5,13 @@ import PropTypes from 'prop-types';
import { useDispatch, useSelector } from 'react-redux';
import { useRouteMatch } from 'react-router';
import { injectIntl, intlShape } from '@edx/frontend-platform/i18n';
-import { history } from '@edx/frontend-platform';
import { Container } from '@edx/paragon';
import { useModel } from '../../../generic/model-store';
import { PagesAndResourcesContext } from '../../PagesAndResourcesProvider';
-import { LOADED, LOADING, selectApp } from '../data/slice';
+import {
+ FAILED, LOADED, LOADING, selectApp,
+} from '../data/slice';
import { saveAppConfig } from '../data/thunks';
import messages from './messages';
@@ -19,6 +20,7 @@ import AppConfigFormApplyButton from './AppConfigFormApplyButton';
import LegacyConfigForm from './apps/legacy';
import LtiConfigForm from './apps/lti';
import Loading from '../../../generic/Loading';
+import SaveFormConnectionErrorAlert from '../../../generic/SaveFormConnectionErrorAlert';
function AppConfigForm({
courseId, intl,
@@ -27,7 +29,7 @@ function AppConfigForm({
const { formRef } = useContext(AppConfigFormContext);
const { path: pagesAndResourcesPath } = useContext(PagesAndResourcesContext);
const { params: { appId: routeAppId } } = useRouteMatch();
- const { selectedAppId, status } = useSelector(state => state.discussions);
+ const { selectedAppId, status, saveStatus } = useSelector(state => state.discussions);
const app = useModel('apps', selectedAppId);
// appConfigs have no ID of their own, so we use the active app ID to reference them.
// This appConfig may come back as null if the selectedAppId is not the activeAppId, i.e.,
@@ -44,9 +46,8 @@ function AppConfigForm({
// This is a callback that gets called after the form has been submitted successfully.
const handleSubmit = useCallback((values) => {
- dispatch(saveAppConfig(courseId, selectedAppId, values)).then(() => {
- history.push(pagesAndResourcesPath);
- });
+ // Note that when this action succeeds, we redirect to pagesAndResurcesPath in the thunk.
+ dispatch(saveAppConfig(courseId, selectedAppId, values, pagesAndResourcesPath));
}, [courseId, selectedAppId, courseId]);
if (!selectedAppId || status === LOADING) {
@@ -55,6 +56,13 @@ function AppConfigForm({
);
}
+ let alert = null;
+ if (saveStatus === FAILED) {
+ alert = (
+
+ );
+ }
+
let form = null;
if (app.id === 'legacy') {
form = (
@@ -78,6 +86,7 @@ function AppConfigForm({
}
return (
+ {alert}
{form}
);
diff --git a/src/pages-and-resources/discussions/data/redux.test.js b/src/pages-and-resources/discussions/data/redux.test.js
index 5ab146d18..4fd624d9d 100644
--- a/src/pages-and-resources/discussions/data/redux.test.js
+++ b/src/pages-and-resources/discussions/data/redux.test.js
@@ -1,9 +1,12 @@
import { getAuthenticatedHttpClient } from '@edx/frontend-platform/auth';
import MockAdapter from 'axios-mock-adapter';
import { initializeMockApp } from '@edx/frontend-platform/testing';
+import { history } from '@edx/frontend-platform';
import initializeStore from '../../../store';
import { getAppsUrl } from './api';
-import { FAILED, SAVED, selectApp } from './slice';
+import {
+ FAILED, SAVED, DENIED, selectApp,
+} from './slice';
import { fetchApps, saveAppConfig } from './thunks';
import { LOADED } from '../../../data/slice';
import { legacyApiResponse, piazzaApiResponse } from '../factories/mockApiResponses';
@@ -16,7 +19,7 @@ const executeThunk = async (thunk, dispatch, getState) => {
};
const courseId = 'course-v1:edX+TestX+Test_Course';
-
+const pagesAndResourcesPath = `/course/${courseId}/pages-and-resources`;
const featuresState = {
'discussion-page': {
id: 'discussion-page',
@@ -103,6 +106,23 @@ describe('Data layer integration tests', () => {
);
});
+ test('permission denied error', async () => {
+ axiosMock.onGet(getAppsUrl(courseId)).reply(403);
+
+ await executeThunk(fetchApps(courseId), store.dispatch);
+
+ expect(store.getState().discussions).toEqual(
+ expect.objectContaining({
+ appIds: [],
+ featureIds: [],
+ activeAppId: null,
+ selectedAppId: null,
+ status: DENIED,
+ saveStatus: SAVED,
+ }),
+ );
+ });
+
test('successfully loads an LTI configuration', async () => {
axiosMock.onGet(getAppsUrl(courseId)).reply(200, piazzaApiResponse);
@@ -170,14 +190,18 @@ describe('Data layer integration tests', () => {
describe('saveAppConfig', () => {
test('network error', async () => {
+ history.push(`/course/${courseId}/pages-and-resources/discussions/configure/piazza`);
+
axiosMock.onGet(getAppsUrl(courseId)).reply(200, piazzaApiResponse);
axiosMock.onPost(getAppsUrl(courseId)).networkError();
// We call fetchApps and selectApp here too just to get us into a real state.
await executeThunk(fetchApps(courseId), store.dispatch);
store.dispatch(selectApp({ appId: 'piazza' }));
- await executeThunk(saveAppConfig(courseId, 'piazza', {}), store.dispatch);
+ await executeThunk(saveAppConfig(courseId, 'piazza', {}, pagesAndResourcesPath), store.dispatch);
+ // Assert we're still on the form.
+ expect(window.location.pathname).toEqual(`/course/${courseId}/pages-and-resources/discussions/configure/piazza`);
expect(store.getState().discussions).toEqual(
expect.objectContaining({
appIds: ['legacy', 'piazza'],
@@ -190,7 +214,34 @@ describe('Data layer integration tests', () => {
);
});
+ test('permission denied error', async () => {
+ history.push(`/course/${courseId}/pages-and-resources/discussions/configure/piazza`);
+
+ axiosMock.onGet(getAppsUrl(courseId)).reply(200, piazzaApiResponse);
+ axiosMock.onPost(getAppsUrl(courseId)).reply(403);
+
+ // We call fetchApps and selectApp here too just to get us into a real state.
+ await executeThunk(fetchApps(courseId), store.dispatch);
+ store.dispatch(selectApp({ appId: 'piazza' }));
+ await executeThunk(saveAppConfig(courseId, 'piazza', {}, pagesAndResourcesPath), store.dispatch);
+
+ // Assert we're still on the form.
+ expect(window.location.pathname).toEqual(`/course/${courseId}/pages-and-resources/discussions/configure/piazza`);
+ expect(store.getState().discussions).toEqual(
+ expect.objectContaining({
+ appIds: ['legacy', 'piazza'],
+ featureIds,
+ activeAppId: 'piazza',
+ selectedAppId: 'piazza',
+ status: DENIED, // We set BOTH statuses to DENIED for saveAppConfig - this removes the UI.
+ saveStatus: DENIED,
+ }),
+ );
+ });
+
test('successfully saves an LTI configuration', async () => {
+ history.push(`/course/${courseId}/pages-and-resources/discussions/configure/piazza`);
+
axiosMock.onGet(getAppsUrl(courseId)).reply(200, piazzaApiResponse);
axiosMock.onPost(getAppsUrl(courseId), {
context_key: courseId,
@@ -217,12 +268,18 @@ describe('Data layer integration tests', () => {
// We call fetchApps and selectApp here too just to get us into a real state.
await executeThunk(fetchApps(courseId), store.dispatch);
store.dispatch(selectApp({ appId: 'piazza' }));
- await executeThunk(saveAppConfig(courseId, 'piazza', {
- consumerKey: 'new_consumer_key',
- consumerSecret: 'new_consumer_secret',
- launchUrl: 'http://localhost/new_launch_url',
- }), store.dispatch);
+ await executeThunk(saveAppConfig(
+ courseId,
+ 'piazza',
+ {
+ consumerKey: 'new_consumer_key',
+ consumerSecret: 'new_consumer_secret',
+ launchUrl: 'http://localhost/new_launch_url',
+ },
+ pagesAndResourcesPath,
+ ), store.dispatch);
+ expect(window.location.pathname).toEqual(pagesAndResourcesPath);
expect(store.getState().discussions).toEqual(
expect.objectContaining({
appIds: ['legacy', 'piazza'],
@@ -242,6 +299,8 @@ describe('Data layer integration tests', () => {
});
test('successfully saves a Legacy configuration', async () => {
+ history.push(`/course/${courseId}/pages-and-resources/discussions/configure/legacy`);
+
axiosMock.onGet(getAppsUrl(courseId)).reply(200, legacyApiResponse);
axiosMock.onPost(getAppsUrl(courseId), {
context_key: courseId,
@@ -266,19 +325,25 @@ describe('Data layer integration tests', () => {
// We call fetchApps and selectApp here too just to get us into a real state.
await executeThunk(fetchApps(courseId), store.dispatch);
store.dispatch(selectApp({ appId: 'legacy' }));
- await executeThunk(saveAppConfig(courseId, 'legacy', {
- allowAnonymousPosts: true,
- allowAnonymousPostsPeers: true,
- blackoutDates: '[["2015-09-15","2015-09-21"],["2015-10-01","2015-10-08"]]',
- // TODO: Note! As of this writing, all the data below this line is NOT returned in the API
- // but we technically send it to the thunk, so here it is.
- divideByCohorts: true,
- allowDivisionByUnit: true,
- divideCourseWideTopics: true,
- divideGeneralTopic: true,
- divideQuestionsForTAsTopic: true,
- }), store.dispatch);
+ await executeThunk(saveAppConfig(
+ courseId,
+ 'legacy',
+ {
+ allowAnonymousPosts: true,
+ allowAnonymousPostsPeers: true,
+ blackoutDates: '[["2015-09-15","2015-09-21"],["2015-10-01","2015-10-08"]]',
+ // TODO: Note! As of this writing, all the data below this line is NOT returned in the API
+ // but we technically send it to the thunk, so here it is.
+ divideByCohorts: true,
+ allowDivisionByUnit: true,
+ divideCourseWideTopics: true,
+ divideGeneralTopic: true,
+ divideQuestionsForTAsTopic: true,
+ },
+ pagesAndResourcesPath,
+ ), store.dispatch);
+ expect(window.location.pathname).toEqual(pagesAndResourcesPath);
expect(store.getState().discussions).toEqual(
expect.objectContaining({
appIds: ['legacy', 'piazza'],
diff --git a/src/pages-and-resources/discussions/data/slice.js b/src/pages-and-resources/discussions/data/slice.js
index 112644921..c10b4af0d 100644
--- a/src/pages-and-resources/discussions/data/slice.js
+++ b/src/pages-and-resources/discussions/data/slice.js
@@ -4,6 +4,7 @@ import { createSlice } from '@reduxjs/toolkit';
export const LOADING = 'LOADING';
export const LOADED = 'LOADED';
export const FAILED = 'FAILED';
+export const DENIED = 'DENIED';
export const SAVING = 'SAVING';
export const SAVED = 'SAVED';
diff --git a/src/pages-and-resources/discussions/data/thunks.js b/src/pages-and-resources/discussions/data/thunks.js
index 0c26dc766..5f4248ecc 100644
--- a/src/pages-and-resources/discussions/data/thunks.js
+++ b/src/pages-and-resources/discussions/data/thunks.js
@@ -1,3 +1,4 @@
+import { history } from '@edx/frontend-platform';
import { addModel, addModels } from '../../../generic/model-store';
import { getApps, postAppConfig } from './api';
@@ -9,6 +10,7 @@ import {
SAVING,
updateStatus,
updateSaveStatus,
+ DENIED,
} from './slice';
export function fetchApps(courseId) {
@@ -31,15 +33,16 @@ export function fetchApps(courseId) {
featureIds: features.map(feature => feature.id),
}));
} catch (error) {
- // TODO: We need generic error handling in the app for when a request just fails... in other
- // parts of the app (proctored exam settings) we show a nice message and ask the user to
- // reload/try again later.
- dispatch(updateStatus({ status: FAILED }));
+ if (error.response && error.response.status === 403) {
+ dispatch(updateStatus({ status: DENIED }));
+ } else {
+ dispatch(updateStatus({ status: FAILED }));
+ }
}
};
}
-export function saveAppConfig(courseId, appId, drafts) {
+export function saveAppConfig(courseId, appId, drafts, successPath) {
return async (dispatch) => {
dispatch(updateSaveStatus({ status: SAVING }));
@@ -60,11 +63,16 @@ export function saveAppConfig(courseId, appId, drafts) {
featureIds: features.map(feature => feature.id),
}));
dispatch(updateSaveStatus({ status: SAVED }));
+ // Note that we redirect here to avoid having to work with the promise over in AppConfigForm.
+ history.push(successPath);
} catch (error) {
- // TODO: We need generic error handling in the app for when a request just fails... in other
- // parts of the app (proctored exam settings) we show a nice message and ask the user to
- // reload/try again later.
- dispatch(updateSaveStatus({ status: FAILED }));
+ if (error.response && error.response.status === 403) {
+ dispatch(updateSaveStatus({ status: DENIED }));
+ // This second one will remove the interface as well and hide it from the user.
+ dispatch(updateStatus({ status: DENIED }));
+ } else {
+ dispatch(updateSaveStatus({ status: FAILED }));
+ }
}
};
}
diff --git a/src/proctored-exam-settings/ProctoredExamSettings.jsx b/src/proctored-exam-settings/ProctoredExamSettings.jsx
index 5725ff018..6340adf94 100644
--- a/src/proctored-exam-settings/ProctoredExamSettings.jsx
+++ b/src/proctored-exam-settings/ProctoredExamSettings.jsx
@@ -12,9 +12,12 @@ import {
FormattedMessage,
} from '@edx/frontend-platform/i18n';
+import { getConfig } from '@edx/frontend-platform';
import messages from './ProctoredExamSettings.messages';
import StudioApiService from '../data/services/StudioApiService';
import Loading from '../generic/Loading';
+import ConnectionErrorAlert from '../generic/ConnectionErrorAlert';
+import PermissionDeniedAlert from '../generic/PermissionDeniedAlert';
function ProctoredExamSettings({ courseId, intl }) {
const [loading, setLoading] = useState(true);
@@ -394,31 +397,13 @@ function ProctoredExamSettings({ courseId, intl }) {
function renderConnectionError() {
return (
-
- {intl.formatMessage(messages['authoring.examsettings.support.text'])} }}
- description=""
- />
-
+
);
}
function renderPermissionError() {
return (
-
-
-
+
);
}
@@ -461,9 +446,15 @@ function ProctoredExamSettings({ courseId, intl }) {
We encountered a technical error while trying to save proctored exam settings.
This might be a temporary issue, so please try again in a few minutes.
If the problem persists,
- please go to {support_link} for help.
+ please go to the {support_link} for help.
`}
- values={{ support_link: {intl.formatMessage(messages['authoring.examsettings.support.text'])} }}
+ values={{
+ support_link: (
+
+ {intl.formatMessage(messages['authoring.examsettings.support.text'])}
+
+ ),
+ }}
/>
);
diff --git a/src/proctored-exam-settings/ProctoredExamSettings.messages.jsx b/src/proctored-exam-settings/ProctoredExamSettings.messages.jsx
index 86a7dbd7c..aa5491954 100644
--- a/src/proctored-exam-settings/ProctoredExamSettings.messages.jsx
+++ b/src/proctored-exam-settings/ProctoredExamSettings.messages.jsx
@@ -23,7 +23,7 @@ const messages = defineMessages({
},
'authoring.examsettings.support.text': {
id: 'authoring.examsettings.support.text',
- defaultMessage: 'edX Support Page',
+ defaultMessage: 'Support Page',
description: 'Text linking to the support page.',
},
'authoring.examsettings.enableproctoredexams.label': {
diff --git a/src/proctored-exam-settings/ProctoredExamSettings.test.jsx b/src/proctored-exam-settings/ProctoredExamSettings.test.jsx
index 3a5f5c62b..58b2ed6f1 100644
--- a/src/proctored-exam-settings/ProctoredExamSettings.test.jsx
+++ b/src/proctored-exam-settings/ProctoredExamSettings.test.jsx
@@ -491,9 +491,9 @@ describe('ProctoredExamSettings', () => {
).reply(500);
await act(async () => render(intlWrapper()));
- const connectionError = screen.getByTestId('connectionError');
+ const connectionError = screen.getByTestId('connectionErrorAlert');
expect(connectionError.textContent).toEqual(
- expect.stringContaining('We encountered a technical error'),
+ expect.stringContaining('We encountered a technical error when loading this page.'),
);
});
@@ -503,8 +503,8 @@ describe('ProctoredExamSettings', () => {
).reply(403);
await act(async () => render(intlWrapper()));
- const connectionError = screen.getByTestId('permissionError');
- expect(connectionError.textContent).toEqual(
+ const permissionError = screen.getByTestId('permissionDeniedAlert');
+ expect(permissionError.textContent).toEqual(
expect.stringContaining('You are not authorized to view this page'),
);
});