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:
@@ -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 {
|
||||
|
||||
@@ -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 }));
|
||||
}
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -55,7 +55,7 @@ function LegacyConfigForm({
|
||||
initialValues={appConfig}
|
||||
validateOnChange={false}
|
||||
validationSchema={legacyFormValidationSchema}
|
||||
onSubmit={(values) => (onSubmit(values))}
|
||||
onSubmit={(values) => onSubmit(values)}
|
||||
>
|
||||
{(
|
||||
{
|
||||
|
||||
@@ -29,6 +29,7 @@ function LtiConfigForm({
|
||||
piiShareUsername: appConfig.piiShareUsername,
|
||||
piiShareEmail: appConfig.piiShareEmail,
|
||||
};
|
||||
|
||||
const user = getAuthenticatedUser();
|
||||
const dispatch = useDispatch();
|
||||
const { externalLinks } = app;
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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}`]),
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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',
|
||||
};
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -42,6 +42,7 @@ function PageCard({
|
||||
iconAs={Icon}
|
||||
size="inline"
|
||||
alt={intl.formatMessage(messages.settings)}
|
||||
onClick={() => {}}
|
||||
/>
|
||||
</Hyperlink>
|
||||
);
|
||||
|
||||
@@ -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,
|
||||
});
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user