From e04e8a5a613afc400fb985a7741f057de699226d Mon Sep 17 00:00:00 2001 From: David Joy Date: Tue, 16 Mar 2021 10:58:37 -0400 Subject: [PATCH] Adds Legacy edX Discussions configuration UI (#55) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * feat: Implement edX forums advanced setting editor This change implements the UX for the advanced settings editor for the internal edX forums. * Stepper now intelligently shows/hides drop shadows on the header and footer Also organized the component into a more Paragon-like organization with the sub-components hanging off the main one. * Organizing full screen modal in a more Paragon-like way. Also fixing a few minor styling issues. * Massaging SettingsEditor into LegacyConfigForm - Reorganizing into apps/legacy directory from settings/base-forum - Using Formik and Yup for managing form data - Refactoring FormSwitchGroup a little and adding data-related properties - Also moving FormSwitchGroup into ‘generic’ - Some initial attempts at error validation in LegacyConfigForm (a blackout dates regex which doesn’t seem to work yet) - Sub-sections of config now fold and animate when their parent is toggled. * Minor naming and refactoring of Discussions component - Event handlers should be named handle*, not on*. Oops. Got over-zealous. - Organizing paths into some variables at the top of the component. The pages and resources path should probably be passed in. * Hooking up and organizing LTI and Legacy forms - The LTI form moves into app/lti - Adds in rendering of the legacy discussions form. - Splits up the messages file a bit (app/lti/messages.js exists now) - Removing unnecessary h1 in the LtiConfigForm. * Removing ‘info’ blue coloring from the pages & resources view. Co-authored-by: Kshitij Sobti --- src/generic/FormSwitchGroup.jsx | 40 ++++ .../{FullScreenModalBody.jsx => Body.jsx} | 4 +- .../full-screen-modal/FullScreenModal.jsx | 5 + .../{FullScreenModalHeader.jsx => Header.jsx} | 9 +- src/generic/full-screen-modal/index.js | 1 + src/generic/stepper/Body.jsx | 47 +++++ .../stepper/{StepperFooter.jsx => Footer.jsx} | 14 +- src/generic/stepper/Header.jsx | 69 +++++++ .../stepper/{StepIcon.jsx => Icon.jsx} | 20 +- .../stepper/{StepLine.jsx => Line.jsx} | 2 +- src/generic/stepper/Stepper.jsx | 16 +- src/generic/stepper/StepperBody.jsx | 25 --- src/generic/stepper/StepperContext.jsx | 28 +++ src/generic/stepper/StepperHeader.jsx | 54 ----- src/generic/stepper/index.js | 1 + src/pages-and-resources/PagesAndResources.jsx | 4 +- .../discussions/ConfigFormContainer.jsx | 51 +++-- .../discussions/Discussions.jsx | 108 +++++----- .../apps/legacy/LegacyConfigForm.jsx | 189 ++++++++++++++++++ .../discussions/apps/legacy/index.js | 1 + .../discussions/apps/legacy/messages.js | 104 ++++++++++ .../{ => apps/lti}/LtiConfigForm.jsx | 7 +- .../discussions/apps/lti/index.js | 1 + .../discussions/apps/lti/messages.js | 40 ++++ .../discussions/data/api.js | 44 ++-- .../discussions/messages.js | 39 +--- src/pages-and-resources/pages/PageCard.jsx | 2 +- src/pages-and-resources/pages/PageGrid.jsx | 2 +- 28 files changed, 713 insertions(+), 214 deletions(-) create mode 100644 src/generic/FormSwitchGroup.jsx rename src/generic/full-screen-modal/{FullScreenModalBody.jsx => Body.jsx} (68%) rename src/generic/full-screen-modal/{FullScreenModalHeader.jsx => Header.jsx} (73%) create mode 100644 src/generic/full-screen-modal/index.js create mode 100644 src/generic/stepper/Body.jsx rename src/generic/stepper/{StepperFooter.jsx => Footer.jsx} (59%) create mode 100644 src/generic/stepper/Header.jsx rename src/generic/stepper/{StepIcon.jsx => Icon.jsx} (52%) rename src/generic/stepper/{StepLine.jsx => Line.jsx} (75%) delete mode 100644 src/generic/stepper/StepperBody.jsx create mode 100644 src/generic/stepper/StepperContext.jsx delete mode 100644 src/generic/stepper/StepperHeader.jsx create mode 100644 src/generic/stepper/index.js create mode 100644 src/pages-and-resources/discussions/apps/legacy/LegacyConfigForm.jsx create mode 100644 src/pages-and-resources/discussions/apps/legacy/index.js create mode 100644 src/pages-and-resources/discussions/apps/legacy/messages.js rename src/pages-and-resources/discussions/{ => apps/lti}/LtiConfigForm.jsx (95%) create mode 100644 src/pages-and-resources/discussions/apps/lti/index.js create mode 100644 src/pages-and-resources/discussions/apps/lti/messages.js diff --git a/src/generic/FormSwitchGroup.jsx b/src/generic/FormSwitchGroup.jsx new file mode 100644 index 000000000..770dec7c3 --- /dev/null +++ b/src/generic/FormSwitchGroup.jsx @@ -0,0 +1,40 @@ +import React from 'react'; +import PropTypes from 'prop-types'; +import { Form } from '@edx/paragon'; + +export default function FormSwitchGroup({ + id, label, helpText, className, onChange, onBlur, checked, +}) { + const helpTextId = `${id}HelpText`; + return ( + +
+ + {label} + + +
+ + {helpText} + +
+ ); +} +FormSwitchGroup.propTypes = { + id: PropTypes.string.isRequired, + label: PropTypes.string.isRequired, + helpText: PropTypes.string.isRequired, + className: PropTypes.string, + onChange: PropTypes.func.isRequired, + onBlur: PropTypes.func.isRequired, + checked: PropTypes.bool.isRequired, +}; +FormSwitchGroup.defaultProps = { + className: null, +}; diff --git a/src/generic/full-screen-modal/FullScreenModalBody.jsx b/src/generic/full-screen-modal/Body.jsx similarity index 68% rename from src/generic/full-screen-modal/FullScreenModalBody.jsx rename to src/generic/full-screen-modal/Body.jsx index 43ae47876..910f5d881 100644 --- a/src/generic/full-screen-modal/FullScreenModalBody.jsx +++ b/src/generic/full-screen-modal/Body.jsx @@ -1,7 +1,7 @@ import React from 'react'; import PropTypes from 'prop-types'; -export default function FullScreenModalBody({ children }) { +export default function Body({ children }) { return (
{children} @@ -9,6 +9,6 @@ export default function FullScreenModalBody({ children }) { ); } -FullScreenModalBody.propTypes = { +Body.propTypes = { children: PropTypes.node.isRequired, }; diff --git a/src/generic/full-screen-modal/FullScreenModal.jsx b/src/generic/full-screen-modal/FullScreenModal.jsx index 33c2a7e7d..8b6001ece 100644 --- a/src/generic/full-screen-modal/FullScreenModal.jsx +++ b/src/generic/full-screen-modal/FullScreenModal.jsx @@ -2,6 +2,8 @@ import React from 'react'; import PropTypes from 'prop-types'; import { ModalLayer } from '@edx/paragon'; import classNames from 'classnames'; +import Header from './Header'; +import Body from './Body'; export default function FullScreenModal({ children, title, onClose }) { return ( @@ -28,3 +30,6 @@ FullScreenModal.propTypes = { title: PropTypes.string.isRequired, onClose: PropTypes.func.isRequired, }; + +FullScreenModal.Header = Header; +FullScreenModal.Body = Body; diff --git a/src/generic/full-screen-modal/FullScreenModalHeader.jsx b/src/generic/full-screen-modal/Header.jsx similarity index 73% rename from src/generic/full-screen-modal/FullScreenModalHeader.jsx rename to src/generic/full-screen-modal/Header.jsx index 3fe4f4a52..96e92da92 100644 --- a/src/generic/full-screen-modal/FullScreenModalHeader.jsx +++ b/src/generic/full-screen-modal/Header.jsx @@ -4,19 +4,18 @@ import { ModalCloseButton } from '@edx/paragon'; import { Close } from '@edx/paragon/icons'; import classNames from 'classnames'; -export default function FullScreenModalHeader({ className, title }) { +export default function Header({ className, title }) { return (
-

{title}

+

{title}

@@ -24,11 +23,11 @@ export default function FullScreenModalHeader({ className, title }) { ); } -FullScreenModalHeader.propTypes = { +Header.propTypes = { className: PropTypes.string, title: PropTypes.string.isRequired, }; -FullScreenModalHeader.defaultProps = { +Header.defaultProps = { className: null, }; diff --git a/src/generic/full-screen-modal/index.js b/src/generic/full-screen-modal/index.js new file mode 100644 index 000000000..77d39d198 --- /dev/null +++ b/src/generic/full-screen-modal/index.js @@ -0,0 +1 @@ +export { default } from './FullScreenModal'; diff --git a/src/generic/stepper/Body.jsx b/src/generic/stepper/Body.jsx new file mode 100644 index 000000000..b08a36eff --- /dev/null +++ b/src/generic/stepper/Body.jsx @@ -0,0 +1,47 @@ +import React, { + useCallback, useContext, useEffect, useRef, +} from 'react'; +import PropTypes from 'prop-types'; +import classNames from 'classnames'; +import { StepperContext } from './StepperContext'; + +export default function Body({ children, className }) { + const { setIsAtTop, setIsAtBottom } = useContext(StepperContext); + + const ref = useRef(); + + const handleScroll = useCallback(() => { + setIsAtTop(ref.current.scrollTop === 0); + setIsAtBottom(ref.current.offsetHeight + ref.current.scrollTop === ref.current.scrollHeight); + }, []); + + useEffect(() => { + if (ref) { + setIsAtTop(ref.current.scrollTop === 0); + setIsAtBottom(ref.current.offsetHeight + ref.current.scrollTop === ref.current.scrollHeight); + } + }, [ref.current ? ref.current.scrollHeight : 0]); + + return ( +
+ {children} +
+ ); +} + +Body.propTypes = { + children: PropTypes.node.isRequired, + className: PropTypes.string, +}; + +Body.defaultProps = { + className: null, +}; diff --git a/src/generic/stepper/StepperFooter.jsx b/src/generic/stepper/Footer.jsx similarity index 59% rename from src/generic/stepper/StepperFooter.jsx rename to src/generic/stepper/Footer.jsx index ccb5bffb9..0b33ee55a 100644 --- a/src/generic/stepper/StepperFooter.jsx +++ b/src/generic/stepper/Footer.jsx @@ -1,8 +1,10 @@ -import React from 'react'; +import React, { useContext } from 'react'; import PropTypes from 'prop-types'; import classNames from 'classnames'; +import { StepperContext } from './StepperContext'; -export default function StepperFooter({ children, className }) { +export default function Footer({ children, className }) { + const { isAtBottom } = useContext(StepperContext); return (
{children} @@ -23,11 +27,11 @@ export default function StepperFooter({ children, className }) { ); } -StepperFooter.propTypes = { +Footer.propTypes = { children: PropTypes.node.isRequired, className: PropTypes.string, }; -StepperFooter.defaultProps = { +Footer.defaultProps = { className: null, }; diff --git a/src/generic/stepper/Header.jsx b/src/generic/stepper/Header.jsx new file mode 100644 index 000000000..371f1b8c2 --- /dev/null +++ b/src/generic/stepper/Header.jsx @@ -0,0 +1,69 @@ +import React, { useContext } from 'react'; +import PropTypes from 'prop-types'; +import classNames from 'classnames'; +import Icon from './Icon'; +import Line from './Line'; +import { StepperContext } from './StepperContext'; + +export default function Header({ steps, className }) { + const { isAtTop } = useContext(StepperContext); + + return ( +
+ {steps.map(({ iconLabel, label, incomplete }, index) => { + const isNotLastStep = index < steps.length - 1; + return ( + // eslint-disable-next-line react/no-array-index-key + + + {iconLabel || index + 1} + + {label} + + {isNotLastStep && ( + + )} + + ); + })} +
+ ); +} + +Header.propTypes = { + steps: PropTypes.arrayOf( + PropTypes.shape({ + iconLabel: PropTypes.oneOfType([ + PropTypes.string, + PropTypes.node, + ]), + label: PropTypes.string.isRequired, + }), + ).isRequired, + className: PropTypes.string, +}; + +Header.defaultProps = { + className: null, +}; diff --git a/src/generic/stepper/StepIcon.jsx b/src/generic/stepper/Icon.jsx similarity index 52% rename from src/generic/stepper/StepIcon.jsx rename to src/generic/stepper/Icon.jsx index 03c9f0b48..0bc80a561 100644 --- a/src/generic/stepper/StepIcon.jsx +++ b/src/generic/stepper/Icon.jsx @@ -1,10 +1,20 @@ import React from 'react'; import PropTypes from 'prop-types'; +import classNames from 'classnames'; -export default function StepIcon({ children, size }) { +export default function Icon({ children, size, className }) { return (
); diff --git a/src/generic/stepper/Stepper.jsx b/src/generic/stepper/Stepper.jsx index 96cecd8e5..4cde4c3d7 100644 --- a/src/generic/stepper/Stepper.jsx +++ b/src/generic/stepper/Stepper.jsx @@ -1,6 +1,12 @@ import React from 'react'; import PropTypes from 'prop-types'; import classNames from 'classnames'; +import Icon from './Icon'; +import Line from './Line'; +import Body from './Body'; +import Footer from './Footer'; +import Header from './Header'; +import { StepperContextProvider } from './StepperContext'; export default function Stepper({ children, className }) { return ( @@ -11,7 +17,9 @@ export default function Stepper({ children, className }) { className, )} > - {children} + + {children} +
); } @@ -24,3 +32,9 @@ Stepper.propTypes = { Stepper.defaultProps = { className: null, }; + +Stepper.Icon = Icon; +Stepper.Line = Line; +Stepper.Body = Body; +Stepper.Footer = Footer; +Stepper.Header = Header; diff --git a/src/generic/stepper/StepperBody.jsx b/src/generic/stepper/StepperBody.jsx deleted file mode 100644 index 79db027a4..000000000 --- a/src/generic/stepper/StepperBody.jsx +++ /dev/null @@ -1,25 +0,0 @@ -import React from 'react'; -import PropTypes from 'prop-types'; -import classNames from 'classnames'; - -export default function StepperBody({ children, className }) { - return ( -
- {children} -
- ); -} - -StepperBody.propTypes = { - children: PropTypes.node.isRequired, - className: PropTypes.string, -}; - -StepperBody.defaultProps = { - className: null, -}; diff --git a/src/generic/stepper/StepperContext.jsx b/src/generic/stepper/StepperContext.jsx new file mode 100644 index 000000000..37b5a7aad --- /dev/null +++ b/src/generic/stepper/StepperContext.jsx @@ -0,0 +1,28 @@ +import React, { useState } from 'react'; +import PropTypes from 'prop-types'; + +export const StepperContext = React.createContext({ + isAtTop: true, + isAtBottom: false, +}); + +export function StepperContextProvider({ children }) { + const [isAtTop, setIsAtTop] = useState(true); + const [isAtBottom, setIsAtBottom] = useState(false); + return ( + + {children} + + ); +} + +StepperContextProvider.propTypes = { + children: PropTypes.node.isRequired, +}; diff --git a/src/generic/stepper/StepperHeader.jsx b/src/generic/stepper/StepperHeader.jsx deleted file mode 100644 index 2f816ab71..000000000 --- a/src/generic/stepper/StepperHeader.jsx +++ /dev/null @@ -1,54 +0,0 @@ -import React from 'react'; -import PropTypes from 'prop-types'; -import classNames from 'classnames'; -import StepIcon from './StepIcon'; -import StepLine from './StepLine'; - -export default function StepperHeader({ steps, className }) { - return ( -
- {steps.map(({ iconLabel, label }, index) => { - const isNotLastStep = index < steps.length - 1; - return ( - // eslint-disable-next-line react/no-array-index-key - - - {iconLabel || index + 1} - - {label} - {isNotLastStep && ( - - )} - - ); - })} -
- ); -} - -StepperHeader.propTypes = { - steps: PropTypes.arrayOf( - PropTypes.shape({ - iconLabel: PropTypes.oneOfType([ - PropTypes.string, - PropTypes.node, - ]), - label: PropTypes.string.isRequired, - }), - ).isRequired, - className: PropTypes.string, -}; - -StepperHeader.defaultProps = { - className: null, -}; diff --git a/src/generic/stepper/index.js b/src/generic/stepper/index.js new file mode 100644 index 000000000..2fb2a1bd8 --- /dev/null +++ b/src/generic/stepper/index.js @@ -0,0 +1 @@ +export { default } from './Stepper'; diff --git a/src/pages-and-resources/PagesAndResources.jsx b/src/pages-and-resources/PagesAndResources.jsx index 9714a2b42..9daa06741 100644 --- a/src/pages-and-resources/PagesAndResources.jsx +++ b/src/pages-and-resources/PagesAndResources.jsx @@ -30,9 +30,9 @@ function PagesAndResources({ courseId, intl }) { return (
-
+
-

{intl.formatMessage(messages.heading)}

+

{intl.formatMessage(messages.heading)}

{intl.formatMessage(messages['viewLive.button'])} diff --git a/src/pages-and-resources/discussions/ConfigFormContainer.jsx b/src/pages-and-resources/discussions/ConfigFormContainer.jsx index 628325557..47fce44c3 100644 --- a/src/pages-and-resources/discussions/ConfigFormContainer.jsx +++ b/src/pages-and-resources/discussions/ConfigFormContainer.jsx @@ -1,16 +1,18 @@ import React, { useEffect } from 'react'; import PropTypes from 'prop-types'; import { useDispatch, useSelector } from 'react-redux'; - import { useRouteMatch } from 'react-router'; +import { Card, Container } from '@edx/paragon'; +import { injectIntl, intlShape } from '@edx/frontend-platform/i18n'; + import { useModel } from '../../generic/model-store'; - -import LtiConfigForm from './LtiConfigForm'; import { fetchAppConfig } from './data/thunks'; +import LegacyConfigForm from './apps/legacy'; +import LtiConfigForm from './apps/lti'; +import messages from './messages'; -// eslint-disable-next-line no-unused-vars -export default function ConfigFormContainer({ - courseId, onSubmit, formRef, +function ConfigFormContainer({ + courseId, onSubmit, formRef, intl, }) { const { params: { appId: routeAppId } } = useRouteMatch(); @@ -28,13 +30,35 @@ export default function ConfigFormContainer({ return null; } + let form = null; + + if (app.id === 'edx-discussions') { + form = ( + + ); + } else { + form = ( + + ); + } return ( - + +

+ {intl.formatMessage(messages.configureApp, { name: app.name })} +

+ + {form} + +
); } @@ -43,4 +67,7 @@ ConfigFormContainer.propTypes = { onSubmit: PropTypes.func.isRequired, // eslint-disable-next-line react/forbid-prop-types formRef: PropTypes.object.isRequired, + intl: intlShape.isRequired, }; + +export default injectIntl(ConfigFormContainer); diff --git a/src/pages-and-resources/discussions/Discussions.jsx b/src/pages-and-resources/discussions/Discussions.jsx index 471d62492..1da41d2c0 100644 --- a/src/pages-and-resources/discussions/Discussions.jsx +++ b/src/pages-and-resources/discussions/Discussions.jsx @@ -14,35 +14,52 @@ import { injectIntl, intlShape } from '@edx/frontend-platform/i18n'; import { Button, StatefulButton } from '@edx/paragon'; import { Check } from '@edx/paragon/icons'; -import FullScreenModal from '../../generic/full-screen-modal/FullScreenModal'; -import Stepper from '../../generic/stepper/Stepper'; +import FullScreenModal from '../../generic/full-screen-modal'; +import Stepper from '../../generic/stepper'; +import { useModel } from '../../generic/model-store'; -import messages from './messages'; import AppList from './AppList'; import ConfigFormContainer from './ConfigFormContainer'; +import messages from './messages'; import { fetchApps, saveAppConfig } from './data/thunks'; -import StepperFooter from '../../generic/stepper/StepperFooter'; -import StepperHeader from '../../generic/stepper/StepperHeader'; -import StepperBody from '../../generic/stepper/StepperBody'; -import FullScreenModalHeader from '../../generic/full-screen-modal/FullScreenModalHeader'; -import FullScreenModalBody from '../../generic/full-screen-modal/FullScreenModalBody'; function Discussions({ courseId, intl }) { - const discussionsPath = `/course/${courseId}/pages-and-resources/discussions`; - const [selectedAppId, setSelectedAppId] = useState(null); const [isSubmitting, setIsSubmitting] = useState(false); const formRef = useRef(); - const { path } = useRouteMatch(); const { pathname } = useLocation(); const dispatch = useDispatch(); + const app = useModel('apps', selectedAppId); + + // Route paths + // TODO: pagesAndResourcesPath should probably be passed in. + const pagesAndResourcesPath = `/course/${courseId}/pages-and-resources`; + const discussionsPath = `${pagesAndResourcesPath}/discussions`; + const selectedAppConfigPath = `${discussionsPath}/configure/${selectedAppId}`; + + const isFirstStep = pathname === discussionsPath; + const submitButtonState = isSubmitting ? 'pending' : 'default'; + + const steps = [{ + label: intl.formatMessage(messages.selectDiscussionTool), + iconLabel: isFirstStep ? undefined : ( + + ), + incomplete: false, + }, + { + label: intl.formatMessage(messages.configureApp, { + name: app ? app.name : 'discussions', + }), + incomplete: isFirstStep, + }]; useEffect(() => { dispatch(fetchApps(courseId)); }, [courseId]); - const onSelectApp = useCallback((appId) => { + const handleSelectApp = useCallback((appId) => { if (selectedAppId === appId) { setSelectedAppId(null); } else { @@ -50,57 +67,48 @@ function Discussions({ courseId, intl }) { } }, [selectedAppId]); - const onClose = useCallback(() => { - history.push(`/course/${courseId}/pages-and-resources`); + const handleClose = useCallback(() => { + history.push(pagesAndResourcesPath); }, [courseId]); - const onStartConfig = useCallback(() => { - history.push(`${discussionsPath}/configure/${selectedAppId}`); + const handleStartConfig = useCallback(() => { + history.push(selectedAppConfigPath); }, [discussionsPath, selectedAppId]); // This causes the form to be submitted from a button outside the form. - const onApply = () => { + const handleApply = () => { setIsSubmitting(true); formRef.current.requestSubmit(); }; // This is a callback that gets called after the form has been submitted successfully. - const onSubmit = useCallback((values) => { + const handleSubmit = useCallback((values) => { + console.log(values); dispatch(saveAppConfig(courseId, selectedAppId, values)).then(() => { - history.push(`/course/${courseId}/pages-and-resources`); + history.push(pagesAndResourcesPath); }); }, [courseId, selectedAppId, courseId]); - const onBack = useCallback(() => { - history.push(discussionsPath); - setSelectedAppId(null); + const handleBack = useCallback(() => { + if (isFirstStep) { + history.push(pagesAndResourcesPath); + } else { + history.push(discussionsPath); + setSelectedAppId(null); + } }, [discussionsPath]); - const isFirstStep = pathname === discussionsPath; - - const steps = [{ - label: 'Select discussion tool', - iconLabel: isFirstStep ? undefined : ( - - ), - }, - { - label: 'Configure discussions', - }]; - - const submitButtonState = isSubmitting ? 'pending' : 'default'; - return ( - - - + + + - - + + @@ -108,18 +116,18 @@ function Discussions({ courseId, intl }) { - - - {isFirstStep && ( - )} @@ -132,12 +140,12 @@ function Discussions({ courseId, intl }) { }} state={submitButtonState} className="mr-3" - onClick={onApply} + onClick={handleApply} /> )} - + - + ); } diff --git a/src/pages-and-resources/discussions/apps/legacy/LegacyConfigForm.jsx b/src/pages-and-resources/discussions/apps/legacy/LegacyConfigForm.jsx new file mode 100644 index 000000000..4d56bf80f --- /dev/null +++ b/src/pages-and-resources/discussions/apps/legacy/LegacyConfigForm.jsx @@ -0,0 +1,189 @@ +import React from 'react'; +import PropTypes from 'prop-types'; +import { injectIntl, intlShape } from '@edx/frontend-platform/i18n'; +import { Form, TransitionReplace } from '@edx/paragon'; +import { useFormik } from 'formik'; +import * as Yup from 'yup'; + +import FormSwitchGroup from '../../../../generic/FormSwitchGroup'; +import messages from './messages'; + +function LegacyConfigForm({ + appConfig, onSubmit, intl, formRef, +}) { + // TODO: This regex doesn't seem to be working yet/validation isn't displaying. Unclear why. + const blackoutDateRegex = /(\[\]|\[(\["\d{4}-\d{2}-\d{2}(T\d{2}:\d{2}){0,1}"\])*\])/; + const { + handleSubmit, + handleChange, + handleBlur, + values, + errors, + } = useFormik({ + initialValues: appConfig, + validationSchema: Yup.object().shape({ + blackoutDates: Yup.string().matches(blackoutDateRegex, 'bad regex'), + + }), + onSubmit, + }); + + const divider =
; + + return ( +
+
{intl.formatMessage(messages.divisionByGroup)}
+ + + + {values.divideByCohorts ? ( + + + + + + + + + ) : } + + + {divider} + +
{intl.formatMessage(messages.visibilityInContext)}
+ + + {values.inContextDiscussion ? ( + + + + + + ) : } + + + + {divider} +
{intl.formatMessage(messages.anonymousPosting)}
+ + + + {intl.formatMessage(messages.blackoutDatesLabel)} + + + Bad blackout date + + + {intl.formatMessage(messages.blackoutDatesHelp)} + + + + ); +} + +LegacyConfigForm.propTypes = { + appConfig: PropTypes.shape({ + divideByCohorts: PropTypes.bool.isRequired, + allowDivisionByUnit: PropTypes.bool.isRequired, + divideCourseWideTopics: PropTypes.bool.isRequired, + divideGeneralTopic: PropTypes.bool.isRequired, + divideQuestionsForTAs: PropTypes.bool.isRequired, + inContextDiscussion: PropTypes.bool.isRequired, + gradedUnitPages: PropTypes.bool.isRequired, + groupInContextSubsection: PropTypes.bool.isRequired, + allowUnitLevelVisibility: PropTypes.bool.isRequired, + allowAnonymousPosts: PropTypes.bool.isRequired, + allowAnonymousPostsPeers: PropTypes.bool.isRequired, + blackoutDates: PropTypes.string.isRequired, + }).isRequired, + intl: intlShape.isRequired, + onSubmit: PropTypes.func.isRequired, + // eslint-disable-next-line react/forbid-prop-types + formRef: PropTypes.object.isRequired, +}; + +export default injectIntl(LegacyConfigForm); diff --git a/src/pages-and-resources/discussions/apps/legacy/index.js b/src/pages-and-resources/discussions/apps/legacy/index.js new file mode 100644 index 000000000..f0b934129 --- /dev/null +++ b/src/pages-and-resources/discussions/apps/legacy/index.js @@ -0,0 +1 @@ +export { default } from './LegacyConfigForm'; diff --git a/src/pages-and-resources/discussions/apps/legacy/messages.js b/src/pages-and-resources/discussions/apps/legacy/messages.js new file mode 100644 index 000000000..802b865f9 --- /dev/null +++ b/src/pages-and-resources/discussions/apps/legacy/messages.js @@ -0,0 +1,104 @@ +import { defineMessages } from '@edx/frontend-platform/i18n'; + +const messages = defineMessages({ + divisionByGroup: { + id: 'authoring.discussions.builtIn.divisionByGroup', + defaultMessage: 'Division by group', + }, + divideByCohortsLabel: { + id: 'authoring.discussions.builtIn.divideByCohorts.label', + defaultMessage: 'Divide discussions by cohorts', + }, + divideByCohortsHelp: { + id: 'authoring.discussions.builtIn.divideByCohorts.help', + defaultMessage: 'Learners will only be able to view and respond to discussions posted by members of their cohort.', + }, + allowDivisionByUnitLabel: { + id: 'authoring.discussions.builtIn.allowDivisionByUnit.label', + defaultMessage: 'Allow cohort division for each course unit', + }, + allowDivisionByUnitHelp: { + id: 'authoring.discussions.builtIn.allowDivisionByUnit.help', + defaultMessage: 'With this advanced setting enabled, you will be able to override the global visibility, and turn the division of cohorts on or off for each unit from the course outline view.', + }, + divideCourseWideTopicsLabel: { + id: 'authoring.discussions.builtIn.divideCourseWideTopics.label', + defaultMessage: 'Divide course wide discussion topics', + }, + divideCourseWideTopicsHelp: { + id: 'authoring.discussions.builtIn.divideCourseWideTopics.help', + defaultMessage: 'Choose which of your general course wide discussion topics you would like to divide.', + }, + visibilityInContext: { + id: 'authoring.discussions.builtIn.visibilityInContext', + defaultMessage: 'Visibility of in-context discussions', + }, + inContextDiscussionLabel: { + id: 'authoring.discussions.builtIn.inContextDiscussion.label', + defaultMessage: 'In-context discussion', + }, + inContextDiscussionHelp: { + id: 'authoring.discussions.builtIn.inContextDiscussion.help', + defaultMessage: 'Learners will eb able to view or hide a discussion side panel to engage with discussion on te course unit page.', + }, + gradedUnitPagesLabel: { + id: 'authoring.discussions.builtIn.gradedUnitPages.label', + defaultMessage: 'Graded unit pages', + }, + gradedUnitPagesHelp: { + id: 'authoring.discussions.builtIn.gradedUnitPages.help', + defaultMessage: 'Allow learners to engage with discussion on all graded unit pages except timed exams.', + }, + groupInContextSubsectionLabel: { + id: 'authoring.discussions.builtIn.groupInContextSubsection.label', + defaultMessage: 'Group in context discussion at the subsection level', + }, + groupInContextSubsectionHelp: { + id: 'authoring.discussions.builtIn.groupInContextSubsection.help', + defaultMessage: 'Learners will be able to view any post in the sub-section no matter which unit page they are viewing. While this is not recommended, if your course has short learning sequences or low enrollment grouping may increase engagement.', + }, + allowUnitLevelVisibilityLabel: { + id: 'authoring.discussions.builtIn.allowUnitLevelVisibility.label', + defaultMessage: 'Allow visibility configuration for each course unit', + }, + allowUnitLevelVisibilityHelp: { + id: 'authoring.discussions.builtIn.allowUnitLevelVisibility.help', + defaultMessage: 'With this advanced setting enabled you will be able to override the global visibility setting and turn discussions on or off for each unit from the course outline view..', + }, + anonymousPosting: { + id: 'authoring.discussions.builtIn.anonymousPosting', + defaultMessage: 'Anonymous posting', + }, + allowAnonymousPostsLabel: { + id: 'authoring.discussions.builtIn.allowAnonymous.label', + defaultMessage: 'Allow Anonymous Discussion Posts', + }, + allowAnonymousPostsHelp: { + id: 'authoring.discussions.builtIn.allowAnonymous.help', + defaultMessage: 'Enter true or false. If true, students can create discussion posts that are anonymous to all users.', + }, + allowAnonymousPostsPeersLabel: { + id: 'authoring.discussions.builtIn.allowAnonymousPeers.label', + defaultMessage: 'Allow Anonymous Discussion Posts to Peers', + }, + allowAnonymousPostsPeersHelp: { + id: 'authoring.discussions.builtIn.allowAnonymousPeers.help', + defaultMessage: 'Enter true or false. If true, students can create discussion posts that are anonymous to other students. This setting does not make posts anonymous to course staff.', + }, + blackoutDatesLabel: { + id: 'authoring.discussions.builtIn.blackoutDates.label', + defaultMessage: 'Discussion Blackout Dates', + }, + blackoutDatesHelp: { + id: 'authoring.discussions.builtIn.blackoutDates.help', + defaultMessage: + `Enter pairs of dates between which students cannot post to discussion forums. Inside the provided + brackets, enter an additional set of square brackets surrounding each pair of dates you add. + Format each pair of dates as ["YYYY-MM-DD", "YYYY-MM-DD"]. To specify times as well as dates, + format each pair as ["YYYY-MM-DDTHH:MM", "YYYY-MM-DDTHH:MM"]. Be sure to include the "T" between + the date and time. For example, an entry defining two blackout periods looks like this, including + the outer pair of square brackets: [["2015-09-15", "2015-09-21"], ["2015-10-01", "2015-10-08"]]`, + }, +}); + +export default messages; diff --git a/src/pages-and-resources/discussions/LtiConfigForm.jsx b/src/pages-and-resources/discussions/apps/lti/LtiConfigForm.jsx similarity index 95% rename from src/pages-and-resources/discussions/LtiConfigForm.jsx rename to src/pages-and-resources/discussions/apps/lti/LtiConfigForm.jsx index bc7eec278..7fb92aa8e 100644 --- a/src/pages-and-resources/discussions/LtiConfigForm.jsx +++ b/src/pages-and-resources/discussions/apps/lti/LtiConfigForm.jsx @@ -1,9 +1,7 @@ import React from 'react'; import PropTypes from 'prop-types'; import { FormattedMessage, injectIntl, intlShape } from '@edx/frontend-platform/i18n'; -import { - Form, Hyperlink, -} from '@edx/paragon'; +import { Form, Hyperlink } from '@edx/paragon'; import { useFormik } from 'formik'; import * as Yup from 'yup'; @@ -29,8 +27,7 @@ function LtiConfigForm({ }); return ( -
-

{intl.formatMessage(messages.configureApp, { name: app.name })}

+

{ setTimeout(() => { diff --git a/src/pages-and-resources/discussions/messages.js b/src/pages-and-resources/discussions/messages.js index e01e66e7b..754d9071a 100644 --- a/src/pages-and-resources/discussions/messages.js +++ b/src/pages-and-resources/discussions/messages.js @@ -40,40 +40,6 @@ const messages = defineMessages({ defaultMessage: 'Saved', description: 'Button text shown once a discussion config has been saved to the server.', }, - documentationPage: { - id: 'authoring.discussions.documentationPage', - defaultMessage: 'Visit the {name} documentation page', - }, - consumerKey: { - id: 'authoring.discussions.consumerKey', - defaultMessage: 'Consumer Key', - description: 'Label for the Consumer Key field.', - }, - consumerSecret: { - id: 'authoring.discussions.consumerSecret', - defaultMessage: 'Consumer Secret', - description: 'Label for the Consumer Secret field.', - }, - launchUrl: { - id: 'authoring.discussions.launchUrl', - defaultMessage: 'Launch URL', - description: 'Label for the Launch URL field.', - }, - consumerKeyRequired: { - id: 'authoring.discussions.consumerKey.required', - defaultMessage: 'Consumer Key is a required field.', - description: 'Tells the user that the Consumer Key field is required and must have a value.', - }, - consumerSecretRequired: { - id: 'authoring.discussions.consumerSecret.required', - defaultMessage: 'Consumer Secret is a required field.', - description: 'Tells the user that the Consumer Secret field is required and must have a value.', - }, - launchUrlRequired: { - id: 'authoring.discussions.launchUrl.required', - defaultMessage: 'Launch URL is a required field.', - description: 'Tells the user that the Launch URL field is required and must have a value.', - }, backButton: { id: 'authoring.discussions.backButton', defaultMessage: 'Back', @@ -89,6 +55,11 @@ const messages = defineMessages({ defaultMessage: 'Apply', description: 'Button allowing the user to submit their discussion configuration.', }, + selectDiscussionTool: { + id: 'authoring.discussions.selectDiscussionTool', + defaultMessage: 'Select discussion tool', + description: 'A label for the first step of a wizard where the user chooses a discussion tool to configure.', + }, }); export default messages; diff --git a/src/pages-and-resources/pages/PageCard.jsx b/src/pages-and-resources/pages/PageCard.jsx index e3b8a46ac..68f8525ea 100644 --- a/src/pages-and-resources/pages/PageCard.jsx +++ b/src/pages-and-resources/pages/PageCard.jsx @@ -29,7 +29,7 @@ function PageCard({ intl, page }) { const componentClasses = classNames( 'd-flex flex-column align-content-stretch', 'bg-white p-3 border shadow', - { 'border-info-300': page.isEnabled, 'border-gray-100': !page.isEnabled }, + { 'border-gray-500': page.isEnabled, 'border-gray-100': !page.isEnabled }, ); const handleClick = () => { diff --git a/src/pages-and-resources/pages/PageGrid.jsx b/src/pages-and-resources/pages/PageGrid.jsx index f7ef9c064..98f2e92e2 100644 --- a/src/pages-and-resources/pages/PageGrid.jsx +++ b/src/pages-and-resources/pages/PageGrid.jsx @@ -7,7 +7,7 @@ import messages from '../messages'; function PageGrid({ intl, pages }) { return ( -

+

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