From 0e7340bdda6302475b6495f5519bcda8539ab638 Mon Sep 17 00:00:00 2001 From: Kshitij Sobti Date: Tue, 30 Mar 2021 16:42:30 +0530 Subject: [PATCH] feat: Hook up course apps API and add base components for app configuration Hooks up the course apps API so that the data returned by the server is being used. Adds the base components and infrastructure to enable adding pages for configuring each app. --- .env | 1 - .env.test | 1 - package.json | 1 + src/data/constants.js | 13 ++ src/generic/FormSwitchGroup.jsx | 45 +++-- src/generic/status-badge/StatusBadge.jsx | 29 +++ src/generic/status-badge/messages.js | 15 ++ src/index.jsx | 1 + src/index.scss | 2 +- src/pages-and-resources/PagesAndResources.jsx | 52 ++++-- .../PagesAndResourcesProvider.jsx | 1 + .../app-settings-modal/AppSettingsModal.jsx | 176 ++++++++++++++++++ .../app-settings-modal/messages.js | 35 ++++ .../calculator/Settings.jsx | 31 +++ .../calculator/messages.js | 24 +++ src/pages-and-resources/data/api.js | 96 ++++------ src/pages-and-resources/data/selectors.js | 4 + src/pages-and-resources/data/slice.js | 26 +-- src/pages-and-resources/data/thunks.js | 44 +++-- .../discussions/DiscussionsSettings.jsx | 2 +- .../discussions/DiscussionsSettings.test.jsx | 28 +-- .../apps/shared/AppConfigFormDivider.jsx | 21 ++- src/pages-and-resources/messages.js | 4 + src/pages-and-resources/pages/PageCard.jsx | 94 +++++----- src/pages-and-resources/pages/PageGrid.jsx | 44 ++--- 25 files changed, 575 insertions(+), 215 deletions(-) create mode 100644 src/data/constants.js create mode 100644 src/generic/status-badge/StatusBadge.jsx create mode 100644 src/generic/status-badge/messages.js create mode 100644 src/pages-and-resources/app-settings-modal/AppSettingsModal.jsx create mode 100644 src/pages-and-resources/app-settings-modal/messages.js create mode 100644 src/pages-and-resources/calculator/Settings.jsx create mode 100644 src/pages-and-resources/calculator/messages.js create mode 100644 src/pages-and-resources/data/selectors.js diff --git a/.env b/.env index 410a9f750..9332343bb 100644 --- a/.env +++ b/.env @@ -6,7 +6,6 @@ CSRF_TOKEN_API_PATH=null DISCOVERY_API_BASE_URL= ECOMMERCE_BASE_URL=null FAVICON_URL='' -FAVICON_URL=null LANGUAGE_PREFERENCE_COOKIE_NAME=null LMS_BASE_URL=null LOGIN_URL=null diff --git a/.env.test b/.env.test index 0e50cf2d7..b2e446dfa 100644 --- a/.env.test +++ b/.env.test @@ -5,7 +5,6 @@ CSRF_TOKEN_API_PATH='/csrf/api/v1/token' DISCOVERY_API_BASE_URL='http://localhost:18381' ECOMMERCE_BASE_URL='http://localhost:18130' FAVICON_URL='https://edx-cdn.org/v3/default/favicon.ico' -FAVICON_URL=https://edx-cdn.org/v3/default/favicon.ico LANGUAGE_PREFERENCE_COOKIE_NAME='openedx-language-preference' LMS_BASE_URL='http://localhost:18000' LOGIN_URL='http://localhost:18000/login' diff --git a/package.json b/package.json index 3434b7458..f49ab4c53 100644 --- a/package.json +++ b/package.json @@ -15,6 +15,7 @@ "i18n_extract": "BABEL_ENV=i18n fedx-scripts babel src --quiet > /dev/null", "is-es5": "es-check es5 ./dist/*.js", "lint": "fedx-scripts eslint --ext .js --ext .jsx .", + "lint:fix": "fedx-scripts eslint --ext .js --ext .jsx . --fix", "snapshot": "fedx-scripts jest --updateSnapshot", "start": "fedx-scripts webpack-dev-server --progress", "test": "fedx-scripts jest --coverage --passWithNoTests" diff --git a/src/data/constants.js b/src/data/constants.js new file mode 100644 index 000000000..2feca441c --- /dev/null +++ b/src/data/constants.js @@ -0,0 +1,13 @@ +/* eslint-disable import/prefer-default-export */ + +/** + * Enum for request status. + * @readonly + * @enum {string} + */ +export const RequestStatus = { + IN_PROGRESS: 'in-progress', + SUCCESSFUL: 'successful', + FAILED: 'failed', + DENIED: 'denied', +}; diff --git a/src/generic/FormSwitchGroup.jsx b/src/generic/FormSwitchGroup.jsx index b0eeb78fd..c8604cf47 100644 --- a/src/generic/FormSwitchGroup.jsx +++ b/src/generic/FormSwitchGroup.jsx @@ -1,9 +1,16 @@ -import React from 'react'; -import PropTypes from 'prop-types'; import { Form, SwitchControl } from '@edx/paragon'; +import PropTypes from 'prop-types'; +import React from 'react'; export default function FormSwitchGroup({ - id, label, helpText, className, onChange, onBlur, checked, + id, + name, + label, + helpText, + className, + onChange, + onBlur, + checked, }) { const helpTextId = `${id}HelpText`; @@ -15,28 +22,27 @@ export default function FormSwitchGroup({ controlId={id} className={className} > -
-
- +
+
+ {label} - - {helpText} - -
-
+ + {helpText} +
@@ -44,13 +50,16 @@ export default function FormSwitchGroup({ } FormSwitchGroup.propTypes = { id: PropTypes.string.isRequired, - label: PropTypes.string.isRequired, - helpText: PropTypes.string.isRequired, + label: PropTypes.node.isRequired, + name: PropTypes.string, + helpText: PropTypes.node.isRequired, className: PropTypes.string, onChange: PropTypes.func.isRequired, - onBlur: PropTypes.func.isRequired, + onBlur: PropTypes.func, checked: PropTypes.bool.isRequired, }; FormSwitchGroup.defaultProps = { className: null, + onBlur: null, + name: null, }; diff --git a/src/generic/status-badge/StatusBadge.jsx b/src/generic/status-badge/StatusBadge.jsx new file mode 100644 index 000000000..0cb4740ab --- /dev/null +++ b/src/generic/status-badge/StatusBadge.jsx @@ -0,0 +1,29 @@ +import { injectIntl, intlShape } from '@edx/frontend-platform/i18n'; +import { Badge } from '@edx/paragon'; +import PropTypes from 'prop-types'; +import React from 'react'; + +import messages from './messages'; + +function StatusBadge({ intl, status, label }) { + return ( + <> + {label && `${label} `} + {status + ? {intl.formatMessage(messages.enabled)} + : {intl.formatMessage(messages.disabled)}} + + ); +} + +StatusBadge.propTypes = { + intl: intlShape.isRequired, + status: PropTypes.bool.isRequired, + label: PropTypes.string, +}; + +StatusBadge.defaultProps = { + label: null, +}; + +export default injectIntl(StatusBadge); diff --git a/src/generic/status-badge/messages.js b/src/generic/status-badge/messages.js new file mode 100644 index 000000000..321e7a6a9 --- /dev/null +++ b/src/generic/status-badge/messages.js @@ -0,0 +1,15 @@ +import { defineMessages } from '@edx/frontend-platform/i18n'; + +const messages = defineMessages({ + enabled: { + id: 'course-authoring.badge.enabled', + defaultMessage: 'Enabled', + }, + disabled: { + id: 'course-authoring.badge.disabled', + defaultMessage: 'Disabled', + }, + +}); + +export default messages; diff --git a/src/index.jsx b/src/index.jsx index b84b9ca41..574bb9be8 100755 --- a/src/index.jsx +++ b/src/index.jsx @@ -45,6 +45,7 @@ initialize({ config: () => { mergeConfig({ SUPPORT_URL: process.env.SUPPORT_URL || null, + CALCULATOR_HELP_URL: process.env.CALCULATOR_HELP_URL || null, }, 'CourseAuthoringConfig'); }, }, diff --git a/src/index.scss b/src/index.scss index 93c7415a8..f6bdd4fba 100755 --- a/src/index.scss +++ b/src/index.scss @@ -7,4 +7,4 @@ @import "~@edx/frontend-component-footer/dist/footer"; @import "proctored-exam-settings/proctoredExamSettings"; -@import "pages-and-resources/discussions/app-list/AppList"; \ No newline at end of file +@import "pages-and-resources/discussions/app-list/AppList"; diff --git a/src/pages-and-resources/PagesAndResources.jsx b/src/pages-and-resources/PagesAndResources.jsx index 511f84cf0..4bb8e00f7 100644 --- a/src/pages-and-resources/PagesAndResources.jsx +++ b/src/pages-and-resources/PagesAndResources.jsx @@ -1,4 +1,4 @@ -import React, { useEffect } from 'react'; +import React, { useEffect, Suspense } from 'react'; import PropTypes from 'prop-types'; import { injectIntl, intlShape } from '@edx/frontend-platform/i18n'; import { PageRoute } from '@edx/frontend-platform/react'; @@ -12,40 +12,60 @@ import DiscussionsSettings from './discussions'; import PageGrid from './pages/PageGrid'; import ResourceList from './resources/ResourcesList'; -import { fetchPages } from './data/thunks'; +import { fetchCourseApps } from './data/thunks'; import { useModels } from '../generic/model-store'; import PagesAndResourcesProvider from './PagesAndResourcesProvider'; function PagesAndResources({ courseId, intl }) { - const { path } = useRouteMatch(); + const { path, url } = useRouteMatch(); const dispatch = useDispatch(); useEffect(() => { - dispatch(fetchPages(courseId)); + dispatch(fetchCourseApps(courseId)); }, [courseId]); - const pageIds = useSelector(state => state.pagesAndResources.pageIds); - const pages = useModels('pages', pageIds); + const courseAppIds = useSelector(state => state.pagesAndResources.courseAppIds); + // Each page here is driven by a course app + const pages = useModels('courseApps', courseAppIds); return ( -
-
-
-

{intl.formatMessage(messages.heading)}

-
- - -
+
+

{intl.formatMessage(messages.heading)}

+ + + + { + ({ match, history }) => { + const SettingsComponent = React.lazy(async () => { + try { + // There seems to be a bug in babel-eslint that causes the checker to crash with the following error + // if we use a template string here: + // TypeError: Cannot read property 'range' of null with using template strings here. + // Ref: https://github.com/babel/babel-eslint/issues/530 + return await import('./' + match.params.appId + '/Settings.jsx'); // eslint-disable-line + } catch (error) { + console.trace(error); // eslint-disable-line no-console + return null; + } + }); + return ( + + history.push(url)} /> + + ); + } + } +
diff --git a/src/pages-and-resources/PagesAndResourcesProvider.jsx b/src/pages-and-resources/PagesAndResourcesProvider.jsx index 0b34d9ed8..b96983291 100644 --- a/src/pages-and-resources/PagesAndResourcesProvider.jsx +++ b/src/pages-and-resources/PagesAndResourcesProvider.jsx @@ -7,6 +7,7 @@ export default function PagesAndResourcesProvider({ courseId, children }) { return ( diff --git a/src/pages-and-resources/app-settings-modal/AppSettingsModal.jsx b/src/pages-and-resources/app-settings-modal/AppSettingsModal.jsx new file mode 100644 index 000000000..7ce46a506 --- /dev/null +++ b/src/pages-and-resources/app-settings-modal/AppSettingsModal.jsx @@ -0,0 +1,176 @@ +import { injectIntl, intlShape } from '@edx/frontend-platform/i18n'; +import { + Button, Form, Hyperlink, ModalLayer, Spinner, TransitionReplace, +} from '@edx/paragon'; +import { Formik } from 'formik'; +import PropTypes from 'prop-types'; +import React, { useContext } from 'react'; +import { useDispatch, useSelector } from 'react-redux'; +import * as Yup from 'yup'; +import { RequestStatus } from '../../data/constants'; +import FormSwitchGroup from '../../generic/FormSwitchGroup'; +import { useModel } from '../../generic/model-store'; +import StatusBadge from '../../generic/status-badge/StatusBadge'; +import { getLoadingStatus } from '../data/selectors'; +import { updateAppStatus } from '../data/thunks'; +import AppConfigFormDivider from '../discussions/app-config-form/apps/shared/AppConfigFormDivider'; +import { PagesAndResourcesContext } from '../PagesAndResourcesProvider'; +import messages from './messages'; + +function AppSettingsForm({ formikProps, children }) { + return children && ( + + {formikProps.values.enabled + ? ( + + {children(formikProps)} + + ) : ( + + )} + + ); +} + +AppSettingsForm.propTypes = { + formikProps: PropTypes.shape({ + values: PropTypes.shape({ enabled: PropTypes.bool.isRequired }), + }).isRequired, + children: PropTypes.func, +}; + +AppSettingsForm.defaultProps = { + children: null, +}; + +function AppSettingsModal({ + intl, + appId, + title, + children, + initialValues, + validationSchema, + onClose, + onSettingsSave, + enableAppLabel, + enableAppHelp, + learnMoreURL, + learnMoreText, +}) { + const { courseId } = useContext(PagesAndResourcesContext); + const loadingStatus = useSelector(getLoadingStatus); + const appInfo = useModel('courseApps', appId); + const dispatch = useDispatch(); + const handleFormSubmit = (values) => { + // If the app's enabled/disabled loadingStatus has changed, set that first. + if (appInfo.enabled !== values.enabled) { + dispatch(updateAppStatus(courseId, appInfo.id, values.enabled, true)); + } + // Call the submit handler for the settings component to save its settings + if (onSettingsSave) { + onSettingsSave(); + } + }; + const learnMoreLink = ( + + {learnMoreText} + + ); + + return ( + +
+ { + loadingStatus === RequestStatus.SUCCESSFUL && ( + {(formikProps) => ( +
+

{title}

+ + {enableAppLabel}  + + + )} + helpText={(

{enableAppHelp}
{learnMoreLink}

)} + /> + + {children} + + {formikProps.values.enabled && children + && } +
+ + +
+ + )} +
+ ) + } + {loadingStatus === RequestStatus.IN_PROGRESS && ( + + )} +
+
+ ); +} + +AppSettingsModal.propTypes = { + intl: intlShape.isRequired, + title: PropTypes.string.isRequired, + appId: PropTypes.string.isRequired, + children: PropTypes.func, + onSettingsSave: PropTypes.func, + initialValues: PropTypes.objectOf(PropTypes.any), + validationSchema: PropTypes.objectOf(PropTypes.func), + onClose: PropTypes.func.isRequired, + enableAppLabel: PropTypes.string.isRequired, + enableAppHelp: PropTypes.string.isRequired, + learnMoreURL: PropTypes.string.isRequired, + learnMoreText: PropTypes.string.isRequired, +}; + +AppSettingsModal.defaultProps = { + children: null, + onSettingsSave: null, + initialValues: {}, + validationSchema: {}, +}; + +export default injectIntl(AppSettingsModal); diff --git a/src/pages-and-resources/app-settings-modal/messages.js b/src/pages-and-resources/app-settings-modal/messages.js new file mode 100644 index 000000000..db2dd4e0c --- /dev/null +++ b/src/pages-and-resources/app-settings-modal/messages.js @@ -0,0 +1,35 @@ +import { defineMessages } from '@edx/frontend-platform/i18n'; + +const messages = defineMessages({ + cancel: { + id: 'course-authoring.pages-resources.app-settings-modal.button.cancel', + defaultMessage: 'Cancel', + }, + apply: { + id: 'course-authoring.pages-resources.app-settings-modal.button.apply', + defaultMessage: 'Apply', + }, + applying: { + id: 'course-authoring.pages-resources.app-settings-modal.button.applying', + defaultMessage: 'Applying', + }, + applied: { + id: 'course-authoring.pages-resources.app-settings-modal.button.applied', + defaultMessage: 'Applied', + }, + retry: { + id: 'course-authoring.pages-resources.app-settings-modal.button.retry', + defaultMessage: 'Retry', + }, + enabled: { + id: 'course-authoring.pages-resources.app-settings-modal.badge.enabled', + defaultMessage: 'Enabled', + }, + disabled: { + id: 'course-authoring.pages-resources.app-settings-modal.badge.disabled', + defaultMessage: 'Disabled', + }, + +}); + +export default messages; diff --git a/src/pages-and-resources/calculator/Settings.jsx b/src/pages-and-resources/calculator/Settings.jsx new file mode 100644 index 000000000..54fa5c204 --- /dev/null +++ b/src/pages-and-resources/calculator/Settings.jsx @@ -0,0 +1,31 @@ +import { getConfig } from '@edx/frontend-platform'; +import { injectIntl, intlShape } from '@edx/frontend-platform/i18n'; +import PropTypes from 'prop-types'; +import React from 'react'; +import AppSettingsModal from '../app-settings-modal/AppSettingsModal'; + +import messages from './messages'; + +const CALCULATOR_HELP_URL = getConfig().CALCULATOR_HELP_URL + || 'https://edx.readthedocs.io/projects/open-edx-building-and-running-a-course/en/latest/exercises_tools/calculator.html'; + +function CalculatorSettings({ intl, onClose }) { + return ( + + ); +} + +CalculatorSettings.propTypes = { + intl: intlShape.isRequired, + onClose: PropTypes.func.isRequired, +}; + +export default injectIntl(CalculatorSettings); diff --git a/src/pages-and-resources/calculator/messages.js b/src/pages-and-resources/calculator/messages.js new file mode 100644 index 000000000..4a8c0627f --- /dev/null +++ b/src/pages-and-resources/calculator/messages.js @@ -0,0 +1,24 @@ +import { defineMessages } from '@edx/frontend-platform/i18n'; + +const messages = defineMessages({ + heading: { + id: 'course-authoring.pages-resources.calculator.heading', + defaultMessage: 'Configure calculator', + }, + enableProgressLabel: { + id: 'course-authoring.pages-resources.calculator.enable-calculator.label', + defaultMessage: 'Calculator', + }, + enableProgressHelp: { + id: 'course-authoring.pages-resources.calculator.enable-calculator.help', + defaultMessage: `The calculator supports numbers, operators, constants, + functions, and other mathematical concepts. When enabled, an icon to + access the calculator appears on all pages in the body of your course.`, + }, + enableProgressLink: { + id: 'course-authoring.pages-resources.calculator.enable-calculator.link', + defaultMessage: 'Learn more about the calculator', + }, +}); + +export default messages; diff --git a/src/pages-and-resources/data/api.js b/src/pages-and-resources/data/api.js index df51949f7..5d67a064d 100644 --- a/src/pages-and-resources/data/api.js +++ b/src/pages-and-resources/data/api.js @@ -1,61 +1,39 @@ /* eslint-disable import/prefer-default-export */ -export function getPages() { - return Promise.resolve({ - pages: [ - { - id: 'discussions', - title: 'Discussions', - isEnabled: false, - showSettings: false, - showStatus: false, - showEnable: true, - description: 'Encourage participation and engagement in your course with discussion forums.', - }, - { - id: 'teams', - title: 'Teams', - isEnabled: true, - showSettings: true, - showStatus: true, - showEnable: false, - description: 'Leverage teams to allow learners to connect by topic of interest.', - }, - { - id: 'progress', - title: 'Progress', - isEnabled: false, - showSettings: true, - showStatus: true, - showEnable: false, - description: 'Allow students to track their progress throughout the course lorem ipsum.', - }, - { - id: 'textbooks', - title: 'Textbooks', - isEnabled: true, - showSettings: true, - showStatus: true, - showEnable: false, - description: 'Provide links to applicable resources for your course.', - }, - { - id: 'notes', - title: 'Notes', - isEnabled: true, - showSettings: true, - showStatus: true, - showEnable: false, - description: 'Support individual note taking that is visible only to the students.', - }, - { - id: 'wiki', - title: 'Wiki', - isEnabled: false, - showSettings: false, - showStatus: false, - showEnable: true, - description: 'Share your wiki content to provide additional course material.', - }, - ], - }); +import { camelCaseObject, ensureConfig, getConfig } from '@edx/frontend-platform'; +import { getAuthenticatedHttpClient } from '@edx/frontend-platform/auth'; + +ensureConfig([ + 'STUDIO_BASE_URL', +], 'Course Apps API service'); + +const apiBaseUrl = getConfig().STUDIO_BASE_URL; + +const courseAppsApiUrl = `${apiBaseUrl}/api/course_apps/v1/apps`; + +/** + * Fetches the course apps installed for provided course + * @param {string} courseId + * @returns {Promise<[{}]>} + */ +export async function getCourseApps(courseId) { + const { data } = await getAuthenticatedHttpClient() + .get(`${courseAppsApiUrl}/${courseId}`); + return camelCaseObject(data); +} + +/** + * Updates the status of a course app. + * @param {string} courseId Course ID for the course to operate on + * @param {string} appId ID for the application to operate on + * @param {boolean} state The new state + */ +export async function updateCourseApp(courseId, appId, state) { + await getAuthenticatedHttpClient() + .patch( + `${courseAppsApiUrl}/${courseId}`, + { + id: appId, + enabled: state, + }, + ); } diff --git a/src/pages-and-resources/data/selectors.js b/src/pages-and-resources/data/selectors.js new file mode 100644 index 000000000..ce02809ad --- /dev/null +++ b/src/pages-and-resources/data/selectors.js @@ -0,0 +1,4 @@ +/* eslint-disable import/prefer-default-export */ + +export const getLoadingStatus = (status) => status.pagesAndResources.loadingStatus; +export const getSavingStatus = (status) => status.pagesAndResources.savingStatus; diff --git a/src/pages-and-resources/data/slice.js b/src/pages-and-resources/data/slice.js index a2c8d2de5..c84533fcb 100644 --- a/src/pages-and-resources/data/slice.js +++ b/src/pages-and-resources/data/slice.js @@ -1,29 +1,31 @@ /* eslint-disable no-param-reassign */ import { createSlice } from '@reduxjs/toolkit'; - -export const LOADING = 'LOADING'; -export const LOADED = 'LOADED'; -export const FAILED = 'FAILED'; +import { RequestStatus } from '../../data/constants'; const slice = createSlice({ name: 'pagesAndResources', initialState: { - pageIds: [], - status: LOADING, + courseAppIds: [], + loadingStatus: RequestStatus.IN_PROGRESS, + savingStatus: RequestStatus.SUCCESSFUL, }, reducers: { - fetchPagesSuccess: (state, { payload }) => { - state.pageIds = payload.pageIds; + fetchCourseAppsSuccess: (state, { payload }) => { + state.courseAppIds = payload.courseAppIds; }, - updateStatus: (state, { payload }) => { - state.status = payload.status; + updateLoadingStatus: (state, { payload }) => { + state.loadingStatus = payload.status; + }, + updateSavingStatus: (state, { payload }) => { + state.savingStatus = payload.status; }, }, }); export const { - fetchPagesSuccess, - updateStatus, + fetchCourseAppsSuccess, + updateLoadingStatus, + updateSavingStatus, } = slice.actions; export const { diff --git a/src/pages-and-resources/data/thunks.js b/src/pages-and-resources/data/thunks.js index 046df678f..4f19cc14e 100644 --- a/src/pages-and-resources/data/thunks.js +++ b/src/pages-and-resources/data/thunks.js @@ -1,33 +1,47 @@ +import { RequestStatus } from '../../data/constants'; import { - getPages, + getCourseApps, + updateCourseApp, } from './api'; -import { addModels } from '../../generic/model-store'; +import { addModels, updateModel } from '../../generic/model-store'; import { - FAILED, - fetchPagesSuccess, - LOADING, - updateStatus, - LOADED, + fetchCourseAppsSuccess, + updateLoadingStatus, + updateSavingStatus, } from './slice'; /* eslint-disable import/prefer-default-export */ -export function fetchPages(courseId) { +export function fetchCourseApps(courseId) { return async (dispatch) => { - dispatch(updateStatus({ courseId, status: LOADING })); + dispatch(updateLoadingStatus({ courseId, status: RequestStatus.IN_PROGRESS })); try { - const { pages } = await getPages(courseId); + const courseApps = await getCourseApps(courseId); - dispatch(addModels({ modelType: 'pages', models: pages })); - dispatch(fetchPagesSuccess({ - pageIds: pages.map(page => page.id), + dispatch(addModels({ modelType: 'courseApps', models: courseApps })); + dispatch(fetchCourseAppsSuccess({ + courseAppIds: courseApps.map(courseApp => courseApp.id), })); - dispatch(updateStatus({ courseId, status: LOADED })); + dispatch(updateLoadingStatus({ courseId, status: RequestStatus.SUCCESSFUL })); } catch (error) { // TODO: We need generic error handling in the app for when a request just fails... in other // parts of the app (proctored exam settings) we show a nice message and ask the user to // reload/try again later. - dispatch(updateStatus({ courseId, status: FAILED })); + dispatch(updateLoadingStatus({ courseId, status: RequestStatus.FAILED })); + } + }; +} + +export function updateAppStatus(courseId, appId, state) { + return async (dispatch) => { + dispatch(updateSavingStatus({ status: RequestStatus.IN_PROGRESS })); + + try { + await updateCourseApp(courseId, appId, state); + dispatch(updateModel({ modelType: 'courseApps', model: { id: appId, enabled: state } })); + dispatch(updateSavingStatus({ status: RequestStatus.SUCCESSFUL })); + } catch (error) { + dispatch(updateSavingStatus({ status: RequestStatus.FAILED })); } }; } diff --git a/src/pages-and-resources/discussions/DiscussionsSettings.jsx b/src/pages-and-resources/discussions/DiscussionsSettings.jsx index dd88331c1..57f985344 100644 --- a/src/pages-and-resources/discussions/DiscussionsSettings.jsx +++ b/src/pages-and-resources/discussions/DiscussionsSettings.jsx @@ -36,7 +36,7 @@ function DiscussionsSettings({ courseId, intl }) { dispatch(fetchApps(courseId)); }, [courseId]); - const discussionsPath = `${pagesAndResourcesPath}/discussions`; + const discussionsPath = `${pagesAndResourcesPath}/discussion`; const { params: { appId } } = useRouteMatch(); const startStep = appId ? SETTINGS_STEP : SELECTION_STEP; diff --git a/src/pages-and-resources/discussions/DiscussionsSettings.test.jsx b/src/pages-and-resources/discussions/DiscussionsSettings.test.jsx index 6b24777cc..9d50ec566 100644 --- a/src/pages-and-resources/discussions/DiscussionsSettings.test.jsx +++ b/src/pages-and-resources/discussions/DiscussionsSettings.test.jsx @@ -40,8 +40,8 @@ function renderComponent() { @@ -79,7 +79,7 @@ describe('DiscussionsSettings', () => { }); test('sets selection step from routes', async () => { - history.push(`/course/${courseId}/pages-and-resources/discussions`); + 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. @@ -90,7 +90,7 @@ describe('DiscussionsSettings', () => { }); test('sets settings step from routes', async () => { - history.push(`/course/${courseId}/pages-and-resources/discussions/configure/piazza`); + history.push(`/course/${courseId}/pages-and-resources/discussion/configure/piazza`); // 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. @@ -101,7 +101,7 @@ describe('DiscussionsSettings', () => { }); test('successfully advances to settings step for lti', async () => { - history.push(`/course/${courseId}/pages-and-resources/discussions`); + 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. @@ -119,7 +119,7 @@ describe('DiscussionsSettings', () => { test('successfully advances to settings step for legacy', async () => { axiosMock.onGet(getAppsUrl(courseId)).reply(200, legacyApiResponse); renderComponent(); - history.push(`/course/${courseId}/pages-and-resources/discussions`); + 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. @@ -135,7 +135,7 @@ describe('DiscussionsSettings', () => { }); test('successfully goes back to first step', async () => { - history.push(`/course/${courseId}/pages-and-resources/discussions/configure/piazza`); + history.push(`/course/${courseId}/pages-and-resources/discussion/configure/piazza`); // 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. @@ -150,7 +150,7 @@ describe('DiscussionsSettings', () => { }); test('successfully closes the modal', async () => { - history.push(`/course/${courseId}/pages-and-resources/discussions`); + 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. @@ -167,7 +167,7 @@ describe('DiscussionsSettings', () => { }); test('successfully submit the modal', async () => { - history.push(`/course/${courseId}/pages-and-resources/discussions`); + history.push(`/course/${courseId}/pages-and-resources/discussion`); axiosMock.onPost(getAppsUrl(courseId)).reply(200, piazzaApiResponse); @@ -205,7 +205,7 @@ describe('DiscussionsSettings', () => { }); test('shows connection error alert', async () => { - history.push(`/course/${courseId}/pages-and-resources/discussions`); + 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. @@ -232,7 +232,7 @@ describe('DiscussionsSettings', () => { }); test('shows connection error alert at top of form', async () => { - history.push(`/course/${courseId}/pages-and-resources/discussions/configure/piazza`); + history.push(`/course/${courseId}/pages-and-resources/discussion/configure/piazza`); // 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. @@ -262,7 +262,7 @@ describe('DiscussionsSettings', () => { }); test('shows permission denied alert', async () => { - history.push(`/course/${courseId}/pages-and-resources/discussions`); + 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. @@ -283,7 +283,7 @@ describe('DiscussionsSettings', () => { }); test('shows permission denied alert at top of form', async () => { - history.push(`/course/${courseId}/pages-and-resources/discussions/configure/piazza`); + history.push(`/course/${courseId}/pages-and-resources/discussion/configure/piazza`); // 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. @@ -300,7 +300,7 @@ describe('DiscussionsSettings', () => { expect(queryByTestId(container, 'appConfigForm')).not.toBeInTheDocument(); // We don't technically leave the route in this case, though the modal is hidden. - expect(window.location.pathname).toEqual(`/course/${courseId}/pages-and-resources/discussions/configure/piazza`); + expect(window.location.pathname).toEqual(`/course/${courseId}/pages-and-resources/discussion/configure/piazza`); const alert = queryByRole(container, 'alert'); expect(alert).toBeInTheDocument(); diff --git a/src/pages-and-resources/discussions/app-config-form/apps/shared/AppConfigFormDivider.jsx b/src/pages-and-resources/discussions/app-config-form/apps/shared/AppConfigFormDivider.jsx index c49a9ca6d..76ae61a05 100644 --- a/src/pages-and-resources/discussions/app-config-form/apps/shared/AppConfigFormDivider.jsx +++ b/src/pages-and-resources/discussions/app-config-form/apps/shared/AppConfigFormDivider.jsx @@ -1,10 +1,17 @@ -import React from 'react'; +import classNames from 'classnames'; import PropTypes from 'prop-types'; +import React from 'react'; -export default function AppConfigFormDivider({ thick }) { +export default function AppConfigFormDivider({ thick, marginAdj }) { return (
{ history.push(`${pagesAndResourcesPath}/${page.id}`); }; return ( -
-
-
- {page.title} - {page.showSettings && } -
+ + +

{page.name}

+ {(page.allowedOperations.configure || page.allowedOperations.enable) + && ( + history.push(`${pagesAndResourcesPath}/${page.id}/settings`)} + /> + )} +
-
- {page.showStatus && {intl.formatMessage(messages[pageStatusMsgId])}} -
+
-
-

{page.description}

-
+ + {page.description} + - {page.showEnable && !page.isEnabled && ( -
- -
+ {(page.allowedOperations.enable && !page.enabled) && ( + )} -
-
+ + ); } diff --git a/src/pages-and-resources/pages/PageGrid.jsx b/src/pages-and-resources/pages/PageGrid.jsx index 98f2e92e2..5ceb7ede5 100644 --- a/src/pages-and-resources/pages/PageGrid.jsx +++ b/src/pages-and-resources/pages/PageGrid.jsx @@ -1,38 +1,26 @@ -import React from 'react'; import PropTypes from 'prop-types'; -import { injectIntl, intlShape } from '@edx/frontend-platform/i18n'; -import PageCard from './PageCard'; +import React from 'react'; +import { injectIntl } from '@edx/frontend-platform/i18n'; +import { CardGrid } from '@edx/paragon'; +import PageCard, { CoursePageShape } from './PageCard'; -import messages from '../messages'; - -function PageGrid({ intl, pages }) { +function PageGrid({ pages }) { return ( -
-

- {intl.formatMessage(messages['pages.subheading'])} -

-
- {pages.map((page) => ( - - ))} -
-
+ + {pages.map((page) => ( + + ))} + ); } -const pageShape = PropTypes.shape({ - id: PropTypes.string.isRequired, - title: PropTypes.string.isRequired, - isEnabled: PropTypes.bool.isRequired, - showSettings: PropTypes.bool.isRequired, - showStatus: PropTypes.bool.isRequired, - showEnable: PropTypes.bool.isRequired, - description: PropTypes.string.isRequired, -}); - PageGrid.propTypes = { - intl: intlShape.isRequired, - pages: PropTypes.arrayOf(pageShape).isRequired, + pages: PropTypes.arrayOf(CoursePageShape.isRequired).isRequired, }; export default injectIntl(PageGrid);