diff --git a/src/pages-and-resources/app-settings-modal/AppSettingsModal.jsx b/src/pages-and-resources/app-settings-modal/AppSettingsModal.jsx index 1184a3e22..4c7cca8b5 100644 --- a/src/pages-and-resources/app-settings-modal/AppSettingsModal.jsx +++ b/src/pages-and-resources/app-settings-modal/AppSettingsModal.jsx @@ -23,25 +23,25 @@ import AppConfigFormDivider from '../discussions/app-config-form/apps/shared/App import { PagesAndResourcesContext } from '../PagesAndResourcesProvider'; import messages from './messages'; -function AppSettingsForm({ formikProps, children }) { +function AppSettingsForm({ formikProps, children, showForm }) { return children && ( - {formikProps.values.enabled - ? ( - - {children(formikProps)} - - ) : ( - - )} + {showForm ? ( + + {children(formikProps)} + + ) : ( + + )} ); } AppSettingsForm.propTypes = { - formikProps: PropTypes.shape({ - values: PropTypes.shape({ enabled: PropTypes.bool.isRequired }), - }).isRequired, + // Ignore the warning here since we're just passing along the props as-is and the child component should validate + // eslint-disable-next-line react/forbid-prop-types + formikProps: PropTypes.object.isRequired, + showForm: PropTypes.bool.isRequired, children: PropTypes.func, }; @@ -101,6 +101,7 @@ function AppSettingsModal({ appId, title, children, + configureBeforeEnable, initialValues, validationSchema, onClose, @@ -210,9 +211,9 @@ function AppSettingsModal({ )} /> - {formikProps.values.enabled && children + {(formikProps.values.enabled || configureBeforeEnable) && children && } - + {children} @@ -251,6 +252,7 @@ AppSettingsModal.propTypes = { enableAppLabel: PropTypes.string.isRequired, enableAppHelp: PropTypes.string.isRequired, learnMoreText: PropTypes.string.isRequired, + configureBeforeEnable: PropTypes.bool, }; AppSettingsModal.defaultProps = { @@ -258,6 +260,7 @@ AppSettingsModal.defaultProps = { onSettingsSave: null, initialValues: {}, validationSchema: {}, + configureBeforeEnable: false, }; export default injectIntl(AppSettingsModal); diff --git a/src/pages-and-resources/teams/Settings.jsx b/src/pages-and-resources/teams/Settings.jsx new file mode 100644 index 000000000..059c69992 --- /dev/null +++ b/src/pages-and-resources/teams/Settings.jsx @@ -0,0 +1,105 @@ +import { injectIntl, intlShape } from '@edx/frontend-platform/i18n'; +import { Button, Icon } from '@edx/paragon'; +import { Add } from '@edx/paragon/icons'; +import { FieldArray } from 'formik'; + +import PropTypes from 'prop-types'; +import React from 'react'; +import { v4 as uuid } from 'uuid'; +import * as Yup from 'yup'; +import { useAppSetting } from '../../utils'; +import AppSettingsModal from '../app-settings-modal/AppSettingsModal'; +import messages from './messages'; +import TeamSetEditor, { TeamSetTypes, TeamSizes } from './TeamSetEditor'; + +function TeamSettings({ + intl, + onClose, +}) { + const [teamsConfiguration, saveSettings] = useAppSetting('teamsConfiguration'); + const blankNewTopic = { + name: '', + description: '', + type: 'open', + maxTeamSize: TeamSizes.DEFAULT, + id: null, + }; + + const handleSettingsSave = (values) => { + // For newly-added teams, fill in an id. + const topics = values.topics.map(topic => ({ + id: topic.id || uuid(), + name: topic.name, + type: topic.type, + description: topic.description, + max_team_size: topic.maxTeamSize, + })); + saveSettings({ topics }); + }; + + return ( + + { + ({ + handleChange, handleBlur, values, errors, touched, + }) => ( + <> + {intl.formatMessage(messages.teamSets)} + + {({ push, remove }) => ( + + {values.topics?.map((topic, index) => ( + remove(index)} + onChange={handleChange} + onBlur={handleBlur} + /> + ))} + push(blankNewTopic)} + > + {intl.formatMessage(messages.addTeamSet)} + + + )} + + > + ) + } + + ); +} + +TeamSettings.propTypes = { + intl: intlShape.isRequired, + onClose: PropTypes.func.isRequired, +}; + +export default injectIntl(TeamSettings); diff --git a/src/pages-and-resources/teams/TeamSetEditor.jsx b/src/pages-and-resources/teams/TeamSetEditor.jsx new file mode 100644 index 000000000..90b974a3b --- /dev/null +++ b/src/pages-and-resources/teams/TeamSetEditor.jsx @@ -0,0 +1,193 @@ +import { injectIntl, intlShape } from '@edx/frontend-platform/i18n'; + +import { + Button, Collapsible, Form, TransitionReplace, +} from '@edx/paragon'; +import PropTypes from 'prop-types'; +import React, { useState } from 'react'; +import messages from './messages'; + +/** + * Team sizes enum + * @enum + * @type {{MIN: number, MAX: number, DEFAULT: number}} + */ +export const TeamSizes = { + DEFAULT: 50, + MIN: 1, + MAX: 500, +}; + +/** + * Team types enum + * @enum + * @type {{PRIVATE_MANAGED: string, PUBLIC_MANAGED: string, OPEN: string}} + */ +export const TeamSetTypes = { + OPEN: 'open', + PUBLIC_MANAGED: 'public_managed', + PRIVATE_MANAGED: 'private_managed', +}; + +// Maps a team type to its corresponding intl message +const TeamTypeNameMessage = { + [TeamSetTypes.OPEN]: messages.teamSetTypeOpen, + [TeamSetTypes.PUBLIC_MANAGED]: messages.teamSetTypePublicManaged, + [TeamSetTypes.PRIVATE_MANAGED]: messages.teamSetTypePrivateManaged, +}; + +function TeamSetEditor({ + intl, teamSet, onDelete, onChange, onBlur, fieldNameCommonBase, errors, touched, +}) { + const [isDeleting, setDeleting] = useState(false); + const initiateDeletion = () => setDeleting(true); + const cancelDeletion = () => setDeleting(false); + + return ( + + {intl.formatMessage(TeamTypeNameMessage[teamSet.type])} + {teamSet.name || ''} + {teamSet.description || ''} + + )} + // If there is no id, this is a new team, so automatically open it + defaultOpen={teamSet.id === null} + > + + {isDeleting + ? ( + + {intl.formatMessage(messages.teamSetDeleteHeading)} + {intl.formatMessage(messages.teamSetDeleteBody)} + + onDelete(teamSet)}> + {intl.formatMessage(messages.delete)} + + + {intl.formatMessage(messages.cancel)} + + + + ) + : ( + + + + {intl.formatMessage(messages.teamSetFormNameHelp)} + {(touched?.name && errors?.name) && ( + + {intl.formatMessage(messages.teamSetFormNameError)} + + )} + + + + {intl.formatMessage(messages.teamSetFormDescriptionHelp)} + {(touched?.description && errors?.description) && ( + + {intl.formatMessage(messages.teamSetFormDescriptionError)} + + )} + + + + {Object.values(TeamSetTypes).map(teamSetType => ( + + {intl.formatMessage(TeamTypeNameMessage[teamSetType])} + + ))} + + {intl.formatMessage(messages.teamSetFormTypeHelp)} + + + + {intl.formatMessage(messages.teamSetFormMaxSizeHelp)} + {(touched?.maxTeamSize && errors?.maxTeamSize) && ( + + {intl.formatMessage(messages.teamSetFormMaxSizeError, { + min: TeamSizes.MIN, + max: TeamSizes.MAX, + })} + + )} + + + + {intl.formatMessage(messages.delete)} + + + )} + + + ); +} + +export const teamSetShape = PropTypes.shape({ + id: PropTypes.string, + name: PropTypes.string.isRequired, + description: PropTypes.string.isRequired, + type: PropTypes.string.isRequired, + maxTeamSize: PropTypes.number.isRequired, +}); + +TeamSetEditor.propTypes = { + intl: intlShape.isRequired, + fieldNameCommonBase: PropTypes.string.isRequired, + errors: PropTypes.shape({ + name: PropTypes.string, + description: PropTypes.string, + maxTeamSize: PropTypes.string, + }), + touched: PropTypes.shape({ + name: PropTypes.bool, + description: PropTypes.bool, + maxTeamSize: PropTypes.bool, + }), + teamSet: teamSetShape.isRequired, + onDelete: PropTypes.func.isRequired, + onChange: PropTypes.func.isRequired, + onBlur: PropTypes.func.isRequired, +}; + +TeamSetEditor.defaultProps = { + errors: { + name: null, + description: null, + maxTeamSize: null, + }, + touched: { + name: false, + description: false, + maxTeamSize: false, + }, +}; + +export default injectIntl(TeamSetEditor); diff --git a/src/pages-and-resources/teams/messages.js b/src/pages-and-resources/teams/messages.js new file mode 100644 index 000000000..b1bca5bd1 --- /dev/null +++ b/src/pages-and-resources/teams/messages.js @@ -0,0 +1,110 @@ +import { defineMessages } from '@edx/frontend-platform/i18n'; + +const messages = defineMessages({ + heading: { + id: 'authoring.pagesAndResources.teams.heading', + defaultMessage: 'Configure teams', + }, + enableTeamsLabel: { + id: 'authoring.pagesAndResources.teams.enableTeams.label', + defaultMessage: 'Teams', + }, + enableTeamsHelp: { + id: 'authoring.pagesAndResources.teams.enableTeams.help', + defaultMessage: `Define the structure of teams in your course by adding + team-sets; groups focussed around specific topics you create`, + }, + enableTeamsLink: { + id: 'authoring.pagesAndResources.teams.enableTeams.link', + defaultMessage: 'Learn more about the teams', + }, + teamSets: { + id: 'authoring.pagesAndResources.teams.teamSets.heading', + defaultMessage: 'Team-sets', + }, + configureTeamSet: { + id: 'authoring.pagesAndResources.teams.configureTeamSet.heading', + defaultMessage: 'Configure team-set', + }, + teamSetFormNameLabel: { + id: 'authoring.pagesAndResources.teams.teamSet.name.label', + defaultMessage: 'Name', + }, + teamSetFormNameHelp: { + id: 'authoring.pagesAndResources.teams.teamSet.name.help', + defaultMessage: 'The name learners will see when interacting with your team-set', + }, + teamSetFormNameError: { + id: 'authoring.pagesAndResources.teams.teamSet.name.error', + defaultMessage: 'Team set name can\'t be blank', + }, + teamSetFormDescriptionLabel: { + id: 'authoring.pagesAndResources.teams.teamSet.description.label', + defaultMessage: 'Description', + }, + teamSetFormDescriptionHelp: { + id: 'authoring.pagesAndResources.teams.teamSet.description.help', + defaultMessage: 'Details about your team-set, displayed below the team-set name.', + }, + teamSetFormDescriptionError: { + id: 'authoring.pagesAndResources.teams.teamSet.description.error', + defaultMessage: 'Team set description can\'t be blank.', + }, + teamSetFormTypeLabel: { + id: 'authoring.pagesAndResources.teams.teamSet.type.label', + defaultMessage: 'Type', + }, + teamSetFormTypeHelp: { + id: 'authoring.pagesAndResources.teams.teamSet.type.help', + defaultMessage: 'Control who can see, create and join teams', + }, + teamSetTypeOpen: { + id: 'authoring.pagesAndResources.teams.teamSet.types.open', + defaultMessage: 'Open', + }, + teamSetTypePublicManaged: { + id: 'authoring.pagesAndResources.teams.teamSet.types.public_managed', + defaultMessage: 'Public Managed', + }, + teamSetTypePrivateManaged: { + id: 'authoring.pagesAndResources.teams.teamSet.types.private_managed', + defaultMessage: 'Private Managed', + }, + teamSetFormMaxSizeLabel: { + id: 'authoring.pagesAndResources.teams.teamSet.maxSize.label', + defaultMessage: 'Max team size', + }, + teamSetFormMaxSizeHelp: { + id: 'authoring.pagesAndResources.teams.teamSet.maxSize.help', + defaultMessage: 'The maximum number of learners that can join a team', + }, + teamSetFormMaxSizeError: { + id: 'authoring.pagesAndResources.teams.teamSet.maxSize.error', + defaultMessage: 'The maximum team size must be a number between {min} and {max}', + }, + addTeamSet: { + id: 'authoring.pagesAndResources.teams.addTeamSet.button', + defaultMessage: 'Add team-set', + }, + delete: { + id: 'authoring.pagesAndResources.teams.deleteTeamSet.delete.button', + defaultMessage: 'Delete', + }, + cancel: { + id: 'authoring.pagesAndResources.teams.deleteTeamSet.cancel-delete.button', + defaultMessage: 'Cancel', + }, + teamSetDeleteHeading: { + id: 'authoring.pagesAndResources.teams.deleteTeamSet.heading', + defaultMessage: 'Delete team-set?', + }, + teamSetDeleteBody: { + id: 'authoring.pagesAndResources.teams.deleteTeamSet.body', + defaultMessage: `EdX recommends that you do not delete team-sets once your + course is running. Your team-set will no longer be visible in the LMS and + learners will not be able to leave teams associated with it. Please delete + learners from teams before deleting the associated teamSet.`, + }, +}); + +export default messages;
{intl.formatMessage(messages.teamSetDeleteBody)}