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 <awais.ansari63@gmail.com>
This commit is contained in:
Awais Jibran
2021-05-21 12:39:24 +05:00
committed by GitHub
parent 442335cf8c
commit 6bb6409d26
12 changed files with 558 additions and 103 deletions

View File

@@ -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": {

View File

@@ -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) {

View File

@@ -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 (
<Card className="mb-5 px-4 px-sm-5 pb-5" data-testid="legacyConfigForm">
<Form ref={formRef} onSubmit={handleSubmit}>
<h3 className="text-primary-500 my-3">{title}</h3>
<AppConfigFormDivider thick />
<AnonymousPostingFields
onBlur={handleBlur}
onChange={handleChange}
values={values}
/>
<AppConfigFormDivider />
<DivisionByGroupFields
onBlur={handleBlur}
onChange={handleChange}
values={values}
/>
<AppConfigFormDivider thick />
<BlackoutDatesField
errors={errors}
onBlur={handleBlur}
onChange={handleChange}
values={values}
/>
</Form>
</Card>
<Formik
initialValues={appConfig}
validationSchema={legacyFormValidationSchema}
onSubmit={(values) => (onSubmit(values))}
>
{(
{
handleSubmit,
handleChange,
handleBlur,
values,
errors,
},
) => (
<Card className="mb-5 px-4 px-sm-5 pb-5" data-testid="legacyConfigForm">
<Form ref={formRef} onSubmit={handleSubmit}>
<h3 className="text-primary-500 my-3">{title}</h3>
<AppConfigFormDivider thick />
<AnonymousPostingFields
onBlur={handleBlur}
onChange={handleChange}
values={values}
/>
<AppConfigFormDivider />
<DivisionByGroupFields
onBlur={handleBlur}
onChange={handleChange}
values={values}
/>
<AppConfigFormDivider thick />
<DiscussionTopics />
<AppConfigFormDivider thick />
<BlackoutDatesField
errors={errors}
onBlur={handleBlur}
onChange={handleChange}
values={values}
/>
</Form>
</Card>
)}
</Formik>
);
}
@@ -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

View File

@@ -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(
<IntlProvider locale="en">
<LegacyConfigForm
title="Test Legacy edX Discussions"
appConfig={defaultAppConfig}
onSubmit={jest.fn()}
formRef={createRef()}
/>
</IntlProvider>,
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(
<AppProvider store={store}>
<IntlProvider locale="en">
<LegacyConfigForm
title="Test Legacy edX Discussions"
appConfig={appConfig}
onSubmit={onSubmit}
formRef={formRef}
/>
</IntlProvider>
</AppProvider>,
);
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(
<IntlProvider locale="en">
<LegacyConfigForm
title="Test Legacy edX Discussions"
appConfig={defaultAppConfig}
onSubmit={handleSubmit}
formRef={formRef}
/>
</IntlProvider>,
);
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(
<IntlProvider locale="en">
<LegacyConfigForm
title="Test Legacy edX Discussions"
appConfig={defaultAppConfig}
onSubmit={jest.fn()}
formRef={createRef()}
/>
</IntlProvider>,
);
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(
<IntlProvider locale="en">
<LegacyConfigForm
title="Test Legacy edX Discussions"
appConfig={{
...defaultAppConfig,
divideByCohorts: true,
allowAnonymousPosts: true,
}}
onSubmit={jest.fn()}
formRef={createRef()}
/>
</IntlProvider>,
);
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();
});
});

View File

@@ -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 (
<>
<h5 className="text-gray-500 mt-4 mb-2">
{intl.formatMessage(messages.discussionTopics)}
</h5>
<label className="text-primary-500 mb-2 h4">
{intl.formatMessage(messages.discussionTopicsLabel)}
</label>
<div className="small mb-4 text-muted">
{intl.formatMessage(messages.discussionTopicsHelp)}
</div>
<div>
<FieldArray
name="discussionTopics"
render={({ push, remove }) => (
<div>
{
topics.map((topic, index) => (
<TopicItem
{...topic}
key={`topic-${topic.id}`}
index={index}
onDelete={() => handleTopicDelete(index, topic.id, remove)}
/>
))
}
<div className="mb-4">
<Add />
<Button
onClick={() => addNewTopic(push)}
variant="link"
size="inline"
className="mr-1 text-primary-500"
>
{intl.formatMessage(messages.addTopicButton)}
</Button>
</div>
</div>
)}
/>
</div>
</>
);
};
DiscussionTopics.propTypes = {
intl: intlShape.isRequired,
};
export default injectIntl(DiscussionTopics);

