diff --git a/package.json b/package.json
index 369247abc..adc79e5f8 100644
--- a/package.json
+++ b/package.json
@@ -59,6 +59,7 @@
"react-transition-group": "4.4.1",
"redux": "4.0.5",
"regenerator-runtime": "0.13.7",
+ "uuid": "^3.4.0",
"yup": "0.31.1"
},
"devDependencies": {
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 692eb2608..682f36905 100644
--- a/src/pages-and-resources/discussions/app-config-form/AppConfigForm.jsx
+++ b/src/pages-and-resources/discussions/app-config-form/AppConfigForm.jsx
@@ -7,7 +7,7 @@ import { useRouteMatch } from 'react-router';
import { injectIntl, intlShape } from '@edx/frontend-platform/i18n';
import { Container } from '@edx/paragon';
-import { useModel } from '../../../generic/model-store';
+import { useModel, useModels } from '../../../generic/model-store';
import { PagesAndResourcesContext } from '../../PagesAndResourcesProvider';
import {
FAILED, LOADED, LOADING, selectApp,
@@ -29,12 +29,16 @@ function AppConfigForm({
const { formRef } = useContext(AppConfigFormContext);
const { path: pagesAndResourcesPath } = useContext(PagesAndResourcesContext);
const { params: { appId: routeAppId } } = useRouteMatch();
- const { selectedAppId, status, saveStatus } = useSelector(state => state.discussions);
+ const {
+ selectedAppId, status, saveStatus, discussionTopicIds,
+ } = 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.,
// if we're configuring a new app.
- const appConfig = useModel('appConfigs', selectedAppId);
+ const appConfigObj = useModel('appConfigs', selectedAppId);
+ const discussionTopics = useModels('discussionTopics', discussionTopicIds);
+ const appConfig = { ...appConfigObj, discussionTopics };
useEffect(() => {
if (status === LOADED) {
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/legacy/LegacyConfigForm.jsx
index 22b0d4c15..a7cbd1f8c 100644
--- a/src/pages-and-resources/discussions/app-config-form/apps/legacy/LegacyConfigForm.jsx
+++ b/src/pages-and-resources/discussions/app-config-form/apps/legacy/LegacyConfigForm.jsx
@@ -1,12 +1,13 @@
import React from 'react';
import PropTypes from 'prop-types';
import { Card, Form } from '@edx/paragon';
-import { useFormik } from 'formik';
+import { Formik } from 'formik';
import * as Yup from 'yup';
import { injectIntl, intlShape } from '@edx/frontend-platform/i18n';
import DivisionByGroupFields from '../shared/DivisionByGroupFields';
import AnonymousPostingFields from '../shared/AnonymousPostingFields';
+import DiscussionTopics from '../shared/discussion-topics/DiscussionTopics';
import BlackoutDatesField, { blackoutDatesRegex } from '../shared/BlackoutDatesField';
import messages from '../shared/messages';
@@ -15,48 +16,80 @@ import AppConfigFormDivider from '../shared/AppConfigFormDivider';
function LegacyConfigForm({
appConfig, onSubmit, formRef, intl, title,
}) {
- const {
- handleSubmit,
- handleChange,
- handleBlur,
- values,
- errors,
- } = useFormik({
- initialValues: appConfig,
- validationSchema: Yup.object().shape({
- blackoutDates: Yup.string().matches(
- blackoutDatesRegex,
- intl.formatMessage(messages.blackoutDatesFormattingError),
- ),
- }),
- onSubmit,
+ const legacyFormValidationSchema = Yup.object().shape({
+ blackoutDates: Yup.string().matches(
+ blackoutDatesRegex,
+ intl.formatMessage(messages.blackoutDatesFormattingError),
+ ),
+ discussionTopics: Yup.array()
+ .of(
+ Yup.object().shape({
+ name: Yup.string().required(intl.formatMessage(messages.discussionTopicRequired)),
+ }),
+ ).test('unique', (
+ discussionTopics,
+ testContext,
+ message = intl.formatMessage(messages.discussionTopicNameAlreadyExist),
+ ) => {
+ const uniqueDiscussionTopics = [...new Set(discussionTopics.map(topic => topic.name))];
+ const isUnique = discussionTopics.length === uniqueDiscussionTopics.length;
+ if (isUnique) {
+ return true;
+ }
+
+ const duplicateNameIndex = discussionTopics.findIndex(
+ (topic, index) => topic.name !== uniqueDiscussionTopics[index],
+ );
+ return testContext.createError({
+ path: `[${duplicateNameIndex}].name`,
+ message,
+ });
+ }),
});
return (
-
-
-
+ (onSubmit(values))}
+ >
+ {(
+ {
+ handleSubmit,
+ handleChange,
+ handleBlur,
+ values,
+ errors,
+ },
+ ) => (
+
+
+
+ )}
+
);
}
@@ -69,6 +102,9 @@ LegacyConfigForm.propTypes = {
allowAnonymousPosts: PropTypes.bool.isRequired,
allowAnonymousPostsPeers: PropTypes.bool.isRequired,
blackoutDates: PropTypes.string.isRequired,
+ discussionTopics: PropTypes.arrayOf(PropTypes.shape({
+ name: PropTypes.string.isRequired,
+ })),
}),
onSubmit: PropTypes.func.isRequired,
// eslint-disable-next-line react/forbid-prop-types
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/legacy/LegacyConfigForm.test.jsx
index 7d08b0063..429a621a3 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/legacy/LegacyConfigForm.test.jsx
@@ -1,122 +1,171 @@
import React, { createRef } from 'react';
import { act, render } from '@testing-library/react';
import { IntlProvider } from '@edx/frontend-platform/i18n';
+import { initializeMockApp } from '@edx/frontend-platform';
+import { AppProvider } from '@edx/frontend-platform/react';
+import { getAuthenticatedHttpClient } from '@edx/frontend-platform/auth';
+import MockAdapter from 'axios-mock-adapter';
+
+import { getAppsUrl } from '../../../data/api';
+import { fetchApps } from '../../../data/thunks';
+import initializeStore from '../../../../../store';
+import executeThunk from '../../../../../utils';
+import {
+ legacyApiResponse,
+} from '../../../factories/mockApiResponses';
import LegacyConfigForm from './LegacyConfigForm';
+const courseId = 'course-v1:edX+TestX+Test_Course';
+
const defaultAppConfig = {
id: 'legacy',
divideByCohorts: false,
divideCourseWideTopics: false,
divideGeneralTopic: false,
divideQuestionsForTAsTopic: false,
+ discussionTopics: [
+ { name: 'General', id: 'course-generated-id-123-client-made-this-up' },
+ { name: 'Edx', id: '13f106c6-6735-4e84-b097-0456cff55960' },
+ ],
allowAnonymousPosts: false,
allowAnonymousPostsPeers: false,
blackoutDates: '[]',
};
describe('LegacyConfigForm', () => {
- test('title rendering', () => {
- const { container } = render(
-
-
- ,
+ let axiosMock;
+ let store;
+ let container;
+
+ beforeEach(() => {
+ initializeMockApp({
+ authenticatedUser: {
+ userId: 3,
+ username: 'abc123',
+ administrator: true,
+ roles: [],
+ },
+ });
+
+ axiosMock = new MockAdapter(getAuthenticatedHttpClient());
+ store = initializeStore();
+ });
+
+ afterEach(() => {
+ axiosMock.reset();
+ });
+
+ const createComponent = (appConfig, onSubmit = jest.fn(), formRef = createRef()) => {
+ const wrapper = render(
+
+
+
+
+ ,
);
+ container = wrapper.container;
+ return container;
+ };
+
+ const mockStore = async (mockResponse) => {
+ axiosMock.onGet(getAppsUrl(courseId)).reply(200, mockResponse);
+ await executeThunk(fetchApps(courseId), store.dispatch);
+ };
+
+ test('title rendering', async () => {
+ await mockStore(legacyApiResponse);
+ createComponent(defaultAppConfig);
expect(container.querySelector('h3')).toHaveTextContent('Test Legacy edX Discussions');
});
+
test('calls onSubmit when the formRef is submitted', async () => {
const formRef = createRef();
const handleSubmit = jest.fn();
- render(
-
-
- ,
- );
+ await mockStore(legacyApiResponse);
+ createComponent(defaultAppConfig, handleSubmit, formRef);
await act(async () => {
formRef.current.submit();
});
+
expect(handleSubmit).toHaveBeenCalledWith(
// Because we use defaultAppConfig as the initialValues of the form, and we haven't changed
// any of the form inputs, this exact object shape is returned back to us, so we're reusing
// it here. It's not supposed to be 'the same object', it just happens to be.
defaultAppConfig,
- // The second argument is a Formik object with all sorts of functions on it which we don't
- // care about.
- expect.anything(),
);
});
- test('default field states are correct, including removal of folded sub-fields', () => {
- const { container } = render(
-
-
- ,
- );
+ test('default field states are correct, including removal of folded sub-fields', async () => {
+ await mockStore(legacyApiResponse);
+ createComponent(defaultAppConfig);
// DivisionByGroupFields
expect(container.querySelector('#divideByCohorts')).toBeInTheDocument();
expect(container.querySelector('#divideByCohorts')).not.toBeChecked();
- expect(container.querySelector('#divideCourseWideTopics')).not.toBeInTheDocument();
- expect(container.querySelector('#divideGeneralTopic')).not.toBeInTheDocument();
- expect(container.querySelector('#divideQuestionsForTAsTopic')).not.toBeInTheDocument();
+ expect(
+ container.querySelector('#divideCourseWideTopics'),
+ ).not.toBeInTheDocument();
+ expect(
+ container.querySelector('#divideGeneralTopic'),
+ ).not.toBeInTheDocument();
+ expect(
+ container.querySelector('#divideQuestionsForTAsTopic'),
+ ).not.toBeInTheDocument();
// AnonymousPostingFields
expect(container.querySelector('#allowAnonymousPosts')).toBeInTheDocument();
expect(container.querySelector('#allowAnonymousPosts')).not.toBeChecked();
- expect(container.querySelector('#allowAnonymousPostsPeers')).not.toBeInTheDocument();
+ expect(
+ container.querySelector('#allowAnonymousPostsPeers'),
+ ).not.toBeInTheDocument();
// BlackoutDatesField
expect(container.querySelector('#blackoutDates')).toBeInTheDocument();
expect(container.querySelector('#blackoutDates')).toHaveValue('[]');
});
- test('folded sub-fields are in the DOM when parents are enabled', () => {
- const { container } = render(
-
-
- ,
- );
+ test('folded sub-fields are in the DOM when parents are enabled', async () => {
+ await mockStore(legacyApiResponse);
+ createComponent({
+ ...defaultAppConfig,
+ divideByCohorts: true,
+ allowAnonymousPosts: true,
+ });
// DivisionByGroupFields
expect(container.querySelector('#divideByCohorts')).toBeInTheDocument();
expect(container.querySelector('#divideByCohorts')).toBeChecked();
- expect(container.querySelector('#divideCourseWideTopics')).toBeInTheDocument();
- expect(container.querySelector('#divideCourseWideTopics')).not.toBeChecked();
+ expect(
+ container.querySelector('#divideCourseWideTopics'),
+ ).toBeInTheDocument();
+ expect(
+ container.querySelector('#divideCourseWideTopics'),
+ ).not.toBeChecked();
expect(container.querySelector('#divideGeneralTopic')).toBeInTheDocument();
expect(container.querySelector('#divideGeneralTopic')).not.toBeChecked();
- expect(container.querySelector('#divideQuestionsForTAsTopic')).toBeInTheDocument();
- expect(container.querySelector('#divideQuestionsForTAsTopic')).not.toBeChecked();
+ expect(
+ container.querySelector('#divideQuestionsForTAsTopic'),
+ ).toBeInTheDocument();
+ expect(
+ container.querySelector('#divideQuestionsForTAsTopic'),
+ ).not.toBeChecked();
// AnonymousPostingFields
expect(container.querySelector('#allowAnonymousPosts')).toBeInTheDocument();
expect(container.querySelector('#allowAnonymousPosts')).toBeChecked();
- expect(container.querySelector('#allowAnonymousPostsPeers')).toBeInTheDocument();
- expect(container.querySelector('#allowAnonymousPostsPeers')).not.toBeChecked();
+ expect(
+ container.querySelector('#allowAnonymousPostsPeers'),
+ ).toBeInTheDocument();
+ expect(
+ container.querySelector('#allowAnonymousPostsPeers'),
+ ).not.toBeChecked();
});
});
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
new file mode 100644
index 000000000..090132d9c
--- /dev/null
+++ b/src/pages-and-resources/discussions/app-config-form/apps/shared/discussion-topics/DiscussionTopics.jsx
@@ -0,0 +1,88 @@
+import React, { useState, useEffect } from 'react';
+import { useDispatch } from 'react-redux';
+import { Add } from '@edx/paragon/icons';
+import { Button } from '@edx/paragon';
+import { injectIntl, intlShape } from '@edx/frontend-platform/i18n';
+import { FieldArray, useFormikContext } from 'formik';
+import { v4 as uuid } from 'uuid';
+
+import messages from '../messages';
+import TopicItem from './TopicItem';
+import { removeModel } from '../../../../../../generic/model-store';
+import { updateDiscussionTopicIds } from '../../../../data/slice';
+import { updatedDiscussionTopics } from '../../../../data/thunks';
+
+const DiscussionTopics = ({ intl }) => {
+ const dispatch = useDispatch();
+ const { values } = useFormikContext();
+ const [topics, setTopics] = useState(values.discussionTopics);
+
+ useEffect(() => {
+ const updatedDiscussionTopicIds = values.discussionTopics?.map(topic => topic.id);
+
+ setTopics(values.discussionTopics);
+ dispatch(updateDiscussionTopicIds(updatedDiscussionTopicIds));
+ dispatch(updatedDiscussionTopics(values.discussionTopics));
+ }, [values.discussionTopics]);
+
+ const handleTopicDelete = (topicIndex, topicId, remove) => {
+ remove(topicIndex);
+ dispatch(removeModel({ modelType: 'discussionTopics', id: topicId }));
+ };
+
+ const addNewTopic = (push) => {
+ const payload = { name: '', id: uuid() };
+ push(payload);
+ };
+
+ return (
+ <>
+
+ {intl.formatMessage(messages.discussionTopics)}
+
+
+
+ {intl.formatMessage(messages.discussionTopicsHelp)}
+
+
+
(
+
+ {
+ topics.map((topic, index) => (
+
handleTopicDelete(index, topic.id, remove)}
+ />
+ ))
+
+ }
+
+
+
+
+
+ )}
+ />
+
+ >
+ );
+};
+
+DiscussionTopics.propTypes = {
+ intl: intlShape.isRequired,
+};
+
+export default injectIntl(DiscussionTopics);
diff --git a/src/pages-and-resources/discussions/app-config-form/apps/shared/discussion-topics/TopicItem.jsx b/src/pages-and-resources/discussions/app-config-form/apps/shared/discussion-topics/TopicItem.jsx
new file mode 100644
index 000000000..73fcc739c
--- /dev/null
+++ b/src/pages-and-resources/discussions/app-config-form/apps/shared/discussion-topics/TopicItem.jsx
@@ -0,0 +1,162 @@
+import React, { useState, useEffect } from 'react';
+import PropTypes from 'prop-types';
+import {
+ Collapsible, Form, Card, Button,
+} from '@edx/paragon';
+import { injectIntl, intlShape } from '@edx/frontend-platform/i18n';
+import { useFormikContext } from 'formik';
+import { ExpandLess, ExpandMore, Delete } from '@edx/paragon/icons';
+import messages from '../messages';
+
+const TopicItem = ({
+ intl, index, name, onDelete,
+}) => {
+ const [title, setTitle] = useState(name);
+ const [showDeletePopup, setShowDeletePopup] = useState(false);
+ const {
+ handleChange,
+ handleBlur,
+ touched,
+ errors,
+ } = useFormikContext();
+
+ useEffect(() => {
+ setTitle(name);
+ }, [name]);
+
+ const isInvalidTopicNameKey = Boolean(
+ (touched.discussionTopics && touched.discussionTopics[index]?.name)
+ && (errors.discussionTopics && errors?.discussionTopics[index]?.name),
+ );
+
+ const isExistingName = Boolean(
+ (touched.discussionTopics && touched.discussionTopics[index]?.name)
+ && (errors && errors[index]?.name),
+ );
+
+ const getHeading = (isOpen = false) => {
+ let heading;
+ if (!title) {
+ heading = Configure topic;
+ } else if (isOpen) {
+ heading = Rename {title} topic;
+ } else {
+ heading = {title};
+ }
+ return heading;
+ };
+
+ const handleToggle = (isOpen) => {
+ if (!isOpen && !isInvalidTopicNameKey) {
+ setTitle(name);
+ }
+ };
+
+ const deleteDiscussionTopic = (event) => {
+ event.stopPropagation();
+ setShowDeletePopup(true);
+ };
+
+ const deletetopic = (
+
+
+
+ {intl.formatMessage(messages.discussionTopicDeletionLabel)}
+
+
+ {intl.formatMessage(messages.discussionTopicDeletionHelp)}
+
+
+
+
+
+
+
+ );
+
+ return (
+ <>
+ {
+ showDeletePopup ? (
+ deletetopic
+ ) : (
+
+
+
+ {getHeading(false)}
+
+
+
+
+
+ {getHeading(true)}
+
+
+
+
+
+
+
+
+
+
+
+ {isInvalidTopicNameKey && (
+
+
+ {intl.formatMessage(messages.discussionTopicRequired)}
+
+
+ )}
+ {isExistingName && (
+
+
+ {intl.formatMessage(messages.discussionTopicNameAlreadyExist)}
+
+
+ )}
+
+
+
+ )
+ }
+ >
+ );
+};
+
+TopicItem.propTypes = {
+ name: PropTypes.string.isRequired,
+ index: PropTypes.number.isRequired,
+ onDelete: PropTypes.func.isRequired,
+ intl: intlShape.isRequired,
+};
+
+export default injectIntl(TopicItem);
diff --git a/src/pages-and-resources/discussions/app-config-form/apps/shared/messages.js b/src/pages-and-resources/discussions/app-config-form/apps/shared/messages.js
index 8b87472b8..4f4e35f02 100644
--- a/src/pages-and-resources/discussions/app-config-form/apps/shared/messages.js
+++ b/src/pages-and-resources/discussions/app-config-form/apps/shared/messages.js
@@ -97,6 +97,56 @@ const messages = defineMessages({
defaultMessage: 'Enter true or false. If true, students can create discussion posts that are anonymous to other students. This setting does not make posts anonymous to course staff.',
},
+ // Discussion Topics
+ discussionTopics: {
+ id: 'authoring.discussions.discussionTopics',
+ defaultMessage: 'Discussion topics',
+ },
+ discussionTopicsLabel: {
+ id: 'authoring.discussions.discussionTopics.label',
+ defaultMessage: 'General discussion topics',
+ description: 'Label for a discussion topic section allowing a user to add new topic.',
+ },
+ discussionTopicsHelp: {
+ id: 'authoring.discussions.discussionTopics.help',
+ defaultMessage: 'Discussions can include general topics not contained to the course structure. All courses have a general topic by default.',
+ description: 'Help text for adding new discussion topics that in general discussion topic section.',
+ },
+ discussionTopicRequired: {
+ id: 'authoring.discussions.discussionTopic.required',
+ defaultMessage: 'Topic name is a required field',
+ description: 'Tells the user that the discussion topic field is required and must have a value.',
+ },
+ discussionTopicNameAlreadyExist: {
+ id: 'authoring.discussions.discussionTopic.alreadyExistError',
+ defaultMessage: 'It looks like this name is already in use',
+ description: 'Tells the user that the discussion topic name already in use and must have a unique name.',
+ },
+ addTopicButton: {
+ id: 'authoring.discussions.addTopicButton',
+ defaultMessage: 'Add Topic',
+ description: 'Button label when Add a new discussion topic.',
+ },
+ deleteButton: {
+ id: 'authoring.discussions.deleteButton',
+ defaultMessage: 'Delete',
+ description: 'Button label when delete discussion topic from conformation card.',
+ },
+ cancelButton: {
+ id: 'authoring.discussions.cancelButton',
+ defaultMessage: 'Cancel',
+ description: 'Button label when cancel discussion topic deletion conformation.',
+ },
+ discussionTopicDeletionHelp: {
+ id: 'authoring.discussions.discussionTopicDeletion.help',
+ defaultMessage: ' edX recommends that you do not delete discussion topics once your course is running.',
+ description: 'Help text for delete a discussion topic from discussion topic section.',
+ },
+ discussionTopicDeletionLabel: {
+ id: 'authoring.discussions.discussionTopicDeletion.label',
+ defaultMessage: 'Delete this topic?',
+ description: 'Label for discussion topic delete popup allowing a user to delete a topic.',
+ },
// Blackout dates
blackoutDates: {
id: 'authoring.discussions.blackoutDates',
diff --git a/src/pages-and-resources/discussions/data/api.js b/src/pages-and-resources/discussions/data/api.js
index e983efe75..56466138c 100644
--- a/src/pages-and-resources/discussions/data/api.js
+++ b/src/pages-and-resources/discussions/data/api.js
@@ -13,6 +13,22 @@ function normalizeLtiConfig(data) {
};
}
+function normalizeDiscussionTopic(data) {
+ return Object.entries(data).map(([key, value]) => (
+ {
+ name: key,
+ id: value.id,
+ }
+ ));
+}
+
+function extractDiscussionTopicIds(data) {
+ return Object.entries(
+ data,
+ // eslint-disable-next-line no-unused-vars
+ ).map(([key, value]) => value.id);
+}
+
function normalizePluginConfig(data) {
if (!data || Object.keys(data).length < 1) {
return {};
@@ -57,6 +73,10 @@ function normalizeApps(data) {
},
activeAppId: data.providers.active,
apps,
+ discussionTopicIds: data.plugin_configuration.discussion_topics
+ ? extractDiscussionTopicIds(data.plugin_configuration.discussion_topics) : [],
+ discussionTopics: data.plugin_configuration.discussion_topics
+ ? normalizeDiscussionTopic(data.plugin_configuration.discussion_topics) : [],
};
}
@@ -81,6 +101,13 @@ function denormalizeData(courseId, appId, data) {
if (data.blackoutDates) {
pluginConfiguration.discussion_blackouts = JSON.parse(data.blackoutDates);
}
+ if (data.discussionTopics?.length) {
+ pluginConfiguration.discussion_topics = data.discussionTopics.reduce((topics, currentTopic) => {
+ const newTopics = { ...topics };
+ newTopics[currentTopic.name] = { id: currentTopic.id };
+ return newTopics;
+ }, {});
+ }
const ltiConfiguration = {};
diff --git a/src/pages-and-resources/discussions/data/redux.test.js b/src/pages-and-resources/discussions/data/redux.test.js
index fd41aa50c..8d630c315 100644
--- a/src/pages-and-resources/discussions/data/redux.test.js
+++ b/src/pages-and-resources/discussions/data/redux.test.js
@@ -132,6 +132,7 @@ describe('Data layer integration tests', () => {
status: LOADED,
saveStatus: SAVED,
hasValidationError: false,
+ discussionTopicIds: [],
});
expect(store.getState().models.apps.legacy).toEqual(legacyApp);
expect(store.getState().models.apps.piazza).toEqual(piazzaApp);
@@ -157,6 +158,10 @@ describe('Data layer integration tests', () => {
status: LOADED,
saveStatus: SAVED,
hasValidationError: false,
+ discussionTopicIds: [
+ '13f106c6-6735-4e84-b097-0456cff55960',
+ 'course-generated-id-123-client-made-this-up',
+ ],
});
expect(store.getState().models.apps.legacy).toEqual(legacyApp);
expect(store.getState().models.apps.piazza).toEqual(piazzaApp);
diff --git a/src/pages-and-resources/discussions/data/slice.js b/src/pages-and-resources/discussions/data/slice.js
index 49092f150..e6dcfa0a6 100644
--- a/src/pages-and-resources/discussions/data/slice.js
+++ b/src/pages-and-resources/discussions/data/slice.js
@@ -23,6 +23,7 @@ const slice = createSlice({
saveStatus: SAVED,
// ValidationError is the Flag that represents a form validation status.
hasValidationError: false,
+ discussionTopicIds: [],
},
reducers: {
loadApps: (state, { payload }) => {
@@ -31,6 +32,7 @@ const slice = createSlice({
state.featureIds = payload.featureIds;
state.status = LOADED;
state.saveStatus = SAVED;
+ state.discussionTopicIds = payload.discussionTopicIds;
},
selectApp: (state, { payload }) => {
const { appId } = payload;
@@ -48,6 +50,9 @@ const slice = createSlice({
const { hasError } = payload;
state.hasValidationError = hasError;
},
+ updateDiscussionTopicIds: (state, { payload }) => {
+ state.discussionTopicIds = payload;
+ },
},
});
@@ -57,6 +62,7 @@ export const {
updateStatus,
updateSaveStatus,
updateValidationStatus,
+ updateDiscussionTopicIds,
} = slice.actions;
export const {
diff --git a/src/pages-and-resources/discussions/data/thunks.js b/src/pages-and-resources/discussions/data/thunks.js
index 5f4248ecc..246842ca2 100644
--- a/src/pages-and-resources/discussions/data/thunks.js
+++ b/src/pages-and-resources/discussions/data/thunks.js
@@ -1,5 +1,7 @@
import { history } from '@edx/frontend-platform';
-import { addModel, addModels } from '../../../generic/model-store';
+import {
+ addModel, addModels, updateModels,
+} from '../../../generic/model-store';
import { getApps, postAppConfig } from './api';
import {
@@ -22,15 +24,20 @@ export function fetchApps(courseId) {
features,
activeAppId,
appConfig,
+ discussionTopicIds,
+ discussionTopics,
} = await getApps(courseId);
dispatch(addModels({ modelType: 'apps', models: apps }));
dispatch(addModels({ modelType: 'features', models: features }));
dispatch(addModel({ modelType: 'appConfigs', model: appConfig }));
+ dispatch(addModels({ modelType: 'discussionTopics', models: discussionTopics }));
+
dispatch(loadApps({
activeAppId,
appIds: apps.map(app => app.id),
featureIds: features.map(feature => feature.id),
+ discussionTopicIds,
}));
} catch (error) {
if (error.response && error.response.status === 403) {
@@ -52,15 +59,20 @@ export function saveAppConfig(courseId, appId, drafts, successPath) {
features,
activeAppId,
appConfig,
+ discussionTopicIds,
+ discussionTopics,
} = await postAppConfig(courseId, appId, drafts);
dispatch(addModels({ modelType: 'apps', models: apps }));
dispatch(addModels({ modelType: 'features', models: features }));
dispatch(addModel({ modelType: 'appConfigs', model: appConfig }));
+ dispatch(addModels({ modelType: 'discussionTopics', models: discussionTopics }));
+
dispatch(loadApps({
activeAppId,
appIds: apps.map(app => app.id),
featureIds: features.map(feature => feature.id),
+ discussionTopicIds,
}));
dispatch(updateSaveStatus({ status: SAVED }));
// Note that we redirect here to avoid having to work with the promise over in AppConfigForm.
@@ -76,3 +88,9 @@ export function saveAppConfig(courseId, appId, drafts, successPath) {
}
};
}
+
+export function updatedDiscussionTopics(payload) {
+ return async (dispatch) => {
+ dispatch(updateModels({ modelType: 'discussionTopics', models: payload }));
+ };
+}
diff --git a/src/pages-and-resources/discussions/factories/mockApiResponses.js b/src/pages-and-resources/discussions/factories/mockApiResponses.js
index a6b8aaac1..585c8e5ed 100644
--- a/src/pages-and-resources/discussions/factories/mockApiResponses.js
+++ b/src/pages-and-resources/discussions/factories/mockApiResponses.js
@@ -48,6 +48,15 @@ export const legacyApiResponse = {
plugin_configuration: {
allow_anonymous: false,
allow_anonymous_to_peers: false,
+ always_divide_inline_discussions: false,
+ available_division_schemes: ['enrollment_track'],
+ discussion_topics: {
+ Edx: { id: '13f106c6-6735-4e84-b097-0456cff55960' },
+ General: { id: 'course-generated-id-123-client-made-this-up' },
+ },
+ divided_course_wide_discussions: [],
+ divided_inline_discussions: [],
+ division_scheme: 'none',
// Note, this gets stringified when normalized into the app, but the API returns it as an
// actual array. Argh.
discussion_blackouts: [],