diff --git a/src/data/slice.js b/src/data/slice.js index 0cd69c4ca..574ff318a 100644 --- a/src/data/slice.js +++ b/src/data/slice.js @@ -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 { diff --git a/src/data/thunks.js b/src/data/thunks.js index 0587a55d7..5de8142a5 100644 --- a/src/data/thunks.js +++ b/src/data/thunks.js @@ -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 })); } diff --git a/src/pages-and-resources/discussions/DiscussionsSettings.jsx b/src/pages-and-resources/discussions/DiscussionsSettings.jsx index 57f985344..f7c035f7e 100644 --- a/src/pages-and-resources/discussions/DiscussionsSettings.jsx +++ b/src/pages-and-resources/discussions/DiscussionsSettings.jsx @@ -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 ; + } + if (status === FAILED) { return ( + { + !canChangeProviders && ( + + {intl.formatMessage(messages.noProviderSwitchAfterCourseStarted)} + + ) + } { }, }); - 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. diff --git a/src/pages-and-resources/discussions/app-config-form/AppConfigForm.jsx b/src/pages-and-resources/discussions/app-config-form/AppConfigForm.jsx index fb51c03e1..4d4b51d22 100644 --- a/src/pages-and-resources/discussions/app-config-form/AppConfigForm.jsx +++ b/src/pages-and-resources/discussions/app-config-form/AppConfigForm.jsx @@ -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({ ); } + if (saveStatus === DENIED) { + alert = ; + } let form = null; if (app.id === 'legacy') { @@ -88,10 +105,34 @@ function AppConfigForm({ /> ); } + return ( {alert} {form} + setConfirmationDialogVisible(false)} + title={intl.formatMessage(messages.ok)} + > + + + {intl.formatMessage(messages.confirmConfigurationChange)} + + + + {intl.formatMessage(messages.configurationChangeConsequence)} + + + + + {intl.formatMessage(messages.cancel)} + + + + + ); } diff --git a/src/pages-and-resources/discussions/app-config-form/AppConfigFormSaveButton.jsx b/src/pages-and-resources/discussions/app-config-form/AppConfigFormSaveButton.jsx index bb6718d68..f3f9bf690 100644 --- a/src/pages-and-resources/discussions/app-config-form/AppConfigFormSaveButton.jsx +++ b/src/pages-and-resources/discussions/app-config-form/AppConfigFormSaveButton.jsx @@ -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 ( ); } AppConfigFormSaveButton.propTypes = { intl: intlShape.isRequired, + labelText: PropTypes.string, +}; + +AppConfigFormSaveButton.defaultProps = { + labelText: '', }; export default injectIntl(AppConfigFormSaveButton); diff --git a/src/pages-and-resources/discussions/app-config-form/apps/legacy/LegacyConfigForm.jsx b/src/pages-and-resources/discussions/app-config-form/apps/legacy/LegacyConfigForm.jsx index e890efe42..62b47ba4d 100644 --- a/src/pages-and-resources/discussions/app-config-form/apps/legacy/LegacyConfigForm.jsx +++ b/src/pages-and-resources/discussions/app-config-form/apps/legacy/LegacyConfigForm.jsx @@ -55,7 +55,7 @@ function LegacyConfigForm({ initialValues={appConfig} validateOnChange={false} validationSchema={legacyFormValidationSchema} - onSubmit={(values) => (onSubmit(values))} + onSubmit={(values) => onSubmit(values)} > {( { diff --git a/src/pages-and-resources/discussions/app-config-form/apps/lti/LtiConfigForm.jsx b/src/pages-and-resources/discussions/app-config-form/apps/lti/LtiConfigForm.jsx index c8849036a..55e0d092e 100644 --- a/src/pages-and-resources/discussions/app-config-form/apps/lti/LtiConfigForm.jsx +++ b/src/pages-and-resources/discussions/app-config-form/apps/lti/LtiConfigForm.jsx @@ -29,6 +29,7 @@ function LtiConfigForm({ piiShareUsername: appConfig.piiShareUsername, piiShareEmail: appConfig.piiShareEmail, }; + const user = getAuthenticatedUser(); const dispatch = useDispatch(); const { externalLinks } = app; diff --git a/src/pages-and-resources/discussions/app-config-form/messages.js b/src/pages-and-resources/discussions/app-config-form/messages.js index a36585079..3aeb92e1e 100644 --- a/src/pages-and-resources/discussions/app-config-form/messages.js +++ b/src/pages-and-resources/discussions/app-config-form/messages.js @@ -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', diff --git a/src/pages-and-resources/discussions/app-list/AppCard.jsx b/src/pages-and-resources/discussions/app-list/AppCard.jsx index 2a92c5732..f82fc5231 100644 --- a/src/pages-and-resources/discussions/app-list/AppCard.jsx +++ b/src/pages-and-resources/discussions/app-list/AppCard.jsx @@ -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 ( 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({ > { - 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( - - jest.fn()} - selected={selected} - features={[]} - /> - , - ); + 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( + + + jest.fn()} + selected={selected} + features={[]} + /> + + , + ); + 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); }); diff --git a/src/pages-and-resources/discussions/app-list/AppList.test.jsx b/src/pages-and-resources/discussions/app-list/AppList.test.jsx index 9f0769650..7ff124d60 100644 --- a/src/pages-and-resources/discussions/app-list/AppList.test.jsx +++ b/src/pages-and-resources/discussions/app-list/AppList.test.jsx @@ -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(); diff --git a/src/pages-and-resources/discussions/data/redux.test.js b/src/pages-and-resources/discussions/data/redux.test.js index 3185af22b..fc6463cca 100644 --- a/src/pages-and-resources/discussions/data/redux.test.js +++ b/src/pages-and-resources/discussions/data/redux.test.js @@ -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', diff --git a/src/pages-and-resources/discussions/data/thunks.js b/src/pages-and-resources/discussions/data/thunks.js index 6e58e44e7..129c14418 100644 --- a/src/pages-and-resources/discussions/data/thunks.js +++ b/src/pages-and-resources/discussions/data/thunks.js @@ -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); diff --git a/src/pages-and-resources/discussions/factories/mockApiResponses.js b/src/pages-and-resources/discussions/factories/mockApiResponses.js index ac65adda4..15c80e907 100644 --- a/src/pages-and-resources/discussions/factories/mockApiResponses.js +++ b/src/pages-and-resources/discussions/factories/mockApiResponses.js @@ -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: '
\n

About This Course

\n

Include your long course description here. The long course description should contain 150-400 words.

\n\n

This is paragraph 2 of the long course description. Add more paragraphs as needed. Make sure to enclose them in paragraph tags.

\n
\n\n
\n

Prerequisites

\n

Add information about course prerequisites here.

\n
\n\n
\n

Course Staff

\n
\n
\n \n
\n\n

Staff Member #1

\n

Biography of instructor/staff member #1

\n
\n\n
\n
\n \n
\n\n

Staff Member #2

\n

Biography of instructor/staff member #2

\n
\n
\n\n
\n
\n

Frequently Asked Questions

\n
\n

What web browser should I use?

\n

The Open edX platform works best with current versions of Chrome, Firefox or Safari, or with Internet Explorer version 9 and above.

\n\n

See our list of supported browsers for the most up-to-date information.

\n
\n\n
\n

Question #2

\n

Your answer would be displayed here.

\n
\n
\n
\n', + pacing: 'instructor', + short_description: null, + start: '2013-02-05T05:00:00Z', + start_display: 'Feb. 5, 2013', + start_type: 'timestamp', +}; diff --git a/src/pages-and-resources/discussions/messages.js b/src/pages-and-resources/discussions/messages.js index 0c2bce4a9..eb165c7b5 100644 --- a/src/pages-and-resources/discussions/messages.js +++ b/src/pages-and-resources/discussions/messages.js @@ -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', diff --git a/src/pages-and-resources/pages/PageCard.jsx b/src/pages-and-resources/pages/PageCard.jsx index b90e88aad..17d25cddc 100644 --- a/src/pages-and-resources/pages/PageCard.jsx +++ b/src/pages-and-resources/pages/PageCard.jsx @@ -42,6 +42,7 @@ function PageCard({ iconAs={Icon} size="inline" alt={intl.formatMessage(messages.settings)} + onClick={() => {}} /> ); diff --git a/src/store.js b/src/store.js index 703a64962..e023afd31 100644 --- a/src/store.js +++ b/src/store.js @@ -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, }); }