feat: Optionally display message on 403 and disable changing provider after course started [BD-38] [TNL-8142][BB-4253] (#126)

* feat: Prevent changing discussion providers after the course started.
* refactor: confirmation modal and update design according to Figma

Co-authored-by: Awais Ansari <awais.ansari63@gmail.com>
This commit is contained in:
João Cabrita
2021-08-11 11:00:28 +01:00
committed by GitHub
parent 2dffc2a4e6
commit 1a9f2b813a
18 changed files with 361 additions and 107 deletions

View File

@@ -10,17 +10,22 @@ const slice = createSlice({
initialState: {
courseId: null,
status: null,
canChangeProvider: null,
},
reducers: {
updateStatus: (state, { payload }) => {
state.courseId = payload.courseId;
state.status = payload.status;
},
updateCanChangeProviders: (state, { payload }) => {
state.canChangeProviders = payload.canChangeProviders;
},
},
});
export const {
updateStatus,
updateCanChangeProviders,
} = slice.actions;
export const {

View File

@@ -1,7 +1,9 @@
import { getAuthenticatedUser } from '@edx/frontend-platform/auth';
import { addModel } from '../generic/model-store';
import { getCourseDetail } from './api';
import {
updateStatus,
updateCanChangeProviders,
LOADING,
LOADED,
FAILED,
@@ -17,6 +19,9 @@ export function fetchCourseDetail(courseId) {
dispatch(updateStatus({ courseId, status: LOADED }));
dispatch(addModel({ modelType: 'courseDetails', model: courseDetail }));
dispatch(updateCanChangeProviders({
canChangeProviders: getAuthenticatedUser().administrator || new Date(courseDetail.start) > new Date(),
}));
} catch (error) {
dispatch(updateStatus({ courseId, status: FAILED }));
}

View File

@@ -7,10 +7,9 @@ import {
} from 'react-router';
import { useDispatch, useSelector } from 'react-redux';
import { history } from '@edx/frontend-platform';
import { injectIntl, intlShape } from '@edx/frontend-platform/i18n';
import {
Button, FullscreenModal, Stepper,
Alert, Button, FullscreenModal, Stepper,
} from '@edx/paragon';
import { PagesAndResourcesContext } from '../PagesAndResourcesProvider';
@@ -22,7 +21,9 @@ import AppList from './app-list';
import AppConfigForm from './app-config-form';
import { DENIED, FAILED } from './data/slice';
import ConnectionErrorAlert from '../../generic/ConnectionErrorAlert';
import { useModel } from '../../generic/model-store';
import PermissionDeniedAlert from '../../generic/PermissionDeniedAlert';
import Loading from '../../generic/Loading';
const SELECTION_STEP = 'selection';
const SETTINGS_STEP = 'settings';
@@ -31,6 +32,8 @@ function DiscussionsSettings({ courseId, intl }) {
const dispatch = useDispatch();
const { path: pagesAndResourcesPath } = useContext(PagesAndResourcesContext);
const { status, hasValidationError } = useSelector(state => state.discussions);
const { canChangeProviders } = useSelector(state => state.courseDetail);
const courseDetail = useModel('courseDetails', courseId);
useEffect(() => {
dispatch(fetchApps(courseId));
@@ -54,6 +57,10 @@ function DiscussionsSettings({ courseId, intl }) {
history.push(discussionsPath);
}, [discussionsPath]);
if (!courseDetail) {
return <Loading />;
}
if (status === FAILED) {
return (
<FullscreenModal
@@ -115,6 +122,13 @@ function DiscussionsSettings({ courseId, intl }) {
title={intl.formatMessage(messages.providerSelection)}
>
<AppList />
{
!canChangeProviders && (
<Alert variant="warning">
{intl.formatMessage(messages.noProviderSwitchAfterCourseStarted)}
</Alert>
)
}
</Stepper.Step>
<Stepper.Step
eventKey={SETTINGS_STEP}

View File

@@ -5,6 +5,7 @@ import { getAuthenticatedHttpClient } from '@edx/frontend-platform/auth';
import { AppProvider, PageRoute } from '@edx/frontend-platform/react';
import {
act,
getByRole,
queryByLabelText,
queryByRole,
queryByTestId,
@@ -25,7 +26,14 @@ import appListMessages from './app-list/messages';
import ltiMessages from './app-config-form/apps/lti/messages';
import { getAppsUrl } from './data/api';
import DiscussionsSettings from './DiscussionsSettings';
import { generatePiazzaApiResponse, legacyApiResponse, piazzaApiResponse } from './factories/mockApiResponses';
import {
generatePiazzaApiResponse,
legacyApiResponse,
piazzaApiResponse,
courseDetailResponse,
} from './factories/mockApiResponses';
import { executeThunk } from '../../utils';
import { fetchCourseDetail } from '../../data/thunks';
const courseId = 'course-v1:edX+TestX+Test_Course';
let axiosMock;
@@ -63,7 +71,15 @@ describe('DiscussionsSettings', () => {
},
});
store = initializeStore();
store = initializeStore({
models: {
courseDetails: {
[courseId]: {
start: Date(),
},
},
},
});
axiosMock = new MockAdapter(getAuthenticatedHttpClient());
// Leave the DiscussionsSettings route after the test.
@@ -174,18 +190,65 @@ describe('DiscussionsSettings', () => {
await waitForElementToBeRemoved(screen.getByRole('status'));
userEvent.click(queryByLabelText(container, 'Select Piazza'));
userEvent.click(queryByText(container, appListMessages.nextButton.defaultMessage));
// Apply causes an async action to take place
act(() => {
userEvent.click(queryByText(container, appMessages.saveButton.defaultMessage));
});
userEvent.click(getByRole(container, 'button', { name: 'Next' }));
userEvent.click(getByRole(container, 'button', { name: 'Save' }));
// This is an important line that ensures the Close button has been removed, which implies that
// the full screen modal has been closed following our click of Apply. Once this has happened,
// then it's safe to proceed with our expectations.
await waitForElementToBeRemoved(screen.queryByLabelText('Close'));
await waitForElementToBeRemoved(queryByRole(container, 'button', { name: 'Close' }));
expect(window.location.pathname).toEqual(`/course/${courseId}/pages-and-resources`);
await waitFor(() => expect(window.location.pathname).toEqual(`/course/${courseId}/pages-and-resources`));
});
test('requires confirmation if changing provider', async () => {
axiosMock.onGet(`${getConfig().LMS_BASE_URL}/api/courses/v1/courses/${courseId}`).reply(200, courseDetailResponse);
await executeThunk(fetchCourseDetail(courseId), store.dispatch);
history.push(`/course/${courseId}/pages-and-resources/discussion`);
// This is an important line that ensures the spinner has been removed - and thus our main
// content has been loaded - prior to proceeding with our expectations.
await waitForElementToBeRemoved(screen.getByRole('status'));
userEvent.click(getByRole(container, 'checkbox', { name: 'Select Discourse' }));
userEvent.click(getByRole(container, 'button', { name: 'Next' }));
userEvent.type(getByRole(container, 'textbox', { name: 'Consumer Key' }), 'key');
userEvent.type(getByRole(container, 'textbox', { name: 'Consumer Secret' }), 'secret');
userEvent.type(getByRole(container, 'textbox', { name: 'Launch URL' }), 'http://example.test');
userEvent.click(getByRole(container, 'button', { name: 'Save' }));
await waitFor(() => expect(getByRole(container, 'dialog', { name: 'OK' })).toBeInTheDocument());
});
test('can cancel confirmation', async () => {
axiosMock.onGet(`${getConfig().LMS_BASE_URL}/api/courses/v1/courses/${courseId}`).reply(200, courseDetailResponse);
await executeThunk(fetchCourseDetail(courseId), store.dispatch);
history.push(`/course/${courseId}/pages-and-resources/discussion`);
// This is an important line that ensures the spinner has been removed - and thus our main
// content has been loaded - prior to proceeding with our expectations.
await waitForElementToBeRemoved(screen.getByRole('status'));
const discourseBox = getByRole(container, 'checkbox', { name: 'Select Discourse' });
expect(discourseBox).not.toBeDisabled();
userEvent.click(discourseBox);
userEvent.click(getByRole(container, 'button', { name: 'Next' }));
expect(getByRole(container, 'heading', { name: 'Discourse' })).toBeInTheDocument();
userEvent.type(getByRole(container, 'textbox', { name: 'Consumer Key' }), 'a');
userEvent.type(getByRole(container, 'textbox', { name: 'Consumer Secret' }), 'secret');
userEvent.type(getByRole(container, 'textbox', { name: 'Launch URL' }), 'http://example.test');
userEvent.click(getByRole(container, 'button', { name: 'Save' }));
await waitFor(() => expect(getByRole(container, 'dialog', { name: 'OK' })).toBeInTheDocument());
userEvent.click(getByRole(container, 'button', { name: 'Cancel' }));
expect(queryByRole(container, 'dialog', { name: 'Confirm' })).not.toBeInTheDocument();
expect(queryByRole(container, 'dialog', { name: 'Configure discussion' }));
});
});
@@ -287,10 +350,7 @@ describe('DiscussionsSettings', () => {
// content has been loaded - prior to proceeding with our expectations.
await waitForElementToBeRemoved(screen.getByRole('status'));
// Apply causes an async action to take place
act(() => {
userEvent.click(queryByText(container, appMessages.saveButton.defaultMessage));
});
userEvent.click(getByRole(container, 'button', { name: 'Save' }));
await waitFor(() => expect(axiosMock.history.post.length).toBe(1));
@@ -323,7 +383,13 @@ describe.each([
},
});
store = initializeStore();
store = initializeStore({
models: {
courseDetails: {
[courseId]: {},
},
},
});
axiosMock = new MockAdapter(getAuthenticatedHttpClient());
// Leave the DiscussionsSettings route after the test.

View File

@@ -1,15 +1,20 @@
import React, {
useCallback, useContext, useEffect,
useCallback, useContext, useEffect, useState,
} from 'react';
import PropTypes from 'prop-types';
import { useDispatch, useSelector } from 'react-redux';
import { useRouteMatch } from 'react-router';
import { injectIntl, intlShape } from '@edx/frontend-platform/i18n';
import { Container } from '@edx/paragon';
import {
ActionRow,
Container,
ModalDialog,
} from '@edx/paragon';
import { useModel, useModels } from '../../../generic/model-store';
import { PagesAndResourcesContext } from '../../PagesAndResourcesProvider';
import {
DENIED,
FAILED, LOADED, LOADING, selectApp,
} from '../data/slice';
import { saveAppConfig } from '../data/thunks';
@@ -21,6 +26,7 @@ import LegacyConfigForm from './apps/legacy';
import LtiConfigForm from './apps/lti';
import Loading from '../../../generic/Loading';
import SaveFormConnectionErrorAlert from '../../../generic/SaveFormConnectionErrorAlert';
import PermissionDeniedAlert from '../../../generic/PermissionDeniedAlert';
function AppConfigForm({
courseId, intl,
@@ -30,7 +36,7 @@ function AppConfigForm({
const { path: pagesAndResourcesPath } = useContext(PagesAndResourcesContext);
const { params: { appId: routeAppId } } = useRouteMatch();
const {
selectedAppId, status, saveStatus, discussionTopicIds, divideDiscussionIds,
activeAppId, discussionTopicIds, divideDiscussionIds, selectedAppId, status, saveStatus,
} = 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.
@@ -40,6 +46,8 @@ function AppConfigForm({
const discussionTopics = useModels('discussionTopics', discussionTopicIds);
const appConfig = { ...appConfigObj, discussionTopics, divideDiscussionIds };
const [confirmationDialogVisible, setConfirmationDialogVisible] = useState(false);
useEffect(() => {
if (status === LOADED) {
if (routeAppId !== selectedAppId) {
@@ -50,9 +58,15 @@ function AppConfigForm({
// This is a callback that gets called after the form has been submitted successfully.
const handleSubmit = useCallback((values) => {
// Note that when this action succeeds, we redirect to pagesAndResurcesPath in the thunk.
dispatch(saveAppConfig(courseId, selectedAppId, values, pagesAndResourcesPath));
}, [courseId, selectedAppId, courseId]);
const needsConfirmation = (activeAppId !== selectedAppId);
if (needsConfirmation && !confirmationDialogVisible) {
setConfirmationDialogVisible(true);
} else {
setConfirmationDialogVisible(false);
// Note that when this action succeeds, we redirect to pagesAndResurcesPath in the thunk.
dispatch(saveAppConfig(courseId, selectedAppId, values, pagesAndResourcesPath));
}
}, [activeAppId, confirmationDialogVisible, courseId, selectedAppId]);
if (!selectedAppId || status === LOADING) {
return (
@@ -66,6 +80,9 @@ function AppConfigForm({
<SaveFormConnectionErrorAlert />
);
}
if (saveStatus === DENIED) {
alert = <PermissionDeniedAlert />;
}
let form = null;
if (app.id === 'legacy') {
@@ -88,10 +105,34 @@ function AppConfigForm({
/>
);
}
return (
<Container size="sm" className="px-sm-0 py-sm-5 p-0" data-testid="appConfigForm">
{alert}
{form}
<ModalDialog
hasCloseButton={false}
isOpen={confirmationDialogVisible}
onClose={() => setConfirmationDialogVisible(false)}
title={intl.formatMessage(messages.ok)}
>
<ModalDialog.Header className="pt-4">
<ModalDialog.Title className="h4 m-0" style={{ fontSize: '1.125rem' }}>
{intl.formatMessage(messages.confirmConfigurationChange)}
</ModalDialog.Title>
</ModalDialog.Header>
<ModalDialog.Body className="overflow-hidden text-primary-700">
{intl.formatMessage(messages.configurationChangeConsequence)}
</ModalDialog.Body>
<ModalDialog.Footer className="pb-4">
<ActionRow>
<ModalDialog.CloseButton variant="tertiary">
{intl.formatMessage(messages.cancel)}
</ModalDialog.CloseButton>
<AppConfigFormSaveButton labelText={intl.formatMessage(messages.ok)} />
</ActionRow>
</ModalDialog.Footer>
</ModalDialog>
</Container>
);
}

View File

@@ -1,5 +1,6 @@
import React, { useCallback, useContext } from 'react';
import { useSelector } from 'react-redux';
import PropTypes from 'prop-types';
import { injectIntl, intlShape } from '@edx/frontend-platform/i18n';
import { StatefulButton } from '@edx/paragon';
@@ -7,7 +8,7 @@ import messages from './messages';
import { SAVING } from '../data/slice';
import { AppConfigFormContext } from './AppConfigFormProvider';
function AppConfigFormSaveButton({ intl }) {
function AppConfigFormSaveButton({ intl, labelText }) {
const saveStatus = useSelector(state => state.discussions.saveStatus);
const { formRef } = useContext(AppConfigFormContext);
@@ -21,18 +22,24 @@ function AppConfigFormSaveButton({ intl }) {
return (
<StatefulButton
labels={{
default: intl.formatMessage(messages.saveButton),
default: labelText || intl.formatMessage(messages.saveButton),
pending: intl.formatMessage(messages.savingButton),
complete: intl.formatMessage(messages.savedButton),
}}
state={submitButtonState}
onClick={handleSave}
style={{ minWidth: '88px' }}
/>
);
}
AppConfigFormSaveButton.propTypes = {
intl: intlShape.isRequired,
labelText: PropTypes.string,
};
AppConfigFormSaveButton.defaultProps = {
labelText: '',
};
export default injectIntl(AppConfigFormSaveButton);

View File

@@ -55,7 +55,7 @@ function LegacyConfigForm({
initialValues={appConfig}
validateOnChange={false}
validationSchema={legacyFormValidationSchema}
onSubmit={(values) => (onSubmit(values))}
onSubmit={(values) => onSubmit(values)}
>
{(
{

View File

@@ -29,6 +29,7 @@ function LtiConfigForm({
piiShareUsername: appConfig.piiShareUsername,
piiShareEmail: appConfig.piiShareEmail,
};
const user = getAuthenticatedUser();
const dispatch = useDispatch();
const { externalLinks } = app;

View File

@@ -1,6 +1,15 @@
import { defineMessages } from '@edx/frontend-platform/i18n';
const messages = defineMessages({
configurationChangeConsequence: {
id: 'authoring.discussions.configurationChangeConsequences',
defaultMessage:
'Students will lose access to any active or previous'
+ ' discussion posts for your course.',
description:
'Describes that, as a consequence of changing configuration,'
+ ' students will lose access posts on the course.',
},
configureApp: {
id: 'authoring.discussions.configure.app',
defaultMessage: 'Configure {name}',
@@ -9,6 +18,21 @@ const messages = defineMessages({
id: 'authoring.discussions.configure',
defaultMessage: 'Configure discussions',
},
ok: {
id: 'authoring.discussions.ok',
defaultMessage: 'OK',
description: 'Button allowing the user to acknowledge the provider change.',
},
cancel: {
id: 'authoring.discussions.cancel',
defaultMessage: 'Cancel',
description: 'Button allowing the user to return to discussion provider configurations.',
},
confirmConfigurationChange: {
id: 'authoring.discussions.confirmConfigurationChange',
defaultMessage: 'Are you sure you want to change the discussion settings?',
description: 'Asks the user whether he/she really wants to change settings.',
},
backButton: {
id: 'authoring.discussions.backButton',
defaultMessage: 'Back',

View File

@@ -1,6 +1,7 @@
import React from 'react';
import PropTypes from 'prop-types';
import classNames from 'classnames';
import { useSelector } from 'react-redux';
import { injectIntl, intlShape } from '@edx/frontend-platform/i18n';
import Responsive from 'react-responsive';
import {
@@ -12,15 +13,16 @@ import FeaturesList from './FeaturesList';
function AppCard({
app, onClick, intl, selected, features,
}) {
const { canChangeProviders } = useSelector(state => state.courseDetail);
const supportText = app.hasFullSupport
? intl.formatMessage(messages.appFullSupport)
: intl.formatMessage(messages.appBasicSupport);
return (
<Card
key={app.id}
tabIndex="-1"
onClick={() => onClick(app.id)}
onKeyPress={() => onClick(app.id)}
onClick={() => canChangeProviders && onClick(app.id)}
onKeyPress={() => canChangeProviders && onClick(app.id)}
role="radio"
aria-checked={selected}
style={{
@@ -39,6 +41,7 @@ function AppCard({
>
<CheckboxControl
checked={selected}
disabled={!canChangeProviders}
readOnly
aria-label={intl.formatMessage(messages.selectApp, {
appName: intl.formatMessage(messages[`appName-${app.id}`]),

View File

@@ -1,39 +1,73 @@
import React from '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 { render, queryByLabelText } from '@testing-library/react';
import AppCard from './AppCard';
import messages from './messages';
import initializeStore from '../../../store';
import { executeThunk } from '../../../utils';
import { getAppsUrl } from '../data/api';
import { fetchApps } from '../data/thunks';
import { legacyApiResponse } from '../factories/mockApiResponses';
const courseId = 'course-v1:edX+TestX+Test_Course';
const selected = true;
const app = {
id: 'legacy',
hasFullSupport: true,
featureIds: ['discussion-page', 'embedded-course-sections', 'wcag-2.1'],
};
describe('AppCard', () => {
let app;
let selected;
let wrapper;
let axiosMock;
let store;
let container;
beforeEach(() => {
selected = true;
app = {
id: 'legacy',
hasFullSupport: true,
featureIds: ['discussion-page', 'embedded-course-sections', 'wcag-2.1'],
};
beforeEach(async () => {
initializeMockApp({
authenticatedUser: {
userId: 3,
username: 'abc123',
administrator: true,
roles: [],
},
});
wrapper = (data) => render(
<IntlProvider locale="en">
<AppCard
app={data}
onClick={() => jest.fn()}
selected={selected}
features={[]}
/>
</IntlProvider>,
);
store = await initializeStore();
axiosMock = new MockAdapter(getAuthenticatedHttpClient());
});
test('checkbox input is checked when AppCard is selected', () => {
const mockStore = async (mockResponse) => {
axiosMock.onGet(getAppsUrl(courseId)).reply(200, mockResponse);
await executeThunk(fetchApps(courseId), store.dispatch);
};
const createComponent = (data) => {
const wrapper = render(
<AppProvider store={store}>
<IntlProvider locale="en">
<AppCard
app={data}
onClick={() => jest.fn()}
selected={selected}
features={[]}
/>
</IntlProvider>
</AppProvider>,
);
container = wrapper.container;
return container;
};
test('checkbox input is checked when AppCard is selected', async () => {
const labelText = `Select ${messages[`appName-${app.id}`].defaultMessage}`;
const { container } = wrapper(app);
await mockStore(legacyApiResponse);
createComponent(app);
expect(container.querySelector('[role="radio"]')).toBeChecked();
expect(queryByLabelText(container, labelText, { selector: 'input[type="checkbox"]' })).toBeChecked();
@@ -42,30 +76,33 @@ describe('AppCard', () => {
test.each([
[true],
[false],
])('providerName and text from the app are displayed with full support %s', (hasFullSupport) => {
])('providerName and text from the app are displayed with full support %s', async (hasFullSupport) => {
const appWithCustomSupport = { ...app, hasFullSupport };
const title = messages[`appName-${appWithCustomSupport.id}`].defaultMessage;
const text = messages[`appDescription-${appWithCustomSupport.id}`].defaultMessage;
const { container } = wrapper(appWithCustomSupport);
await mockStore(legacyApiResponse);
createComponent(appWithCustomSupport);
expect(container.querySelector('.card-title')).toHaveTextContent(title);
expect(container.querySelector('.card-text')).toHaveTextContent(text);
});
test('full support subtitle shown when hasFullSupport is true', () => {
test('full support subtitle shown when hasFullSupport is true', async () => {
const subtitle = messages.appFullSupport.defaultMessage;
const { container } = wrapper(app);
await mockStore(legacyApiResponse);
createComponent(app);
expect(container.querySelector('.card-subtitle')).toHaveTextContent(subtitle);
});
test('partial support subtitle shown when hasFullSupport is false', () => {
test('partial support subtitle shown when hasFullSupport is false', async () => {
const appWithBasicSupport = { ...app, hasFullSupport: false };
const subtitle = messages.appBasicSupport.defaultMessage;
const { container } = wrapper(appWithBasicSupport);
await mockStore(legacyApiResponse);
createComponent(appWithBasicSupport);
expect(container.querySelector('.card-subtitle')).toHaveTextContent(subtitle);
});

View File

@@ -16,7 +16,7 @@ import initializeStore from '../../../store';
import { executeThunk } from '../../../utils';
import { getAppsUrl } from '../data/api';
import { fetchApps } from '../data/thunks';
import { emptyAppApiResponse, legacyApiResponse, piazzaApiResponse } from '../factories/mockApiResponses';
import { emptyAppApiResponse, piazzaApiResponse } from '../factories/mockApiResponses';
import AppList from './AppList';
import messages from './messages';
@@ -99,7 +99,7 @@ describe('AppList', () => {
});
test('selectApp is called when an app is clicked', async () => {
await mockStore(legacyApiResponse);
await mockStore(piazzaApiResponse);
userEvent.click(getByLabelText(container, 'Select Piazza'));
const clickedCard = getByRole(container, 'radio', { checked: true });
expect(queryByLabelText(clickedCard, 'Select Piazza')).toBeInTheDocument();

View File

@@ -151,7 +151,7 @@ describe('Data layer integration tests', () => {
await executeThunk(fetchApps(courseId), store.dispatch);
expect(store.getState().discussions).toEqual({
appIds: ['legacy', 'piazza'],
appIds: ['legacy', 'piazza', 'discourse'],
featureIds,
activeAppId: 'piazza',
selectedAppId: null,
@@ -187,7 +187,7 @@ describe('Data layer integration tests', () => {
await executeThunk(fetchApps(courseId), store.dispatch);
expect(store.getState().discussions).toEqual({
appIds: ['legacy', 'piazza'],
appIds: ['legacy', 'piazza', 'discourse'],
featureIds,
activeAppId: 'piazza',
selectedAppId: null,
@@ -276,7 +276,7 @@ describe('Data layer integration tests', () => {
expect(window.location.pathname).toEqual(`/course/${courseId}/pages-and-resources/discussions/configure/piazza`);
expect(store.getState().discussions).toEqual(
expect.objectContaining({
appIds: ['legacy', 'piazza'],
appIds: ['legacy', 'piazza', 'discourse'],
featureIds,
activeAppId: 'piazza',
selectedAppId: 'piazza',
@@ -302,7 +302,7 @@ describe('Data layer integration tests', () => {
expect(window.location.pathname).toEqual(`/course/${courseId}/pages-and-resources/discussions/configure/piazza`);
expect(store.getState().discussions).toEqual(
expect.objectContaining({
appIds: ['legacy', 'piazza'],
appIds: ['legacy', 'piazza', 'discourse'],
featureIds,
activeAppId: 'piazza',
selectedAppId: 'piazza',
@@ -356,7 +356,7 @@ describe('Data layer integration tests', () => {
expect(window.location.pathname).toEqual(pagesAndResourcesPath);
expect(store.getState().discussions).toEqual(
expect.objectContaining({
appIds: ['legacy', 'piazza'],
appIds: ['legacy', 'piazza', 'discourse'],
featureIds,
activeAppId: 'piazza',
selectedAppId: 'piazza',

View File

@@ -12,32 +12,39 @@ import {
DENIED,
} from './slice';
function updateAppState({
apps,
features,
activeAppId,
appConfig,
discussionTopicIds,
discussionTopics,
divideDiscussionIds,
userPermissions,
}) {
return async (dispatch) => {
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,
divideDiscussionIds,
userPermissions,
}));
};
}
export function fetchApps(courseId) {
return async (dispatch) => {
dispatch(updateStatus({ status: LOADING }));
try {
const {
apps,
features,
activeAppId,
appConfig,
discussionTopicIds,
discussionTopics,
divideDiscussionIds,
} = await getApps(courseId);
dispatch(addModel({ modelType: 'appConfigs', model: appConfig }));
dispatch(addModels({ modelType: 'apps', models: apps }));
dispatch(addModels({ modelType: 'features', models: features }));
dispatch(addModels({ modelType: 'discussionTopics', models: discussionTopics }));
dispatch(loadApps({
activeAppId,
appIds: apps.map(app => app.id),
featureIds: features.map(feature => feature.id),
discussionTopicIds,
divideDiscussionIds,
}));
const apps = await getApps(courseId);
dispatch(updateAppState(apps));
} catch (error) {
if (error.response && error.response.status === 403) {
dispatch(updateStatus({ status: DENIED }));
@@ -53,28 +60,9 @@ export function saveAppConfig(courseId, appId, drafts, successPath) {
dispatch(updateSaveStatus({ status: SAVING }));
try {
const {
apps,
features,
activeAppId,
appConfig,
discussionTopicIds,
discussionTopics,
divideDiscussionIds,
} = await postAppConfig(courseId, appId, drafts);
const apps = await postAppConfig(courseId, appId, drafts);
dispatch(updateAppState(apps));
dispatch(addModel({ modelType: 'appConfigs', model: appConfig }));
dispatch(addModels({ modelType: 'apps', models: apps }));
dispatch(addModels({ modelType: 'features', models: features }));
dispatch(addModels({ modelType: 'discussionTopics', models: discussionTopics }));
dispatch(loadApps({
activeAppId,
appIds: apps.map(app => app.id),
featureIds: features.map(feature => feature.id),
discussionTopicIds,
divideDiscussionIds,
}));
dispatch(updateSaveStatus({ status: SAVED }));
// Note that we redirect here to avoid having to work with the promise over in AppConfigForm.
history.push(successPath);

View File

@@ -53,6 +53,23 @@ export const generatePiazzaApiResponse = (piazzaAdminOnlyConfig = false) => ({
has_full_support: false,
admin_only_config: piazzaAdminOnlyConfig,
},
discourse: {
features: [
'discussion-page',
'embedded-course-sections',
'wcag-2.1',
'lti',
],
external_links: {
learn_more: '',
configuration: '',
general: '',
accessibility: '',
contact_email: '',
},
messages: [],
has_full_support: false,
},
},
},
});
@@ -143,3 +160,42 @@ export const emptyAppApiResponse = {
};
export const piazzaApiResponse = generatePiazzaApiResponse(false);
export const courseDetailResponse = {
blocks_url: 'http://localhost:18000/api/courses/v2/blocks/?course_id=course-v1%3AedX%2BDemoX%2BDemo_Course',
course_id: 'course-v1:edX+DemoX+Demo_Course',
effort: null,
end: null,
enrollment_end: null,
enrollment_start: null,
hidden: false,
id: 'course-v1:edX+DemoX+Demo_Course',
invitation_only: false,
media: {
banner_image: {
uri: '/asset-v1:edX+DemoX+Demo_Course+type@asset+block@images_course_image.jpg',
uri_absolute: 'http://localhost:18000/asset-v1:edX+DemoX+Demo_Course+type@asset+block@images_course_image.jpg',
},
course_image: {
uri: '/asset-v1:edX+DemoX+Demo_Course+type@asset+block@images_course_image.jpg',
},
course_video: {
uri: null,
},
image: {
large: 'http://localhost:18000/asset-v1:edX+DemoX+Demo_Course+type@asset+block@images_course_image.jpg',
raw: 'http://localhost:18000/asset-v1:edX+DemoX+Demo_Course+type@asset+block@images_course_image.jpg',
small: 'http://localhost:18000/asset-v1:edX+DemoX+Demo_Course+type@asset+block@images_course_image.jpg',
},
},
mobile_available: false,
name: 'Demonstration Course',
number: 'DemoX',
org: 'edX',
overview: '<section class="about">\n <h2>About This Course</h2>\n <p>Include your long course description here. The long course description should contain 150-400 words.</p>\n\n <p>This is paragraph 2 of the long course description. Add more paragraphs as needed. Make sure to enclose them in paragraph tags.</p>\n </section>\n\n <section class="prerequisites">\n <h2>Prerequisites</h2>\n <p>Add information about course prerequisites here.</p>\n </section>\n\n <section class="course-staff">\n <h2>Course Staff</h2>\n <article class="teacher">\n <div class="teacher-image">\n <img src="/static/images/pl-faculty.png" align="left" style="margin:0 20 px 0">\n </div>\n\n <h3>Staff Member #1</h3>\n <p>Biography of instructor/staff member #1</p>\n </article>\n\n <article class="teacher">\n <div class="teacher-image">\n <img src="/static/images/pl-faculty.png" align="left" style="margin:0 20 px 0">\n </div>\n\n <h3>Staff Member #2</h3>\n <p>Biography of instructor/staff member #2</p>\n </article>\n </section>\n\n <section class="faq">\n <section class="responses">\n <h2>Frequently Asked Questions</h2>\n <article class="response">\n <h3>What web browser should I use?</h3>\n <p>The Open edX platform works best with current versions of Chrome, Firefox or Safari, or with Internet Explorer version 9 and above.</p>\n\n <p>See our <a href="http://edx.readthedocs.org/en/latest/browsers.html">list of supported browsers</a> for the most up-to-date information.</p>\n </article>\n\n <article class="response">\n <h3>Question #2</h3>\n <p>Your answer would be displayed here.</p>\n </article>\n </section>\n </section>\n',
pacing: 'instructor',
short_description: null,
start: '2013-02-05T05:00:00Z',
start_display: 'Feb. 5, 2013',
start_type: 'timestamp',
};

View File

@@ -34,6 +34,11 @@ const messages = defineMessages({
defaultMessage: 'Applied',
description: 'Button label when the discussion configuration has been successfully submitted.',
},
noProviderSwitchAfterCourseStarted: {
id: 'authoring.discussions.noProviderSwitchAfterCourseStarted',
defaultMessage: "Discussion provider can't be changed after course has started, please reach out to partner support.",
description: "Informs the user that the provider can't be changed after the course has started.",
},
providerSelection: {
id: 'authoring.discussions.providerSelection',
defaultMessage: 'Provider selection',

View File

@@ -42,6 +42,7 @@ function PageCard({
iconAs={Icon}
size="inline"
alt={intl.formatMessage(messages.settings)}
onClick={() => {}}
/>
</Hyperlink>
);

View File

@@ -5,7 +5,7 @@ import { reducer as courseDetailReducer } from './data/slice';
import { reducer as discussionsReducer } from './pages-and-resources/discussions';
import { reducer as pagesAndResourcesReducer } from './pages-and-resources/data/slice';
export default function initializeStore() {
export default function initializeStore(preloadedState = undefined) {
return configureStore({
reducer: {
courseDetail: courseDetailReducer,
@@ -13,5 +13,6 @@ export default function initializeStore() {
pagesAndResources: pagesAndResourcesReducer,
models: modelsReducer,
},
preloadedState,
});
}