diff --git a/src/pages-and-resources/discussions/DiscussionsSettings.jsx b/src/pages-and-resources/discussions/DiscussionsSettings.jsx
index 691f82169a..8240de93c5 100644
--- a/src/pages-and-resources/discussions/DiscussionsSettings.jsx
+++ b/src/pages-and-resources/discussions/DiscussionsSettings.jsx
@@ -16,7 +16,7 @@ import { PagesAndResourcesContext } from '../PagesAndResourcesProvider';
import messages from './messages';
import DiscussionsProvider from './DiscussionsProvider';
-import { fetchApps } from './data/thunks';
+import { fetchProviders } from './data/thunks';
import AppList from './app-list';
import AppConfigForm from './app-config-form';
import { DENIED, FAILED } from './data/slice';
@@ -36,7 +36,7 @@ function DiscussionsSettings({ courseId, intl }) {
const courseDetail = useModel('courseDetails', courseId);
useEffect(() => {
- dispatch(fetchApps(courseId));
+ dispatch(fetchProviders(courseId));
}, [courseId]);
const discussionsPath = `${pagesAndResourcesPath}/discussion`;
diff --git a/src/pages-and-resources/discussions/DiscussionsSettings.test.jsx b/src/pages-and-resources/discussions/DiscussionsSettings.test.jsx
index 261063b209..ce7b185470 100644
--- a/src/pages-and-resources/discussions/DiscussionsSettings.test.jsx
+++ b/src/pages-and-resources/discussions/DiscussionsSettings.test.jsx
@@ -5,6 +5,7 @@ import { getAuthenticatedHttpClient } from '@edx/frontend-platform/auth';
import { AppProvider, PageRoute } from '@edx/frontend-platform/react';
import {
act,
+ findByRole,
getByRole,
queryByLabelText,
queryByRole,
@@ -19,21 +20,22 @@ import userEvent from '@testing-library/user-event';
import MockAdapter from 'axios-mock-adapter';
import React from 'react';
import { Switch } from 'react-router';
+import { fetchCourseDetail } from '../../data/thunks';
import initializeStore from '../../store';
+import { executeThunk } from '../../utils';
import PagesAndResourcesProvider from '../PagesAndResourcesProvider';
+import ltiMessages from './app-config-form/apps/lti/messages';
import appMessages from './app-config-form/messages';
import messages from './app-list/messages';
-import ltiMessages from './app-config-form/apps/lti/messages';
-import { getAppsUrl } from './data/api';
+import { getDiscussionsProvidersUrl, getDiscussionsSettingsUrl } from './data/api';
import DiscussionsSettings from './DiscussionsSettings';
import {
+ courseDetailResponse,
generatePiazzaApiResponse,
+ generateProvidersApiResponse,
legacyApiResponse,
piazzaApiResponse,
- courseDetailResponse,
} from './factories/mockApiResponses';
-import { executeThunk } from '../../utils';
-import { fetchCourseDetail } from '../../data/thunks';
const courseId = 'course-v1:edX+TestX+Test_Course';
let axiosMock;
@@ -88,7 +90,10 @@ describe('DiscussionsSettings', () => {
describe('with successful network connections', () => {
beforeEach(() => {
- axiosMock.onGet(getAppsUrl(courseId)).reply(200, piazzaApiResponse);
+ axiosMock.onGet(getDiscussionsProvidersUrl(courseId))
+ .reply(200, generateProvidersApiResponse(false));
+ axiosMock.onGet(getDiscussionsSettingsUrl(courseId))
+ .reply(200, piazzaApiResponse);
renderComponent();
});
@@ -124,6 +129,8 @@ describe('DiscussionsSettings', () => {
userEvent.click(queryByLabelText(container, 'Select Piazza'));
userEvent.click(queryByText(container, messages.nextButton.defaultMessage));
+ await waitForElementToBeRemoved(screen.getByRole('status'));
+
expect(queryByTestId(container, 'appList')).not.toBeInTheDocument();
expect(queryByTestId(container, 'appConfigForm')).toBeInTheDocument();
expect(queryByTestId(container, 'ltiConfigForm')).toBeInTheDocument();
@@ -131,7 +138,8 @@ describe('DiscussionsSettings', () => {
});
test('successfully advances to settings step for legacy', async () => {
- axiosMock.onGet(getAppsUrl(courseId)).reply(200, legacyApiResponse);
+ axiosMock.onGet(getDiscussionsProvidersUrl(courseId)).reply(200, generateProvidersApiResponse(false, 'legacy'));
+ axiosMock.onGet(getDiscussionsSettingsUrl(courseId)).reply(200, legacyApiResponse);
renderComponent();
history.push(`/course/${courseId}/pages-and-resources/discussion`);
@@ -139,9 +147,11 @@ describe('DiscussionsSettings', () => {
// content has been loaded - prior to proceeding with our expectations.
await waitForElementToBeRemoved(screen.getByRole('status'));
- userEvent.click(queryByLabelText(container, 'Select edX'));
+ userEvent.click(queryByLabelText(container, 'Select edX (Legacy)'));
userEvent.click(queryByText(container, messages.nextButton.defaultMessage));
+ await waitForElementToBeRemoved(screen.getByRole('status'));
+
expect(queryByTestId(container, 'appList')).not.toBeInTheDocument();
expect(queryByTestId(container, 'appConfigForm')).toBeInTheDocument();
expect(queryByTestId(container, 'ltiConfigForm')).not.toBeInTheDocument();
@@ -183,7 +193,7 @@ describe('DiscussionsSettings', () => {
test('successfully submit the modal', async () => {
history.push(`/course/${courseId}/pages-and-resources/discussion`);
- axiosMock.onPost(getAppsUrl(courseId)).reply(200, piazzaApiResponse);
+ axiosMock.onPost(getDiscussionsSettingsUrl(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.
@@ -193,7 +203,7 @@ describe('DiscussionsSettings', () => {
userEvent.click(getByRole(container, 'button', { name: 'Next' }));
- userEvent.click(getByRole(container, 'button', { name: 'Save' }));
+ userEvent.click(await findByRole(container, 'button', { name: 'Save' }));
// 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,
@@ -215,6 +225,7 @@ describe('DiscussionsSettings', () => {
userEvent.click(getByRole(container, 'checkbox', { name: 'Select Discourse' }));
userEvent.click(getByRole(container, 'button', { name: 'Next' }));
+ await findByRole(container, 'button', { name: 'Save' });
userEvent.type(getByRole(container, 'textbox', { name: 'Consumer Key' }), 'key');
userEvent.type(getByRole(container, 'textbox', { name: 'Consumer Secret' }), 'secret');
userEvent.type(getByRole(container, 'textbox', { name: 'Launch URL' }), 'http://example.test');
@@ -237,6 +248,7 @@ describe('DiscussionsSettings', () => {
userEvent.click(discourseBox);
userEvent.click(getByRole(container, 'button', { name: 'Next' }));
+ await waitForElementToBeRemoved(screen.getByRole('status'));
expect(getByRole(container, 'heading', { name: 'Discourse' })).toBeInTheDocument();
userEvent.type(getByRole(container, 'textbox', { name: 'Consumer Key' }), 'a');
@@ -252,7 +264,7 @@ describe('DiscussionsSettings', () => {
});
});
- describe('with network error fetchApps API requests', () => {
+ describe('with network error fetchProviders API requests', () => {
beforeEach(() => {
// Expedient way of getting SUPPORT_URL into config.
setConfig({
@@ -260,8 +272,8 @@ describe('DiscussionsSettings', () => {
SUPPORT_URL: 'http://support.edx.org',
});
- axiosMock.onGet(getAppsUrl(courseId)).networkError();
-
+ axiosMock.onGet(getDiscussionsProvidersUrl(courseId)).networkError();
+ axiosMock.onGet(getDiscussionsSettingsUrl(courseId)).networkError();
renderComponent();
});
@@ -287,8 +299,11 @@ describe('DiscussionsSettings', () => {
SUPPORT_URL: 'http://support.edx.org',
});
- axiosMock.onGet(getAppsUrl(courseId)).reply(200, piazzaApiResponse);
- axiosMock.onPost(getAppsUrl(courseId)).networkError();
+ axiosMock.onGet(getDiscussionsProvidersUrl(courseId))
+ .reply(200, generateProvidersApiResponse());
+ axiosMock.onGet(getDiscussionsSettingsUrl(courseId))
+ .reply(200, piazzaApiResponse);
+ axiosMock.onPost(getDiscussionsSettingsUrl(courseId)).networkError();
renderComponent();
});
@@ -307,17 +322,17 @@ describe('DiscussionsSettings', () => {
await waitFor(() => expect(axiosMock.history.post.length).toBe(1));
expect(queryByTestId(container, 'appConfigForm')).toBeInTheDocument();
-
- const alert = queryByRole(container, 'alert');
+ const alert = await findByRole(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', () => {
+ describe('with permission denied error for fetchProviders API requests', () => {
beforeEach(() => {
- axiosMock.onGet(getAppsUrl(courseId)).reply(403);
+ axiosMock.onGet(getDiscussionsProvidersUrl(courseId)).reply(403);
+ axiosMock.onGet(getDiscussionsSettingsUrl(courseId)).reply(403);
renderComponent();
});
@@ -337,8 +352,10 @@ describe('DiscussionsSettings', () => {
describe('with permission denied error for postAppConfig API requests', () => {
beforeEach(() => {
- axiosMock.onGet(getAppsUrl(courseId)).reply(200, piazzaApiResponse);
- axiosMock.onPost(getAppsUrl(courseId)).reply(403);
+ axiosMock.onGet(getDiscussionsProvidersUrl(courseId))
+ .reply(200, generateProvidersApiResponse());
+ axiosMock.onGet(getDiscussionsSettingsUrl(courseId)).reply(200, piazzaApiResponse);
+ axiosMock.onPost(getDiscussionsSettingsUrl(courseId)).reply(403);
renderComponent();
});
@@ -360,7 +377,7 @@ describe('DiscussionsSettings', () => {
// 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/discussion/configure/piazza`);
- const alert = queryByRole(container, 'alert');
+ const alert = await findByRole(container, 'alert');
expect(alert).toBeInTheDocument();
expect(alert.textContent).toEqual(expect.stringContaining('You are not authorized to view this page.'));
});
@@ -394,7 +411,10 @@ describe.each([
// Leave the DiscussionsSettings route after the test.
history.push(`/course/${courseId}/pages-and-resources`);
- axiosMock.onGet(getAppsUrl(courseId)).reply(200, generatePiazzaApiResponse(isAdminOnlyConfig));
+ axiosMock.onGet(getDiscussionsProvidersUrl(courseId))
+ .reply(200, generateProvidersApiResponse(isAdminOnlyConfig));
+ axiosMock.onGet(getDiscussionsSettingsUrl(courseId))
+ .reply(200, generatePiazzaApiResponse());
renderComponent();
});
@@ -408,6 +428,7 @@ describe.each([
userEvent.click(queryByLabelText(container, 'Select Piazza'));
userEvent.click(queryByText(container, messages.nextButton.defaultMessage));
+ await waitForElementToBeRemoved(screen.getByRole('status'));
if (showLTIConfig) {
expect(queryByText(container, ltiMessages.formInstructions.defaultMessage)).toBeInTheDocument();
@@ -446,7 +467,10 @@ describe.each([
// Leave the DiscussionsSettings route after the test.
history.push(`/course/${courseId}/pages-and-resources`);
- axiosMock.onGet(getAppsUrl(courseId)).reply(200, generatePiazzaApiResponse(false, piiSharingAllowed));
+ axiosMock.onGet(getDiscussionsProvidersUrl(courseId))
+ .reply(200, generateProvidersApiResponse(false));
+ axiosMock.onGet(getDiscussionsSettingsUrl(courseId))
+ .reply(200, generatePiazzaApiResponse(piiSharingAllowed));
renderComponent();
});
@@ -460,6 +484,7 @@ describe.each([
userEvent.click(queryByLabelText(container, 'Select Piazza'));
userEvent.click(queryByText(container, messages.nextButton.defaultMessage));
+ await waitForElementToBeRemoved(screen.getByRole('status'));
if (enablePIISharing) {
expect(queryByTestId(container, 'piiSharingFields')).toBeInTheDocument();
} else {
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 d898d5e2d4..73776e007a 100644
--- a/src/pages-and-resources/discussions/app-config-form/AppConfigForm.jsx
+++ b/src/pages-and-resources/discussions/app-config-form/AppConfigForm.jsx
@@ -21,8 +21,8 @@ import {
DENIED,
FAILED, LOADED, LOADING, selectApp,
} from '../data/slice';
-import { saveAppConfig } from '../data/thunks';
-import LegacyConfigForm from './apps/legacy';
+import { fetchDiscussionSettings, saveProviderConfig } from '../data/thunks';
+import OpenedXConfigForm from './apps/openedx';
import LtiConfigForm from './apps/lti';
import AppConfigFormProvider, { AppConfigFormContext } from './AppConfigFormProvider';
import AppConfigFormSaveButton from './AppConfigFormSaveButton';
@@ -36,12 +36,20 @@ function AppConfigForm({
const { formRef } = useContext(AppConfigFormContext);
const { path: pagesAndResourcesPath } = useContext(PagesAndResourcesContext);
const { params: { appId: routeAppId } } = useRouteMatch();
+ const [isLoading, setLoading] = useState(true);
const {
activeAppId, selectedAppId, status, saveStatus,
} = useSelector(state => state.discussions);
const [confirmationDialogVisible, setConfirmationDialogVisible] = useState(false);
+ useEffect(() => {
+ (async () => {
+ await dispatch(fetchDiscussionSettings(courseId, selectedAppId));
+ setLoading(false);
+ })();
+ }, [courseId, selectedAppId]);
+
useEffect(() => {
if (status === LOADED) {
if (routeAppId !== selectedAppId) {
@@ -57,12 +65,12 @@ function AppConfigForm({
setConfirmationDialogVisible(true);
} else {
setConfirmationDialogVisible(false);
- // Note that when this action succeeds, we redirect to pagesAndResurcesPath in the thunk.
- dispatch(saveAppConfig(courseId, selectedAppId, values, pagesAndResourcesPath));
+ // Note that when this action succeeds, we redirect to pagesAndResourcesPath in the thunk.
+ dispatch(saveProviderConfig(courseId, selectedAppId, values, pagesAndResourcesPath));
}
}, [activeAppId, confirmationDialogVisible, courseId, selectedAppId]);
- if (!selectedAppId || status === LOADING) {
+ if (!selectedAppId || status === LOADING || isLoading) {
return (
);
@@ -78,12 +86,21 @@ function AppConfigForm({
alert = ;
}
- let form = null;
+ let form;
if (selectedAppId === 'legacy') {
form = (
-
+ );
+ } else if (selectedAppId === 'openedx') {
+ form = (
+
);
} else {
diff --git a/src/pages-and-resources/discussions/app-config-form/apps/legacy/index.js b/src/pages-and-resources/discussions/app-config-form/apps/legacy/index.js
deleted file mode 100644
index f0b9341294..0000000000
--- a/src/pages-and-resources/discussions/app-config-form/apps/legacy/index.js
+++ /dev/null
@@ -1 +0,0 @@
-export { default } from './LegacyConfigForm';
diff --git a/src/pages-and-resources/discussions/app-config-form/apps/lti/LtiConfigForm.jsx b/src/pages-and-resources/discussions/app-config-form/apps/lti/LtiConfigForm.jsx
index 9d74736502..a5f7005055 100644
--- a/src/pages-and-resources/discussions/app-config-form/apps/lti/LtiConfigForm.jsx
+++ b/src/pages-and-resources/discussions/app-config-form/apps/lti/LtiConfigForm.jsx
@@ -21,9 +21,7 @@ ensureConfig(['SITE_NAME', 'SUPPORT_EMAIL'], 'LTI Config Form');
function LtiConfigForm({ onSubmit, intl, formRef }) {
const dispatch = useDispatch();
- const { selectedAppId } = useSelector((state) => state.discussions);
-
- const piiConfig = useModel('appConfigs', 'pii');
+ const { selectedAppId, piiConfig } = useSelector((state) => state.discussions);
const appConfig = useModel('appConfigs', selectedAppId);
const app = useModel('apps', selectedAppId);
const providerName = intl.formatMessage(appMessages[`appName-${app?.id}`]);
diff --git a/src/pages-and-resources/discussions/app-config-form/apps/legacy/LegacyConfigForm.jsx b/src/pages-and-resources/discussions/app-config-form/apps/openedx/OpenedXConfigForm.jsx
similarity index 69%
rename from src/pages-and-resources/discussions/app-config-form/apps/legacy/LegacyConfigForm.jsx
rename to src/pages-and-resources/discussions/app-config-form/apps/openedx/OpenedXConfigForm.jsx
index 71335f774a..5bb417908c 100644
--- a/src/pages-and-resources/discussions/app-config-form/apps/legacy/LegacyConfigForm.jsx
+++ b/src/pages-and-resources/discussions/app-config-form/apps/openedx/OpenedXConfigForm.jsx
@@ -1,41 +1,57 @@
-import React, { useState } from 'react';
-import PropTypes from 'prop-types';
+import { injectIntl, intlShape } from '@edx/frontend-platform/i18n';
import { Card, Form } from '@edx/paragon';
import { Formik } from 'formik';
-import * as Yup from 'yup';
-import { injectIntl, intlShape } from '@edx/frontend-platform/i18n';
+import PropTypes from 'prop-types';
+import React, { useState } from 'react';
import { useSelector } from 'react-redux';
-import DivisionByGroupFields from '../shared/DivisionByGroupFields';
-import AnonymousPostingFields from '../shared/AnonymousPostingFields';
-import DiscussionTopics from '../shared/discussion-topics/DiscussionTopics';
-import BlackoutDatesField from '../shared/BlackoutDatesField';
-import LegacyConfigFormProvider from './LegacyConfigFormProvider';
-import AppConfigFormDivider from '../shared/AppConfigFormDivider';
-import { checkFieldErrors } from '../../utils';
-import { setupYupExtensions } from '../../../../../utils';
+import * as Yup from 'yup';
import { useModel, useModels } from '../../../../../generic/model-store';
+import { setupYupExtensions } from '../../../../../utils';
import messages from '../../messages';
+import { checkFieldErrors } from '../../utils';
+import AnonymousPostingFields from '../shared/AnonymousPostingFields';
+import AppConfigFormDivider from '../shared/AppConfigFormDivider';
+import BlackoutDatesField from '../shared/BlackoutDatesField';
+import DiscussionTopics from '../shared/discussion-topics/DiscussionTopics';
+import DivisionByGroupFields from '../shared/DivisionByGroupFields';
+import InContextDiscussionFields from '../shared/InContextDiscussionFields';
+import OpenedXConfigFormProvider from './OpenedXConfigFormProvider';
setupYupExtensions();
-function LegacyConfigForm({ onSubmit, formRef, intl }) {
- const { discussionTopicIds, divideDiscussionIds, selectedAppId } = useSelector((state) => state.discussions);
+function OpenedXConfigForm({
+ onSubmit, formRef, intl, legacy,
+}) {
+ const {
+ selectedAppId, enableInContext, enableGradedUnits, unitLevelVisibility, discussionTopicIds, divideDiscussionIds,
+ } = useSelector(state => state.discussions);
const appConfigObj = useModel('appConfigs', selectedAppId);
const discussionTopicsModel = useModels('discussionTopics', discussionTopicIds);
- const appConfig = { ...appConfigObj, discussionTopics: discussionTopicsModel, divideDiscussionIds };
- const LegacyAppConfig = {
- ...appConfig,
- allowAnonymousPosts: appConfig.allowAnonymousPosts || false,
- allowAnonymousPostsPeers: appConfig.allowAnonymousPostsPeers || false,
- blackoutDates: appConfig.blackoutDates || [],
- discussionTopics: appConfig.discussionTopics || [],
- divideByCohorts: appConfig.divideByCohorts || false,
- divideCourseTopicsByCohorts: appConfig.divideCourseTopicsByCohorts || false,
+ const legacyAppConfig = {
+ ...(appConfigObj || {}),
+ divideDiscussionIds,
+ enableInContext,
+ enableGradedUnits,
+ unitLevelVisibility,
+ allowAnonymousPosts: appConfigObj?.allowAnonymousPosts || false,
+ allowAnonymousPostsPeers: appConfigObj?.allowAnonymousPostsPeers || false,
+ blackoutDates: appConfigObj?.blackoutDates || [],
+ discussionTopics: discussionTopicsModel || [],
+ divideByCohorts: appConfigObj?.divideByCohorts || false,
+ divideCourseTopicsByCohorts: appConfigObj?.divideCourseTopicsByCohorts || false,
+ groupAtSubsection: appConfigObj?.groupAtSubsection || false,
};
- const [validDiscussionTopics, setValidDiscussionTopics] = useState(appConfig.discussionTopics);
- const legacyFormValidationSchema = Yup.object().shape({
+ const [validDiscussionTopics, setValidDiscussionTopics] = useState(discussionTopicsModel);
+ // These fields are only used for the new provider and aren't supported for legacy.
+ const additionalFields = legacy ? {} : {
+ enableInContext: Yup.bool().default(true),
+ enabledGradedUnits: Yup.bool().default(false),
+ groupAtSubsection: Yup.bool().default(false),
+ unitLevelVisibility: Yup.bool().default(false),
+ };
+ const validationSchema = Yup.object().shape({
blackoutDates: Yup.array(
Yup.object().shape({
startDate: Yup.string()
@@ -65,13 +81,14 @@ function LegacyConfigForm({ onSubmit, formRef, intl }) {
name: Yup.string().required(intl.formatMessage(messages.discussionTopicRequired)),
}).uniqueObjectProperty('name', intl.formatMessage(messages.discussionTopicNameAlreadyExist)),
),
+ ...additionalFields,
});
return (
onSubmit(values)}
>
{({
@@ -97,11 +114,18 @@ function LegacyConfigForm({ onSubmit, formRef, intl }) {
};
return (
-
+
-
+
);
}}
);
}
-LegacyConfigForm.propTypes = {
+OpenedXConfigForm.propTypes = {
+ legacy: PropTypes.bool.isRequired,
onSubmit: PropTypes.func.isRequired,
// eslint-disable-next-line react/forbid-prop-types
formRef: PropTypes.object.isRequired,
intl: intlShape.isRequired,
};
-export default injectIntl(LegacyConfigForm);
+export default injectIntl(OpenedXConfigForm);
diff --git a/src/pages-and-resources/discussions/app-config-form/apps/legacy/LegacyConfigForm.test.jsx b/src/pages-and-resources/discussions/app-config-form/apps/openedx/OpenedXConfigForm.test.jsx
similarity index 87%
rename from src/pages-and-resources/discussions/app-config-form/apps/legacy/LegacyConfigForm.test.jsx
rename to src/pages-and-resources/discussions/app-config-form/apps/openedx/OpenedXConfigForm.test.jsx
index 8816729ae4..fd102e6191 100644
--- a/src/pages-and-resources/discussions/app-config-form/apps/legacy/LegacyConfigForm.test.jsx
+++ b/src/pages-and-resources/discussions/app-config-form/apps/openedx/OpenedXConfigForm.test.jsx
@@ -2,7 +2,8 @@ import React, { createRef } from 'react';
import {
act,
- fireEvent, queryAllByText,
+ fireEvent,
+ queryAllByText,
queryByLabelText,
queryByRole,
queryByTestId,
@@ -20,11 +21,11 @@ import { AppProvider } from '@edx/frontend-platform/react';
import initializeStore from '../../../../../store';
import { executeThunk } from '../../../../../utils';
-import { getAppsUrl } from '../../../data/api';
-import { fetchApps } from '../../../data/thunks';
-import { legacyApiResponse } from '../../../factories/mockApiResponses';
+import { getDiscussionsProvidersUrl, getDiscussionsSettingsUrl } from '../../../data/api';
+import { fetchDiscussionSettings, fetchProviders } from '../../../data/thunks';
+import { generateProvidersApiResponse, legacyApiResponse } from '../../../factories/mockApiResponses';
import messages from '../../messages';
-import LegacyConfigForm from './LegacyConfigForm';
+import OpenedXConfigForm from './OpenedXConfigForm';
import { selectApp } from '../../../data/slice';
import { DivisionSchemes } from '../../../../../data/constants';
@@ -39,12 +40,16 @@ const defaultAppConfig = (divideDiscussionIds = []) => ({
{ name: 'General', id: 'course' },
],
divideDiscussionIds,
+ enableGradedUnits: undefined,
+ enableInContext: undefined,
+ groupAtSubsection: false,
+ unitLevelVisibility: undefined,
allowAnonymousPosts: false,
allowAnonymousPostsPeers: false,
allowDivisionByUnit: false,
blackoutDates: [],
});
-describe('LegacyConfigForm', () => {
+describe('OpenedXConfigForm', () => {
let axiosMock;
let store;
let container;
@@ -66,13 +71,14 @@ describe('LegacyConfigForm', () => {
axiosMock.reset();
});
- const createComponent = (onSubmit = jest.fn(), formRef = createRef()) => {
+ const createComponent = (onSubmit = jest.fn(), formRef = createRef(), legacy = true) => {
const wrapper = render(
-
,
@@ -82,8 +88,10 @@ describe('LegacyConfigForm', () => {
};
const mockStore = async (mockResponse) => {
- axiosMock.onGet(getAppsUrl(courseId)).reply(200, mockResponse);
- await executeThunk(fetchApps(courseId), store.dispatch);
+ axiosMock.onGet(getDiscussionsProvidersUrl(courseId)).reply(200, generateProvidersApiResponse());
+ axiosMock.onGet(getDiscussionsSettingsUrl(courseId)).reply(200, mockResponse);
+ await executeThunk(fetchProviders(courseId), store.dispatch);
+ await executeThunk(fetchDiscussionSettings(courseId), store.dispatch);
store.dispatch(selectApp({ appId: 'legacy' }));
};
@@ -93,6 +101,15 @@ describe('LegacyConfigForm', () => {
expect(container.querySelector('h3')).toHaveTextContent('edX');
});
+ test('new Open edX provider config', async () => {
+ await mockStore({ ...legacyApiResponse, enable_in_context: true });
+ createComponent(jest.fn(), createRef(), false);
+ expect(queryByText(container, messages.visibilityInContext.defaultMessage)).toBeInTheDocument();
+ expect(queryByText(container, messages.gradedUnitPagesLabel.defaultMessage)).toBeInTheDocument();
+ expect(queryByText(container, messages.groupInContextSubsectionLabel.defaultMessage)).toBeInTheDocument();
+ expect(queryByText(container, messages.allowUnitLevelVisibilityLabel.defaultMessage)).toBeInTheDocument();
+ });
+
test('calls onSubmit when the formRef is submitted', async () => {
const formRef = createRef();
const handleSubmit = jest.fn();
diff --git a/src/pages-and-resources/discussions/app-config-form/apps/legacy/LegacyConfigFormProvider.jsx b/src/pages-and-resources/discussions/app-config-form/apps/openedx/OpenedXConfigFormProvider.jsx
similarity index 75%
rename from src/pages-and-resources/discussions/app-config-form/apps/legacy/LegacyConfigFormProvider.jsx
rename to src/pages-and-resources/discussions/app-config-form/apps/openedx/OpenedXConfigFormProvider.jsx
index 2d0fcd66e7..d5a1c8792f 100644
--- a/src/pages-and-resources/discussions/app-config-form/apps/legacy/LegacyConfigFormProvider.jsx
+++ b/src/pages-and-resources/discussions/app-config-form/apps/openedx/OpenedXConfigFormProvider.jsx
@@ -3,9 +3,9 @@ import PropTypes from 'prop-types';
import { useDispatch } from 'react-redux';
import { updateValidationStatus } from '../../../data/slice';
-export const LegacyConfigFormContext = createContext({});
+export const OpenedXConfigFormContext = createContext({});
-export default function LegacyConfigFormProvider({ children, value }) {
+export default function OpenedXConfigFormProvider({ children, value }) {
const dispatch = useDispatch();
useEffect(() => {
@@ -13,13 +13,13 @@ export default function LegacyConfigFormProvider({ children, value }) {
}, [value.isFormInvalid]);
return (
-
+
{children}
-
+
);
}
-LegacyConfigFormProvider.propTypes = {
+OpenedXConfigFormProvider.propTypes = {
children: PropTypes.node.isRequired,
value: PropTypes.shape({
discussionTopicErrors: PropTypes.arrayOf(PropTypes.bool),
diff --git a/src/pages-and-resources/discussions/app-config-form/apps/openedx/index.js b/src/pages-and-resources/discussions/app-config-form/apps/openedx/index.js
new file mode 100644
index 0000000000..d0140aae7a
--- /dev/null
+++ b/src/pages-and-resources/discussions/app-config-form/apps/openedx/index.js
@@ -0,0 +1 @@
+export { default } from './OpenedXConfigForm';
diff --git a/src/pages-and-resources/discussions/app-config-form/apps/shared/DivisionByGroupFields.jsx b/src/pages-and-resources/discussions/app-config-form/apps/shared/DivisionByGroupFields.jsx
index 7809e70694..fdbf57bdd0 100644
--- a/src/pages-and-resources/discussions/app-config-form/apps/shared/DivisionByGroupFields.jsx
+++ b/src/pages-and-resources/discussions/app-config-form/apps/shared/DivisionByGroupFields.jsx
@@ -6,10 +6,10 @@ import _ from 'lodash';
import FormSwitchGroup from '../../../../../generic/FormSwitchGroup';
import messages from '../../messages';
import AppConfigFormDivider from './AppConfigFormDivider';
-import { LegacyConfigFormContext } from '../legacy/LegacyConfigFormProvider';
+import { OpenedXConfigFormContext } from '../openedx/OpenedXConfigFormProvider';
const DivisionByGroupFields = ({ intl }) => {
- const { validDiscussionTopics } = useContext(LegacyConfigFormContext);
+ const { validDiscussionTopics } = useContext(OpenedXConfigFormContext);
const {
handleChange,
handleBlur,
diff --git a/src/pages-and-resources/discussions/app-config-form/apps/shared/InContextDiscussionFields.jsx b/src/pages-and-resources/discussions/app-config-form/apps/shared/InContextDiscussionFields.jsx
index 0190c09f83..c2a9228a8f 100644
--- a/src/pages-and-resources/discussions/app-config-form/apps/shared/InContextDiscussionFields.jsx
+++ b/src/pages-and-resources/discussions/app-config-form/apps/shared/InContextDiscussionFields.jsx
@@ -18,21 +18,21 @@ function InContextDiscussionFields({
- {values.inContextDiscussion ? (
+ {values.enableInContext ? (
@@ -41,8 +41,8 @@ function InContextDiscussionFields({
onChange={onChange}
onBlur={onBlur}
className="ml-4"
- id="groupInContextSubsection"
- checked={values.groupInContextSubsection}
+ id="groupAtSubsection"
+ checked={values.groupAtSubsection}
label={intl.formatMessage(messages.groupInContextSubsectionLabel)}
helpText={intl.formatMessage(messages.groupInContextSubsectionHelp)}
/>
@@ -51,8 +51,8 @@ function InContextDiscussionFields({
onChange={onChange}
onBlur={onBlur}
className="ml-4"
- id="allowUnitLevelVisibility"
- checked={values.allowUnitLevelVisibility}
+ id="unitLevelVisibility"
+ checked={values.unitLevelVisibility}
label={intl.formatMessage(messages.allowUnitLevelVisibilityLabel)}
helpText={intl.formatMessage(messages.allowUnitLevelVisibilityHelp)}
/>
@@ -69,10 +69,10 @@ InContextDiscussionFields.propTypes = {
onChange: PropTypes.func.isRequired,
intl: intlShape.isRequired,
values: PropTypes.shape({
- inContextDiscussion: PropTypes.bool,
- gradedUnitPages: PropTypes.bool,
- groupInContextSubsection: PropTypes.bool,
- allowUnitLevelVisibility: PropTypes.bool,
+ enableInContext: PropTypes.bool,
+ enableGradedUnits: PropTypes.bool,
+ groupAtSubsection: PropTypes.bool,
+ unitLevelVisibility: PropTypes.bool,
}).isRequired,
};
diff --git a/src/pages-and-resources/discussions/app-config-form/apps/shared/discussion-topics/DiscussionTopics.jsx b/src/pages-and-resources/discussions/app-config-form/apps/shared/discussion-topics/DiscussionTopics.jsx
index 943ae00567..661c83998b 100644
--- a/src/pages-and-resources/discussions/app-config-form/apps/shared/discussion-topics/DiscussionTopics.jsx
+++ b/src/pages-and-resources/discussions/app-config-form/apps/shared/discussion-topics/DiscussionTopics.jsx
@@ -7,7 +7,7 @@ import { v4 as uuid } from 'uuid';
import _ from 'lodash';
import messages from '../../../messages';
import TopicItem from './TopicItem';
-import { LegacyConfigFormContext } from '../../legacy/LegacyConfigFormProvider';
+import { OpenedXConfigFormContext } from '../../openedx/OpenedXConfigFormProvider';
import { filterItemFromObject } from '../../../utils';
const DiscussionTopics = ({ intl }) => {
@@ -21,7 +21,7 @@ const DiscussionTopics = ({ intl }) => {
discussionTopicErrors,
validDiscussionTopics,
setValidDiscussionTopics,
- } = useContext(LegacyConfigFormContext);
+ } = useContext(OpenedXConfigFormContext);
const handleTopicDelete = async (topicIndex, topicId, remove) => {
await remove(topicIndex);
diff --git a/src/pages-and-resources/discussions/app-config-form/apps/shared/discussion-topics/DiscussionTopics.test.jsx b/src/pages-and-resources/discussions/app-config-form/apps/shared/discussion-topics/DiscussionTopics.test.jsx
index 548e052dde..d25638e6fa 100644
--- a/src/pages-and-resources/discussions/app-config-form/apps/shared/discussion-topics/DiscussionTopics.test.jsx
+++ b/src/pages-and-resources/discussions/app-config-form/apps/shared/discussion-topics/DiscussionTopics.test.jsx
@@ -14,10 +14,10 @@ import { AppProvider } from '@edx/frontend-platform/react';
import initializeStore from '../../../../../../store';
import { executeThunk } from '../../../../../../utils';
-import { getAppsUrl } from '../../../../data/api';
-import { fetchApps } from '../../../../data/thunks';
+import { getDiscussionsProvidersUrl } from '../../../../data/api';
+import { fetchProviders } from '../../../../data/thunks';
import { legacyApiResponse } from '../../../../factories/mockApiResponses';
-import LegacyConfigFormProvider from '../../legacy/LegacyConfigFormProvider';
+import OpenedXConfigFormProvider from '../../openedx/OpenedXConfigFormProvider';
import messages from '../../../messages';
import DiscussionTopics from './DiscussionTopics';
@@ -73,11 +73,11 @@ describe('DiscussionTopics', () => {
const wrapper = render(
-
+
-
+
,
);
@@ -85,8 +85,8 @@ describe('DiscussionTopics', () => {
};
const mockStore = async (mockResponse) => {
- axiosMock.onGet(getAppsUrl(courseId)).reply(200, mockResponse);
- await executeThunk(fetchApps(courseId), store.dispatch);
+ axiosMock.onGet(getDiscussionsProvidersUrl(courseId)).reply(200, mockResponse);
+ await executeThunk(fetchProviders(courseId), store.dispatch);
};
test('renders each discussion topic correctly', async () => {
diff --git a/src/pages-and-resources/discussions/app-config-form/apps/shared/discussion-topics/TopicItem.test.jsx b/src/pages-and-resources/discussions/app-config-form/apps/shared/discussion-topics/TopicItem.test.jsx
index 55355f07fa..46e122205e 100644
--- a/src/pages-and-resources/discussions/app-config-form/apps/shared/discussion-topics/TopicItem.test.jsx
+++ b/src/pages-and-resources/discussions/app-config-form/apps/shared/discussion-topics/TopicItem.test.jsx
@@ -20,8 +20,8 @@ import { AppProvider } from '@edx/frontend-platform/react';
import initializeStore from '../../../../../../store';
import { executeThunk } from '../../../../../../utils';
-import { getAppsUrl } from '../../../../data/api';
-import { fetchApps } from '../../../../data/thunks';
+import { getDiscussionsProvidersUrl } from '../../../../data/api';
+import { fetchProviders } from '../../../../data/thunks';
import { legacyApiResponse } from '../../../../factories/mockApiResponses';
import messages from '../../../messages';
import TopicItem from './TopicItem';
@@ -91,8 +91,8 @@ describe('TopicItem', () => {
};
const mockStore = async (mockResponse) => {
- axiosMock.onGet(getAppsUrl(courseId)).reply(200, mockResponse);
- await executeThunk(fetchApps(courseId), store.dispatch);
+ axiosMock.onGet(getDiscussionsProvidersUrl(courseId)).reply(200, mockResponse);
+ await executeThunk(fetchProviders(courseId), store.dispatch);
};
test('displays a collapsible card for discussion topic', async () => {
diff --git a/src/pages-and-resources/discussions/app-config-form/messages.js b/src/pages-and-resources/discussions/app-config-form/messages.js
index 1d303a0b0e..a065a51e79 100644
--- a/src/pages-and-resources/discussions/app-config-form/messages.js
+++ b/src/pages-and-resources/discussions/app-config-form/messages.js
@@ -82,9 +82,14 @@ const messages = defineMessages({
},
'appName-legacy': {
id: 'authoring.discussions.appConfigForm.appName-legacy',
- defaultMessage: 'edX',
+ defaultMessage: 'edX (Legacy)',
description: 'The name of the Legacy edX Discussions app.',
},
+ 'appName-openedx': {
+ id: 'authoring.discussions.appConfigForm.appName-openedx',
+ defaultMessage: 'edX (NEW)',
+ description: 'The name of the new edX Discussions app.',
+ },
divisionByGroup: {
id: 'authoring.discussions.builtIn.divisionByGroup',
defaultMessage: 'Cohorts',
diff --git a/src/pages-and-resources/discussions/app-list/AppCard.test.jsx b/src/pages-and-resources/discussions/app-list/AppCard.test.jsx
index 5a4391d454..5280f8420d 100644
--- a/src/pages-and-resources/discussions/app-list/AppCard.test.jsx
+++ b/src/pages-and-resources/discussions/app-list/AppCard.test.jsx
@@ -11,8 +11,8 @@ import messages from './messages';
import appMessages from '../app-config-form/messages';
import initializeStore from '../../../store';
import { executeThunk } from '../../../utils';
-import { getAppsUrl } from '../data/api';
-import { fetchApps } from '../data/thunks';
+import { getDiscussionsProvidersUrl } from '../data/api';
+import { fetchProviders } from '../data/thunks';
import { legacyApiResponse } from '../factories/mockApiResponses';
const courseId = 'course-v1:edX+TestX+Test_Course';
@@ -43,8 +43,8 @@ describe('AppCard', () => {
});
const mockStore = async (mockResponse) => {
- axiosMock.onGet(getAppsUrl(courseId)).reply(200, mockResponse);
- await executeThunk(fetchApps(courseId), store.dispatch);
+ axiosMock.onGet(getDiscussionsProvidersUrl(courseId)).reply(200, mockResponse);
+ await executeThunk(fetchProviders(courseId), store.dispatch);
};
const createComponent = (data) => {
diff --git a/src/pages-and-resources/discussions/app-list/AppList.test.jsx b/src/pages-and-resources/discussions/app-list/AppList.test.jsx
index 7ff124d606..abd15fcc90 100644
--- a/src/pages-and-resources/discussions/app-list/AppList.test.jsx
+++ b/src/pages-and-resources/discussions/app-list/AppList.test.jsx
@@ -14,9 +14,12 @@ import { Context as ResponsiveContext } from 'react-responsive';
import initializeStore from '../../../store';
import { executeThunk } from '../../../utils';
-import { getAppsUrl } from '../data/api';
-import { fetchApps } from '../data/thunks';
-import { emptyAppApiResponse, piazzaApiResponse } from '../factories/mockApiResponses';
+import { getDiscussionsProvidersUrl, getDiscussionsSettingsUrl } from '../data/api';
+import { fetchDiscussionSettings, fetchProviders } from '../data/thunks';
+import {
+ generateProvidersApiResponse,
+ piazzaApiResponse,
+} from '../factories/mockApiResponses';
import AppList from './AppList';
import messages from './messages';
@@ -54,23 +57,15 @@ describe('AppList', () => {
});
const mockStore = async (mockResponse, screenWidth = breakpoints.extraLarge.minWidth) => {
- axiosMock.onGet(getAppsUrl(courseId)).reply(200, mockResponse);
- await executeThunk(fetchApps(courseId), store.dispatch);
+ axiosMock.onGet(getDiscussionsProvidersUrl(courseId)).reply(200, generateProvidersApiResponse());
+ axiosMock.onGet(getDiscussionsSettingsUrl(courseId)).reply(200, mockResponse);
+ await executeThunk(fetchProviders(courseId), store.dispatch);
+ await executeThunk(fetchDiscussionSettings(courseId), store.dispatch);
const component = createComponent(screenWidth);
const wrapper = render(component);
container = wrapper.container;
};
- test('displays a message when there are no apps available', async () => {
- await mockStore(emptyAppApiResponse);
- expect(queryByText(container, `${messages.noApps.defaultMessage}`)).toBeInTheDocument();
- });
-
- test('displays loading state when there is no active App', async () => {
- await mockStore({});
- expect(queryByRole(container, 'status')).toBeInTheDocument();
- });
-
test('display a card for each available app', async () => {
await mockStore(piazzaApiResponse);
const appCount = store.getState().discussions.appIds.length;
diff --git a/src/pages-and-resources/discussions/app-list/messages.js b/src/pages-and-resources/discussions/app-list/messages.js
index 92747ce3c3..e45da318ac 100644
--- a/src/pages-and-resources/discussions/app-list/messages.js
+++ b/src/pages-and-resources/discussions/app-list/messages.js
@@ -45,11 +45,34 @@ const messages = defineMessages({
description: 'A label for the checkbox that allows a user to select the discussions app they want to configure.',
},
+ // Legacy
+ 'appName-legacy': {
+ id: 'authoring.discussions.appList.appName-legacy',
+ defaultMessage: 'edX (Legacy)',
+ description: 'The name of the Legacy edX Discussions app.',
+ },
'appDescription-legacy': {
id: 'authoring.discussions.appList.appDescription-legacy',
defaultMessage: 'Start conversations with other learners, ask questions, and interact with other learners in the course.',
description: 'A description of the Legacy edX Discussions app.',
},
+ // New provider
+ 'appName-openedx': {
+ id: 'authoring.discussions.appList.appName-openedx',
+ defaultMessage: 'edX',
+ description: 'The name of the new edX Discussions app.',
+ },
+ 'appDescription-openedx': {
+ id: 'authoring.discussions.appList.appDescription-openedx',
+ defaultMessage: 'Start conversations with other learners, ask questions, and interact with other learners in the course.',
+ description: 'A description of the new edX Discussions app.',
+ },
+ // Piazza
+ 'appName-piazza': {
+ id: 'authoring.discussions.appList.appName-piazza',
+ defaultMessage: 'Piazza',
+ description: 'The name of the Piazza app.',
+ },
'appDescription-piazza': {
id: 'authoring.discussions.appList.appDescription-piazza',
defaultMessage: 'Piazza is designed to connect students, TAs, and professors so every student can get the help they need when they need it.',
diff --git a/src/pages-and-resources/discussions/data/api.js b/src/pages-and-resources/discussions/data/api.js
index 9fa7360881..f99f3fc4e9 100644
--- a/src/pages-and-resources/discussions/data/api.js
+++ b/src/pages-and-resources/discussions/data/api.js
@@ -1,20 +1,19 @@
-import { ensureConfig, getConfig, camelCaseObject } from '@edx/frontend-platform';
+import { camelCaseObject, ensureConfig, getConfig } from '@edx/frontend-platform';
import { getAuthenticatedHttpClient } from '@edx/frontend-platform/auth';
-import _ from 'lodash';
import { v4 as uuid } from 'uuid';
+import { DivisionSchemes } from '../../../data/constants';
import {
checkStatus,
- sortBlackoutDatesByStatus,
+ endOfDayTime,
+ getTime,
mergeDateTime,
normalizeDate,
normalizeTime,
- getTime,
+ sortBlackoutDatesByStatus,
startOfDayTime,
- endOfDayTime,
} from '../app-config-form/utils';
import { blackoutDatesStatus as constants } from './constants';
-import { DivisionSchemes } from '../../../data/constants';
ensureConfig([
'STUDIO_BASE_URL',
@@ -80,23 +79,11 @@ function normalizePiiSharing(data) {
}
function normalizeAppConfig(data) {
- let ltiConfig = {};
- const legacyConfig = {
- id: 'legacy',
+ return {
+ id: data.provider_type,
...normalizePluginConfig(data.plugin_configuration),
+ ...normalizeLtiConfig(data.lti_configuration),
};
- const piiConfig = {
- id: 'pii',
- ...normalizePiiSharing(data.lti_configuration),
- };
- if (data.providers.active !== 'legacy') {
- ltiConfig = {
- id: data.providers.active,
- ...normalizeLtiConfig(data.lti_configuration),
- };
- }
- if (!_.isEmpty(ltiConfig)) { return [legacyConfig, ltiConfig, piiConfig]; }
- return [legacyConfig, piiConfig];
}
function normalizeDiscussionTopic(data) {
@@ -123,8 +110,8 @@ function normalizeFeatures(data, apps) {
);
}
-function normalizeApps(data) {
- const apps = Object.entries(data.providers.available).map(([key, app]) => ({
+function normalizeProviders(data) {
+ const apps = Object.entries(data.available).map(([key, app]) => ({
id: key,
messages: app.messages,
featureIds: app.features,
@@ -139,12 +126,20 @@ function normalizeApps(data) {
adminOnlyConfig: !!app.admin_only_config,
}));
return {
- courseId: data.context_key,
- enabled: data.enabled,
features: normalizeFeatures(data.features, apps),
- appConfigs: normalizeAppConfig(data),
- activeAppId: data.providers.active,
+ activeAppId: data.active,
apps,
+ };
+}
+
+function normalizeSettings(data) {
+ return {
+ enabled: data.enabled,
+ enableInContext: data.enable_in_context,
+ enableGradedUnits: data.enable_graded_units,
+ unitLevelVisibility: data.unit_level_visibility,
+ appConfig: normalizeAppConfig(data),
+ piiConfig: normalizePiiSharing(data.lti_configuration),
discussionTopicIds: data.plugin_configuration.discussion_topics
? extractDiscussionTopicIds(data.plugin_configuration.discussion_topics)
: [],
@@ -181,6 +176,9 @@ function denormalizeData(courseId, appId, data) {
pluginConfiguration.division_scheme = data.divideByCohorts ? DivisionSchemes.COHORT : DivisionSchemes.NONE;
pluginConfiguration.always_divide_inline_discussions = data.divideByCohorts;
}
+ if ('groupAtSubsection' in data) {
+ pluginConfiguration.group_at_subsection = data.groupAtSubsection;
+ }
if (data.blackoutDates?.length) {
pluginConfiguration.discussion_blackouts = data.blackoutDates.map((blackoutDates) => (
denormalizeBlackoutDate(blackoutDates)
@@ -224,31 +222,57 @@ function denormalizeData(courseId, appId, data) {
ltiConfiguration.version = 'lti_1p1';
}
- return {
+ const apiData = {
context_key: courseId,
enabled: true,
lti_configuration: ltiConfiguration,
plugin_configuration: pluginConfiguration,
provider_type: appId,
};
+ if ('enableInContext' in data) {
+ apiData.enable_in_context = data.enableInContext;
+ }
+ if ('enableGradedUnits' in data) {
+ apiData.enable_graded_units = data.enableGradedUnits;
+ }
+ if ('unitLevelVisibility' in data) {
+ apiData.unit_level_visibility = data.unitLevelVisibility;
+ }
+ return apiData;
}
-export function getAppsUrl(courseId) {
- return `${getConfig().STUDIO_BASE_URL}/api/discussions/v0/${courseId}`;
+export function getDiscussionsProvidersUrl(courseId) {
+ return `${getConfig().STUDIO_BASE_URL}/api/discussions/v0/course/${courseId}/providers`;
}
-export async function getApps(courseId) {
+export function getDiscussionsSettingsUrl(courseId) {
+ return `${getConfig().STUDIO_BASE_URL}/api/discussions/v0/course/${courseId}/settings`;
+}
+
+export async function getDiscussionsProviders(courseId) {
+ const { data } = await getAuthenticatedHttpClient()
+ .get(getDiscussionsProvidersUrl(courseId));
+
+ return normalizeProviders(data);
+}
+
+export async function getDiscussionsSettings(courseId, providerId = null) {
+ const params = {};
+ if (providerId) {
+ params.params = { provider_id: providerId };
+ }
+ const url = getDiscussionsSettingsUrl(courseId);
const { data } = await getAuthenticatedHttpClient()
- .get(getAppsUrl(courseId));
+ .get(url, params);
- return normalizeApps(data);
+ return normalizeSettings(data);
}
-export async function postAppConfig(courseId, appId, values) {
+export async function postDiscussionsSettings(courseId, appId, values) {
const { data } = await getAuthenticatedHttpClient().post(
- getAppsUrl(courseId),
+ getDiscussionsSettingsUrl(courseId),
denormalizeData(courseId, appId, values),
);
- return normalizeApps(data);
+ return normalizeSettings(data);
}
diff --git a/src/pages-and-resources/discussions/data/redux.test.js b/src/pages-and-resources/discussions/data/redux.test.js
index f02b801e11..5d70e609bc 100644
--- a/src/pages-and-resources/discussions/data/redux.test.js
+++ b/src/pages-and-resources/discussions/data/redux.test.js
@@ -1,17 +1,17 @@
+import { history } from '@edx/frontend-platform';
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 MockAdapter from 'axios-mock-adapter';
+import { DivisionSchemes } from '../../../data/constants';
+import { LOADED } from '../../../data/slice';
import initializeStore from '../../../store';
-import { getAppsUrl } from './api';
+import { executeThunk } from '../../../utils';
+import { generateProvidersApiResponse, legacyApiResponse, piazzaApiResponse } from '../factories/mockApiResponses';
+import { getDiscussionsProvidersUrl, getDiscussionsSettingsUrl } from './api';
import {
- FAILED, SAVED, DENIED, selectApp, updateValidationStatus,
+ DENIED, FAILED, SAVED, selectApp, updateValidationStatus,
} from './slice';
-import { fetchApps, saveAppConfig } from './thunks';
-import { LOADED } from '../../../data/slice';
-import { legacyApiResponse, piazzaApiResponse } from '../factories/mockApiResponses';
-import { executeThunk } from '../../../utils';
-import { DivisionSchemes } from '../../../data/constants';
+import { fetchDiscussionSettings, fetchProviders, saveProviderConfig } from './thunks';
const courseId = 'course-v1:edX+TestX+Test_Course';
const pagesAndResourcesPath = `/course/${courseId}/pages-and-resources`;
@@ -116,11 +116,11 @@ describe('Data layer integration tests', () => {
axiosMock.reset();
});
- describe('fetchApps', () => {
+ describe('fetchProviders', () => {
test('network error', async () => {
- axiosMock.onGet(getAppsUrl(courseId)).networkError();
+ axiosMock.onGet(getDiscussionsProvidersUrl(courseId)).networkError();
- await executeThunk(fetchApps(courseId), store.dispatch);
+ await executeThunk(fetchProviders(courseId), store.dispatch);
expect(store.getState().discussions).toEqual(
expect.objectContaining({
@@ -136,9 +136,9 @@ describe('Data layer integration tests', () => {
});
test('permission denied error', async () => {
- axiosMock.onGet(getAppsUrl(courseId)).reply(403);
+ axiosMock.onGet(getDiscussionsProvidersUrl(courseId)).reply(403);
- await executeThunk(fetchApps(courseId), store.dispatch);
+ await executeThunk(fetchProviders(courseId), store.dispatch);
expect(store.getState().discussions).toEqual(
expect.objectContaining({
@@ -154,11 +154,13 @@ describe('Data layer integration tests', () => {
});
test('successfully loads an LTI configuration', async () => {
- axiosMock.onGet(getAppsUrl(courseId)).reply(200, piazzaApiResponse);
+ axiosMock.onGet(getDiscussionsProvidersUrl(courseId)).reply(200, generateProvidersApiResponse());
+ axiosMock.onGet(getDiscussionsSettingsUrl(courseId)).reply(200, piazzaApiResponse);
- await executeThunk(fetchApps(courseId), store.dispatch);
+ await executeThunk(fetchProviders(courseId), store.dispatch);
+ await executeThunk(fetchDiscussionSettings(courseId), store.dispatch);
- expect(store.getState().discussions).toEqual({
+ expect(store.getState().discussions).toEqual(expect.objectContaining({
appIds: ['legacy', 'piazza', 'discourse'],
featureIds,
activeAppId: 'piazza',
@@ -167,7 +169,7 @@ describe('Data layer integration tests', () => {
saveStatus: SAVED,
hasValidationError: false,
discussionTopicIds: [],
- });
+ }));
expect(store.getState().models.apps.legacy).toEqual(legacyApp);
expect(store.getState().models.apps.piazza).toEqual(piazzaApp);
expect(store.getState().models.features).toEqual(featuresState);
@@ -180,7 +182,8 @@ describe('Data layer integration tests', () => {
});
test('successfully loads an LTI configuration with PII Sharing', async () => {
- axiosMock.onGet(getAppsUrl(courseId)).reply(200, {
+ axiosMock.onGet(getDiscussionsProvidersUrl(courseId)).reply(200, generateProvidersApiResponse());
+ axiosMock.onGet(getDiscussionsSettingsUrl(courseId)).reply(200, {
...piazzaApiResponse,
lti_configuration: {
...piazzaApiResponse.lti_configuration,
@@ -190,9 +193,10 @@ describe('Data layer integration tests', () => {
},
});
- await executeThunk(fetchApps(courseId), store.dispatch);
+ await executeThunk(fetchProviders(courseId), store.dispatch);
+ await executeThunk(fetchDiscussionSettings(courseId), store.dispatch);
- expect(store.getState().discussions).toEqual({
+ expect(store.getState().discussions).toEqual(expect.objectContaining({
appIds: ['legacy', 'piazza', 'discourse'],
featureIds,
activeAppId: 'piazza',
@@ -201,7 +205,7 @@ describe('Data layer integration tests', () => {
saveStatus: SAVED,
hasValidationError: false,
discussionTopicIds: [],
- });
+ }));
expect(store.getState().models.apps.legacy).toEqual(legacyApp);
expect(store.getState().models.apps.piazza).toEqual(piazzaApp);
expect(store.getState().models.features).toEqual(featuresState);
@@ -214,12 +218,14 @@ describe('Data layer integration tests', () => {
});
test('successfully loads a Legacy configuration', async () => {
- axiosMock.onGet(getAppsUrl(courseId)).reply(200, legacyApiResponse);
+ axiosMock.onGet(getDiscussionsProvidersUrl(courseId)).reply(200, generateProvidersApiResponse(false, 'legacy'));
+ axiosMock.onGet(getDiscussionsSettingsUrl(courseId)).reply(200, legacyApiResponse);
- await executeThunk(fetchApps(courseId), store.dispatch);
+ await executeThunk(fetchProviders(courseId), store.dispatch);
+ await executeThunk(fetchDiscussionSettings(courseId), store.dispatch);
- expect(store.getState().discussions).toEqual({
- appIds: ['legacy', 'piazza'],
+ expect(store.getState().discussions).toEqual(expect.objectContaining({
+ appIds: ['legacy', 'piazza', 'discourse'],
featureIds,
activeAppId: 'legacy',
selectedAppId: null,
@@ -228,7 +234,7 @@ describe('Data layer integration tests', () => {
hasValidationError: false,
discussionTopicIds,
divideDiscussionIds: [],
- });
+ }));
expect(store.getState().models.apps.legacy).toEqual(legacyApp);
expect(store.getState().models.apps.piazza).toEqual(piazzaApp);
expect(store.getState().models.features).toEqual(featuresState);
@@ -269,13 +275,15 @@ describe('Data layer integration tests', () => {
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();
+ axiosMock.onGet(getDiscussionsProvidersUrl(courseId)).reply(200, generateProvidersApiResponse());
+ axiosMock.onGet(getDiscussionsSettingsUrl(courseId)).reply(200, piazzaApiResponse);
+ axiosMock.onPost(getDiscussionsSettingsUrl(courseId)).networkError();
- // We call fetchApps and selectApp here too just to get us into a real state.
- await executeThunk(fetchApps(courseId), store.dispatch);
+ // We call fetchProviders and selectApp here too just to get us into a real state.
+ await executeThunk(fetchProviders(courseId), store.dispatch);
+ await executeThunk(fetchDiscussionSettings(courseId), store.dispatch);
store.dispatch(selectApp({ appId: 'piazza' }));
- await executeThunk(saveAppConfig(courseId, 'piazza', {}, pagesAndResourcesPath), store.dispatch);
+ await executeThunk(saveProviderConfig(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`);
@@ -295,13 +303,14 @@ 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);
+ axiosMock.onGet(getDiscussionsProvidersUrl(courseId)).reply(200, generateProvidersApiResponse());
+ axiosMock.onGet(getDiscussionsSettingsUrl(courseId)).reply(200, piazzaApiResponse);
+ axiosMock.onPost(getDiscussionsSettingsUrl(courseId)).reply(403);
- // We call fetchApps and selectApp here too just to get us into a real state.
- await executeThunk(fetchApps(courseId), store.dispatch);
+ // We call fetchProviders and selectApp here too just to get us into a real state.
+ await executeThunk(fetchProviders(courseId), store.dispatch);
store.dispatch(selectApp({ appId: 'piazza' }));
- await executeThunk(saveAppConfig(courseId, 'piazza', {}, pagesAndResourcesPath), store.dispatch);
+ await executeThunk(saveProviderConfig(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`);
@@ -311,7 +320,7 @@ describe('Data layer integration tests', () => {
featureIds,
activeAppId: 'piazza',
selectedAppId: 'piazza',
- status: DENIED, // We set BOTH statuses to DENIED for saveAppConfig - this removes the UI.
+ status: DENIED, // We set BOTH statuses to DENIED for saveProviderConfig - this removes the UI.
saveStatus: DENIED,
hasValidationError: false,
}),
@@ -321,8 +330,9 @@ describe('Data layer integration tests', () => {
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), {
+ axiosMock.onGet(getDiscussionsProvidersUrl(courseId)).reply(200, generateProvidersApiResponse());
+ axiosMock.onGet(getDiscussionsSettingsUrl(courseId)).reply(200, piazzaApiResponse);
+ axiosMock.onPost(getDiscussionsSettingsUrl(courseId), {
context_key: courseId,
enabled: true,
lti_configuration: {
@@ -344,10 +354,10 @@ 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);
+ // We call fetchProviders and selectApp here too just to get us into a real state.
+ await executeThunk(fetchProviders(courseId), store.dispatch);
store.dispatch(selectApp({ appId: 'piazza' }));
- await executeThunk(saveAppConfig(
+ await executeThunk(saveProviderConfig(
courseId,
'piazza',
{
@@ -381,8 +391,9 @@ 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), {
+ axiosMock.onGet(getDiscussionsProvidersUrl(courseId)).reply(200, generateProvidersApiResponse(false, 'legacy'));
+ axiosMock.onGet(getDiscussionsSettingsUrl(courseId)).reply(200, legacyApiResponse);
+ axiosMock.onPost(getDiscussionsSettingsUrl(courseId), {
context_key: courseId,
enabled: true,
lti_configuration: {},
@@ -422,10 +433,11 @@ 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);
+ // We call fetchProviders and selectApp here too just to get us into a real state.
+ await executeThunk(fetchProviders(courseId), store.dispatch);
+ await executeThunk(fetchDiscussionSettings(courseId), store.dispatch);
store.dispatch(selectApp({ appId: 'legacy' }));
- await executeThunk(saveAppConfig(
+ await executeThunk(saveProviderConfig(
courseId,
'legacy',
{
@@ -450,7 +462,7 @@ describe('Data layer integration tests', () => {
expect(window.location.pathname).toEqual(pagesAndResourcesPath);
expect(store.getState().discussions).toEqual(
expect.objectContaining({
- appIds: ['legacy', 'piazza'],
+ appIds: ['legacy', 'piazza', 'discourse'],
featureIds,
activeAppId: 'legacy',
selectedAppId: 'legacy',
diff --git a/src/pages-and-resources/discussions/data/slice.js b/src/pages-and-resources/discussions/data/slice.js
index 1d79b06ed8..174b31e12e 100644
--- a/src/pages-and-resources/discussions/data/slice.js
+++ b/src/pages-and-resources/discussions/data/slice.js
@@ -25,16 +25,15 @@ const slice = createSlice({
hasValidationError: false,
discussionTopicIds: [],
divideDiscussionIds: [],
+ enableInContext: false,
+ enableGradedUnits: false,
+ unitLevelVisibility: false,
},
reducers: {
loadApps: (state, { payload }) => {
- state.activeAppId = payload.activeAppId;
- state.appIds = payload.appIds;
- state.featureIds = payload.featureIds;
state.status = LOADED;
state.saveStatus = SAVED;
- state.discussionTopicIds = payload.discussionTopicIds;
- state.divideDiscussionIds = payload.divideDiscussionIds;
+ Object.assign(state, payload);
},
selectApp: (state, { payload }) => {
const { appId } = payload;
diff --git a/src/pages-and-resources/discussions/data/thunks.js b/src/pages-and-resources/discussions/data/thunks.js
index a155a17c17..660403a280 100644
--- a/src/pages-and-resources/discussions/data/thunks.js
+++ b/src/pages-and-resources/discussions/data/thunks.js
@@ -1,52 +1,63 @@
import { history } from '@edx/frontend-platform';
-import { addModels } from '../../../generic/model-store';
-import { getApps, postAppConfig } from './api';
+import { addModel, addModels } from '../../../generic/model-store';
+import { getDiscussionsProviders, getDiscussionsSettings, postDiscussionsSettings } from './api';
import {
- FAILED,
- loadApps,
- LOADING,
- SAVED,
- SAVING,
- updateStatus,
- updateSaveStatus,
- DENIED,
+ DENIED, FAILED, loadApps, LOADING, SAVED, SAVING, updateSaveStatus, updateStatus,
} from './slice';
-function updateAppState({
+function updateDiscussionSettingsState({
+ appConfig,
+ discussionTopics,
+ ...discussionSettings
+}) {
+ return async (dispatch) => {
+ dispatch(addModel({ modelType: 'appConfigs', model: appConfig }));
+ dispatch(addModels({ modelType: 'discussionTopics', models: discussionTopics }));
+ dispatch(loadApps(discussionSettings));
+ };
+}
+
+function updateProviderState({
apps,
features,
activeAppId,
- appConfigs,
- discussionTopicIds,
- discussionTopics,
- divideDiscussionIds,
- userPermissions,
}) {
return async (dispatch) => {
dispatch(addModels({ modelType: 'apps', models: apps }));
dispatch(addModels({ modelType: 'features', models: features }));
- dispatch(addModels({ modelType: 'appConfigs', models: appConfigs }));
- dispatch(addModels({ modelType: 'discussionTopics', models: discussionTopics }));
dispatch(
loadApps({
activeAppId,
appIds: apps.map((app) => app.id),
featureIds: features.map((feature) => feature.id),
- discussionTopicIds,
- divideDiscussionIds,
- userPermissions,
}),
);
};
}
-export function fetchApps(courseId) {
+export function fetchProviders(courseId) {
+ return async (dispatch) => {
+ dispatch(updateStatus({ status: LOADING }));
+ try {
+ const apps = await getDiscussionsProviders(courseId);
+ dispatch(updateProviderState(apps));
+ } catch (error) {
+ if (error.response && error.response.status === 403) {
+ dispatch(updateStatus({ status: DENIED }));
+ } else {
+ dispatch(updateStatus({ status: FAILED }));
+ }
+ }
+ };
+}
+
+export function fetchDiscussionSettings(courseId, providerId = null) {
return async (dispatch) => {
dispatch(updateStatus({ status: LOADING }));
try {
- const apps = await getApps(courseId);
- dispatch(updateAppState(apps));
+ const apps = await getDiscussionsSettings(courseId, providerId);
+ dispatch(updateDiscussionSettingsState(apps));
} catch (error) {
if (error.response && error.response.status === 403) {
dispatch(updateStatus({ status: DENIED }));
@@ -57,13 +68,13 @@ export function fetchApps(courseId) {
};
}
-export function saveAppConfig(courseId, appId, drafts, successPath) {
+export function saveProviderConfig(courseId, appId, drafts, successPath) {
return async (dispatch) => {
dispatch(updateSaveStatus({ status: SAVING }));
try {
- const apps = await postAppConfig(courseId, appId, drafts);
- dispatch(updateAppState(apps));
+ const apps = await postDiscussionsSettings(courseId, appId, drafts);
+ dispatch(updateDiscussionSettingsState(apps));
dispatch(updateSaveStatus({ status: SAVED }));
// Note that we redirect here to avoid having to work with the promise over in AppConfigForm.
diff --git a/src/pages-and-resources/discussions/factories/mockApiResponses.js b/src/pages-and-resources/discussions/factories/mockApiResponses.js
index 71ad23c2b8..0d165b6674 100644
--- a/src/pages-and-resources/discussions/factories/mockApiResponses.js
+++ b/src/pages-and-resources/discussions/factories/mockApiResponses.js
@@ -1,15 +1,9 @@
import { DivisionSchemes } from '../../../data/constants';
-export const generatePiazzaApiResponse = (piazzaAdminOnlyConfig = false, piiSharingAllowed = false) => ({
+export const generatePiazzaApiResponse = (piiSharingAllowed = false) => ({
context_key: 'course-v1:edX+DemoX+Demo_Course',
enabled: true,
provider_type: 'piazza',
- features: [
- { id: 'discussion-page', feature_support_type: 'basic' },
- { id: 'embedded-course-sections', feature_support_type: 'full' },
- { id: 'wcag-2.1', feature_support_type: 'partial' },
- { id: 'basic-configuration', feature_support_type: 'common' },
- ],
lti_configuration: {
lti_1p1_client_key: 'client_key_123',
lti_1p1_client_secret: 'client_secret_123',
@@ -20,63 +14,70 @@ export const generatePiazzaApiResponse = (piazzaAdminOnlyConfig = false, piiShar
version: 'lti_1p1',
},
plugin_configuration: {},
- providers: {
- active: 'piazza',
- available: {
- legacy: {
- features: [
- 'discussion-page',
- 'embedded-course-sections',
- 'wcag-2.1',
- ],
- external_links: {
- learn_more: '',
- configuration: '',
- general: '',
- accessibility: '',
- contact_email: '',
- },
- messages: [],
- has_full_support: true,
- admin_only_config: false,
+});
+
+export const generateProvidersApiResponse = (piazzaAdminOnlyConfig = false, activeProvider = 'piazza') => ({
+ active: activeProvider,
+ features: [
+ { id: 'discussion-page', feature_support_type: 'basic' },
+ { id: 'embedded-course-sections', feature_support_type: 'full' },
+ { id: 'wcag-2.1', feature_support_type: 'partial' },
+ { id: 'basic-configuration', feature_support_type: 'common' },
+ ],
+ available: {
+ legacy: {
+ features: [
+ 'discussion-page',
+ 'embedded-course-sections',
+ 'wcag-2.1',
+ ],
+ external_links: {
+ learn_more: '',
+ configuration: '',
+ general: '',
+ accessibility: '',
+ contact_email: '',
},
- piazza: {
- features: [
- // We give piazza all features just so we can test our "full support" text.
- 'discussion-page',
- 'embedded-course-sections',
- 'wcag-2.1',
- 'basic-configuration',
- ],
- external_links: {
- learn_more: '',
- configuration: '',
- general: '',
- accessibility: '',
- contact_email: '',
- },
- messages: [],
- has_full_support: false,
- admin_only_config: piazzaAdminOnlyConfig,
+ messages: [],
+ has_full_support: true,
+ admin_only_config: false,
+ },
+ piazza: {
+ features: [
+ // We give piazza all features just so we can test our "full support" text.
+ 'discussion-page',
+ 'embedded-course-sections',
+ 'wcag-2.1',
+ 'basic-configuration',
+ ],
+ external_links: {
+ learn_more: '',
+ configuration: '',
+ general: '',
+ accessibility: '',
+ contact_email: '',
},
- discourse: {
- features: [
- 'discussion-page',
- 'embedded-course-sections',
- 'wcag-2.1',
- 'lti',
- ],
- external_links: {
- learn_more: '',
- configuration: '',
- general: '',
- accessibility: '',
- contact_email: '',
- },
- messages: [],
- has_full_support: false,
- admin_only_config: false,
+ messages: [],
+ has_full_support: false,
+ admin_only_config: piazzaAdminOnlyConfig,
+ },
+ discourse: {
+ features: [
+ 'discussion-page',
+ 'embedded-course-sections',
+ 'wcag-2.1',
+ 'lti',
+ ],
+ external_links: {
+ learn_more: '',
+ configuration: '',
+ general: '',
+ accessibility: '',
+ contact_email: '',
},
+ messages: [],
+ has_full_support: false,
+ admin_only_config: false,
},
},
});
@@ -85,12 +86,6 @@ export const generateLegacyApiResponse = () => ({
context_key: 'course-v1:edX+DemoX+Demo_Course',
enabled: true,
provider_type: 'legacy',
- features: [
- { id: 'discussion-page', feature_support_type: 'basic' },
- { id: 'embedded-course-sections', feature_support_type: 'full' },
- { id: 'wcag-2.1', feature_support_type: 'partial' },
- { id: 'basic-configuration', feature_support_type: 'common' },
- ],
lti_configuration: {},
plugin_configuration: {
allow_anonymous: false,
@@ -125,7 +120,6 @@ export const generateLegacyApiResponse = () => ({
},
messages: [],
has_full_support: true,
- admin_only_config: false,
},
piazza: {
features: [
@@ -144,7 +138,6 @@ export const generateLegacyApiResponse = () => ({
},
messages: [],
has_full_support: false,
- admin_only_config: false,
},
},
},
@@ -165,7 +158,7 @@ export const emptyAppApiResponse = {
},
};
-export const piazzaApiResponse = generatePiazzaApiResponse(false);
+export const piazzaApiResponse = generatePiazzaApiResponse();
export const courseDetailResponse = {
blocks_url: 'http://localhost:18000/api/courses/v2/blocks/?course_id=course-v1%3AedX%2BDemoX%2BDemo_Course',