View File

@@ -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 = <span className="h4 py-2 mr-auto">Configure topic</span>;
} else if (isOpen) {
heading = <span className="h4 py-2 mr-auto">Rename {title} topic</span>;
} else {
heading = <span className="py-2">{title}</span>;
}
return heading;
};
const handleToggle = (isOpen) => {
if (!isOpen && !isInvalidTopicNameKey) {
setTitle(name);
}
};
const deleteDiscussionTopic = (event) => {
event.stopPropagation();
setShowDeletePopup(true);
};
const deletetopic = (
<Card className="rounded mb-3 p-1">
<Card.Body>
<div className="text-primary-500 mb-2 h4">
{intl.formatMessage(messages.discussionTopicDeletionLabel)}
</div>
<Card.Text className="text-justify text-muted">
{intl.formatMessage(messages.discussionTopicDeletionHelp)}
</Card.Text>
<div className="d-flex justify-content-end">
<Button
variant="tertiary"
onClick={() => setShowDeletePopup(false)}
>
{intl.formatMessage(messages.cancelButton)}
</Button>
<Button
variant="outline-brand"
className="ml-2"
onClick={() => onDelete()}
>
{intl.formatMessage(messages.deleteButton)}
</Button>
</div>
</Card.Body>
</Card>
);
return (
<>
{
showDeletePopup ? (
deletetopic
) : (
<Collapsible.Advanced
className="collapsible-card rounded mb-3 px-3 py-2"
onToggle={handleToggle}
defaultOpen={!title}
>
<Collapsible.Trigger
className="collapsible-trigger d-flex border-0"
style={{ justifyContent: 'unset' }}
>
<Collapsible.Visible whenClosed>
{getHeading(false)}
<div className="py-2 ml-auto">
<ExpandMore />
</div>
</Collapsible.Visible>
<Collapsible.Visible whenOpen>
{getHeading(true)}
<div className="pr-4 border-right">
<Delete onClick={deleteDiscussionTopic} />
</div>
<div className="pl-4">
<ExpandLess />
</div>
</Collapsible.Visible>
</Collapsible.Trigger>
<Collapsible.Body className="collapsible-body rounded px-0">
<Form.Group
controlId={`discussionTopics.${index}.name`}
isInvalid={isInvalidTopicNameKey}
className="m-2"
>
<Form.Control
floatingLabel="Topic name"
onChange={handleChange}
onBlur={handleBlur}
value={name}
controlClassName="bg-white"
/>
{isInvalidTopicNameKey && (
<Form.Control.Feedback type="invalid" hasIcon={false}>
<div className="small">
{intl.formatMessage(messages.discussionTopicRequired)}
</div>
</Form.Control.Feedback>
)}
{isExistingName && (
<Form.Control.Feedback type="invalid" hasIcon={false}>
<div className="small">
{intl.formatMessage(messages.discussionTopicNameAlreadyExist)}
</div>
</Form.Control.Feedback>
)}
</Form.Group>
</Collapsible.Body>
</Collapsible.Advanced>
)
}
</>
);
};
TopicItem.propTypes = {
name: PropTypes.string.isRequired,
index: PropTypes.number.isRequired,
onDelete: PropTypes.func.isRequired,
intl: intlShape.isRequired,
};
export default injectIntl(TopicItem);

View File

@@ -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',

View File

@@ -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 = {};

View File

@@ -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);

View File

@@ -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 {

View File

@@ -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 }));
};
}

View File

@@ -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: [],