feat: add teams setting editor component

This commit adds a setting editor for teams that allows adding/removing and editing teams.
This commit is contained in:
Kshitij Sobti
2021-03-30 16:42:30 +05:30
committed by Awais Jibran
parent a047236f59
commit 7682a17758
4 changed files with 425 additions and 14 deletions

View File

@@ -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 && (
<TransitionReplace>
{formikProps.values.enabled
? (
<React.Fragment key="app-enabled">
{children(formikProps)}
</React.Fragment>
) : (
<React.Fragment key="app-disabled" />
)}
{showForm ? (
<React.Fragment key="app-enabled">
{children(formikProps)}
</React.Fragment>
) : (
<React.Fragment key="app-disabled" />
)}
</TransitionReplace>
);
}
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({
</div>
)}
/>
{formikProps.values.enabled && children
{(formikProps.values.enabled || configureBeforeEnable) && children
&& <AppConfigFormDivider marginAdj={{ default: 0, sm: 0 }} />}
<AppSettingsForm formikProps={formikProps}>
<AppSettingsForm formikProps={formikProps} showForm={formikProps.values.enabled || configureBeforeEnable}>
{children}
</AppSettingsForm>
</AppSettingsModalBase>
@@ -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);

View File

@@ -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 (
<AppSettingsModal
appId="teams"
title={intl.formatMessage(messages.heading)}
enableAppHelp={intl.formatMessage(messages.enableTeamsHelp)}
enableAppLabel={intl.formatMessage(messages.enableTeamsLabel)}
learnMoreText={intl.formatMessage(messages.enableTeamsLink)}
onClose={onClose}
initialValues={{ ...teamsConfiguration }}
validationSchema={{
topics: Yup.array().of(
Yup.object({
id: Yup.string(),
name: Yup.string().required().trim(),
type: Yup.mixed().oneOf(Object.values(TeamSetTypes)),
description: Yup.string().required().trim(),
maxTeamSize: Yup.number().required().min(TeamSizes.MIN).max(TeamSizes.MAX),
}),
).default([]),
}}
onSettingsSave={handleSettingsSave}
configureBeforeEnable
>
{
({
handleChange, handleBlur, values, errors, touched,
}) => (
<>
<h5>{intl.formatMessage(messages.teamSets)}</h5>
<FieldArray name="topics">
{({ push, remove }) => (
<div>
{values.topics?.map((topic, index) => (
<TeamSetEditor
key={index}
teamSet={topic}
errors={errors.topics?.[index]}
touched={touched.topics?.[index]}
fieldNameCommonBase={`topics.${index}`}
onDelete={() => remove(index)}
onChange={handleChange}
onBlur={handleBlur}
/>
))}
<Button
variant="plain"
onClick={() => push(blankNewTopic)}
>
<Icon src={Add} /> {intl.formatMessage(messages.addTeamSet)}
</Button>
</div>
)}
</FieldArray>
</>
)
}
</AppSettingsModal>
);
}
TeamSettings.propTypes = {
intl: intlShape.isRequired,
onClose: PropTypes.func.isRequired,
};
export default injectIntl(TeamSettings);

View File

@@ -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 (
<Collapsible
title={(
<div className="d-flex flex-column small flex-shrink-1">
<span>{intl.formatMessage(TeamTypeNameMessage[teamSet.type])}</span>
<span className="h4 text-truncate">{teamSet.name || '<new>'}</span>
<span className="text-truncate">{teamSet.description || '<new>'}</span>
</div>
)}
// If there is no id, this is a new team, so automatically open it
defaultOpen={teamSet.id === null}
>
<TransitionReplace>
{isDeleting
? (
<div className="d-flex flex-column m-4" key="isDeleting">
<h3>{intl.formatMessage(messages.teamSetDeleteHeading)}</h3>
<p>{intl.formatMessage(messages.teamSetDeleteBody)}</p>
<div className="d-flex flex-row justify-content-end">
<Button variant="muted" size="sm" onClick={() => onDelete(teamSet)}>
{intl.formatMessage(messages.delete)}
</Button>
<Button variant="muted" size="sm" onClick={cancelDeletion}>
{intl.formatMessage(messages.cancel)}
</Button>
</div>
</div>
)
: (
<React.Fragment key="isConfiguring">
<Form.Group key="isConfiguring">
<Form.Control
name={`${fieldNameCommonBase}.name`}
floatingLabel={intl.formatMessage(messages.teamSetFormNameLabel)}
defaultValue={teamSet.name}
onChange={onChange}
onBlur={onBlur}
/>
<Form.Text>{intl.formatMessage(messages.teamSetFormNameHelp)}</Form.Text>
{(touched?.name && errors?.name) && (
<Form.Control.Feedback type="invalid" hasIcon={false}>
{intl.formatMessage(messages.teamSetFormNameError)}
</Form.Control.Feedback>
)}
</Form.Group>
<Form.Group>
<Form.Control
name={`${fieldNameCommonBase}.description`}
floatingLabel={intl.formatMessage(messages.teamSetFormDescriptionLabel)}
defaultValue={teamSet.description}
onChange={onChange}
onBlur={onBlur}
/>
<Form.Text>{intl.formatMessage(messages.teamSetFormDescriptionHelp)}</Form.Text>
{(touched?.description && errors?.description) && (
<Form.Control.Feedback type="invalid" hasIcon={false}>
{intl.formatMessage(messages.teamSetFormDescriptionError)}
</Form.Control.Feedback>
)}
</Form.Group>
<Form.Group>
<Form.Control
as="select"
name={`${fieldNameCommonBase}.type`}
floatingLabel={intl.formatMessage(messages.teamSetFormTypeLabel)}
defaultValue={teamSet.type}
onChange={onChange}
onBlur={onBlur}
>
{Object.values(TeamSetTypes).map(teamSetType => (
<option value={teamSetType} key={teamSetType}>
{intl.formatMessage(TeamTypeNameMessage[teamSetType])}
</option>
))}
</Form.Control>
<Form.Text>{intl.formatMessage(messages.teamSetFormTypeHelp)}</Form.Text>
</Form.Group>
<Form.Group>
<Form.Control
type="number"
name={`${fieldNameCommonBase}.maxTeamSize`}
floatingLabel={intl.formatMessage(messages.teamSetFormMaxSizeLabel)}
defaultValue={teamSet.maxTeamSize}
onChange={onChange}
onBlur={onBlur}
/>
<Form.Text>{intl.formatMessage(messages.teamSetFormMaxSizeHelp)}</Form.Text>
{(touched?.maxTeamSize && errors?.maxTeamSize) && (
<Form.Control.Feedback type="invalid" hasIcon={false}>
{intl.formatMessage(messages.teamSetFormMaxSizeError, {
min: TeamSizes.MIN,
max: TeamSizes.MAX,
})}
</Form.Control.Feedback>
)}
</Form.Group>
<hr style={{ marginLeft: '-0.75rem', marginRight: '-0.5rem' }} />
<Button variant="muted" className="p-0" onClick={initiateDeletion}>
{intl.formatMessage(messages.delete)}
</Button>
</React.Fragment>
)}
</TransitionReplace>
</Collapsible>
);
}
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);

View File

@@ -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;