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:
@@ -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": {
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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);
|
||||
@@ -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);
|
||||
@@ -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',
|
||||
|
||||
@@ -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 = {};
|
||||
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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 }));
|
||||
};
|
||||
}
|
||||
|
||||
@@ -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: [],
|
||||
|
||||
Reference in New Issue
Block a user