From 6bb6409d2692cfbfc8b13b153a8590370d252ed4 Mon Sep 17 00:00:00 2001 From: Awais Jibran Date: Fri, 21 May 2021 12:39:24 +0500 Subject: [PATCH] feat: Add UI for Add Topics (#105) * Fixes an issue where Form Switch was unexpectedly getting wrong width from its parent. * style: add discussion topics UI * feat: Add section for general discussion topic in legacy form * style: improve style according to Figma and add name uniqueness validation 1- Improve styling for General discussion topics section 2- Add discussion topic name uniqueness validation using yup test 3- Add internationalisation for discussion topic section * test: Update LegacyForm test cases Update LegacyForm test cases according to new requirements. Add mock store in test cases as we are using redux in DiscussionTopic and manipulating the redux store. * refactor: update variables name and add async type in thunk function Co-authored-by: Awais Ansari --- package.json | 1 + .../app-config-form/AppConfigForm.jsx | 10 +- .../apps/legacy/LegacyConfigForm.jsx | 116 +++++++----- .../apps/legacy/LegacyConfigForm.test.jsx | 167 +++++++++++------- .../discussion-topics/DiscussionTopics.jsx | 88 +++++++++ .../shared/discussion-topics/TopicItem.jsx | 162 +++++++++++++++++ .../app-config-form/apps/shared/messages.js | 50 ++++++ .../discussions/data/api.js | 27 +++ .../discussions/data/redux.test.js | 5 + .../discussions/data/slice.js | 6 + .../discussions/data/thunks.js | 20 ++- .../discussions/factories/mockApiResponses.js | 9 + 12 files changed, 558 insertions(+), 103 deletions(-) create mode 100644 src/pages-and-resources/discussions/app-config-form/apps/shared/discussion-topics/DiscussionTopics.jsx create mode 100644 src/pages-and-resources/discussions/app-config-form/apps/shared/discussion-topics/TopicItem.jsx 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 ( - -
-

{title}

- - - - - - - -
+ (onSubmit(values))} + > + {( + { + handleSubmit, + handleChange, + handleBlur, + values, + errors, + }, + ) => ( + +
+

{title}

+ + + + + + + + + +
+ )} +
); } @@ -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: [],