diff --git a/src/components/Groups/GroupsTreeView/GroupsTreeList.js b/src/components/Groups/GroupsTreeView/GroupsTreeList.js index 9b36459..50203b7 100644 --- a/src/components/Groups/GroupsTreeView/GroupsTreeList.js +++ b/src/components/Groups/GroupsTreeView/GroupsTreeList.js @@ -3,23 +3,46 @@ import PropTypes from 'prop-types'; import GroupsTreeNode from './GroupsTreeNode.js'; -const GroupsTreeList = React.memo(({ groups, isExpanded = false, addAttribute, removeAttribute, locale }) => ( - -)); +const GroupsTreeList = React.memo( + ({ + groups, + checkboxes, + highlight, + checked, + errors, + setChecked, + isExpanded = false, + addAttribute, + removeAttribute, + locale, + }) => ( + + ) +); GroupsTreeList.propTypes = { groups: PropTypes.array.isRequired, + checkboxes: PropTypes.func, + highlight: PropTypes.func, + checked: PropTypes.object, + errors: PropTypes.object, + setChecked: PropTypes.func, isExpanded: PropTypes.bool, addAttribute: PropTypes.func, removeAttribute: PropTypes.func, diff --git a/src/components/Groups/GroupsTreeView/GroupsTreeNode.js b/src/components/Groups/GroupsTreeView/GroupsTreeNode.js index 6b2feae..19e0053 100644 --- a/src/components/Groups/GroupsTreeView/GroupsTreeNode.js +++ b/src/components/Groups/GroupsTreeView/GroupsTreeNode.js @@ -8,8 +8,16 @@ import Button from '../../widgets/TheButton'; import Confirm from '../../widgets/Confirm'; import GroupsTreeList from './GroupsTreeList.js'; import GroupMembershipIcon from '../GroupMembershipIcon'; -import Icon, { AddIcon, CloseIcon, GroupIcon, LectureIcon, LoadingIcon, TermIcon } from '../../icons'; -import withLinks from '../../../helpers/withLinks.js'; +import Icon, { + AddIcon, + CloseIcon, + GroupIcon, + LectureIcon, + LoadingIcon, + PlantIcon, + TermIcon, + WarningIcon, +} from '../../icons'; import { EMPTY_OBJ } from '../../../helpers/common.js'; const DEFAULT_ICON = ['far', 'square']; @@ -35,164 +43,223 @@ const getLocalizedName = (name, id, locale) => { const KNOWN_ATTR_KEYS = { course: 'primary', + 'for-term': 'success', term: 'info', group: 'warning', }; const ATTR_ICONS = { course: , + 'for-term': , term: , group: , }; -const GroupsTreeNode = React.memo(({ group, isExpanded = false, addAttribute, removeAttribute, links, locale }) => { - const { - id, - admins: adminsRaw, - name, - organizational = false, - exam = false, - attributes = EMPTY_OBJ, - membership, - children = [], - pending = false, - } = group; - - const admins = Array.isArray(adminsRaw) ? adminsRaw : Object.values(adminsRaw || {}); - const leafNode = children.length === 0; - const [isOpen, setOpen] = useState(isExpanded); - - return ( -
  • - setOpen(!isOpen)} className="clearfix"> - - - {getLocalizedName(name, id, locale)} - - {admins && admins.length > 0 && ( - - ( - {admins.length > 2 ? ( - - - - : - - {adminsList(admins)} - - }> - - - - - ) : ( - {adminsList(admins)} - )} - ) - - )} +const ATTR_TRANSFORM = { + 'for-term': value => + value === '1' ? ( + + ) : value === '2' ? ( + + ) : ( + value + ), +}; - {exam && ( - } - /> - )} +const GroupsTreeNode = React.memo( + ({ + group, + checkboxes = null, + highlight = null, + checked = null, + errors = null, + setChecked = null, + isExpanded = false, + addAttribute, + removeAttribute, + locale, + }) => { + const { + id, + admins: adminsRaw, + name, + organizational = false, + exam = false, + attributes = EMPTY_OBJ, + membership, + children = [], + pending = false, + } = group; - {organizational && ( - setChecked(id, !isChecked) : () => setOpen(!isOpen); + const error = (errors && errors[id]) || null; + const highlightClass = highlight ? highlight(group) : ''; + const highlightIconClass = highlight ? highlight(group) : 'text-body-tertiary'; + + return ( +
  • + + + {hasCheckbox ? ( + - } - /> - )} + ) : checkboxes ? ( + + ) : ( + + )} - - {pending && } + {error && ( + + )} - {addAttribute && ( - - + {getLocalizedName(name, id, locale)} - )} - {attributes && Object.keys(attributes).length > 0 && ( - - {Object.keys(attributes).map(key => - attributes[key].map(value => ( - - {ATTR_ICONS[key]} - {!KNOWN_ATTR_KEYS[key] && `${key}: `} - {value} - - {removeAttribute && !pending && ( - removeAttribute(id, key, value)} - question={ + {admins && admins.length > 0 && ( + + ( + {admins.length > 2 ? ( + + - }> - - - )} - {pending && } - - )) - )} - + : + + {adminsList(admins)} + + }> + + + + + ) : ( + {adminsList(admins)} + )} + ) + + )} + + {exam && ( + } + /> + )} + + {organizational && ( + + } + /> + )} + + + {pending && } + + {addAttribute && ( + + + + )} + + {attributes && Object.keys(attributes).length > 0 && ( + + {Object.keys(attributes).map(key => + attributes[key].map(value => ( + + {ATTR_ICONS[key]} + {!KNOWN_ATTR_KEYS[key] && `${key}: `} + {ATTR_TRANSFORM[key] ? ATTR_TRANSFORM[key](value) : value} + + {removeAttribute && !pending && ( + removeAttribute(id, key, value)} + question={ + + }> + + + )} + {pending && } + + )) + )} + + )} + + + {!leafNode && ( + + + )} - - - {!leafNode && ( - - - - )} -
  • - ); -}); + + ); + } +); GroupsTreeNode.propTypes = { group: PropTypes.shape({ @@ -206,11 +273,15 @@ GroupsTreeNode.propTypes = { children: PropTypes.arrayOf(PropTypes.object), pending: PropTypes.bool, }), + checkboxes: PropTypes.func, + highlight: PropTypes.func, + checked: PropTypes.object, + errors: PropTypes.object, + setChecked: PropTypes.func, isExpanded: PropTypes.bool, addAttribute: PropTypes.func, removeAttribute: PropTypes.func, locale: PropTypes.string.isRequired, - links: PropTypes.object, }; -export default withLinks(GroupsTreeNode); +export default GroupsTreeNode; diff --git a/src/components/Groups/GroupsTreeView/GroupsTreeView.js b/src/components/Groups/GroupsTreeView/GroupsTreeView.js index 83c8800..8245ed3 100644 --- a/src/components/Groups/GroupsTreeView/GroupsTreeView.js +++ b/src/components/Groups/GroupsTreeView/GroupsTreeView.js @@ -1,6 +1,5 @@ import React from 'react'; import PropTypes from 'prop-types'; -import ImmutablePropTypes from 'react-immutable-proptypes'; import { FormattedMessage, injectIntl } from 'react-intl'; import GroupsTreeList from './GroupsTreeList.js'; @@ -11,8 +10,20 @@ import './GroupsTreeView.css'; /* * Component displaying groups in a hierarchical tree view with associated attributes. */ -const GroupsTreeView = ({ groups, isExpanded = false, intl: { locale }, addAttribute, removeAttribute }) => { - const topLevelGroups = getTopLevelGroups(groups, locale); +const GroupsTreeView = ({ + groups, + filter = null, + checkboxes = null, + highlight = null, + checked = null, + setChecked = null, + isExpanded = false, + addAttribute = null, + removeAttribute = null, + errors = null, + intl: { locale }, +}) => { + const topLevelGroups = getTopLevelGroups(groups, locale, filter); return topLevelGroups.length === 0 ? ( ) : ( @@ -23,12 +34,23 @@ const GroupsTreeView = ({ groups, isExpanded = false, intl: { locale }, addAttri locale={locale} addAttribute={addAttribute} removeAttribute={removeAttribute} + checkboxes={checkboxes} + highlight={highlight} + checked={checked} + setChecked={setChecked} + errors={errors} /> ); }; GroupsTreeView.propTypes = { - groups: ImmutablePropTypes.map, + groups: PropTypes.object.isRequired, // plain object with groupId -> group mappings + filter: PropTypes.func, + checkboxes: PropTypes.func, + highlight: PropTypes.func, + checked: PropTypes.object, + errors: PropTypes.object, + setChecked: PropTypes.func, isExpanded: PropTypes.bool, addAttribute: PropTypes.func, removeAttribute: PropTypes.func, diff --git a/src/components/Groups/helpers.js b/src/components/Groups/helpers.js index fc6ccd7..cc2a6f9 100644 --- a/src/components/Groups/helpers.js +++ b/src/components/Groups/helpers.js @@ -1,4 +1,5 @@ import { lruMemoize } from 'reselect'; +import { objectFilter } from '../../helpers/common.js'; /** * Retrieve course name in the given locale (with fallbacks). @@ -56,7 +57,7 @@ const augmentGroupObject = (groups, id, locale) => { * @param {Boolean} createChildren whether to create children list in each group * @returns {Array} sorted array of augmented group objects */ -const getGroups = lruMemoize((groups, locale, createChildren = false) => { +export const getGroups = lruMemoize((groups, locale, createChildren = false) => { // make a copy of groups so we can augment it const result = {}; Object.keys(groups).forEach(id => { @@ -92,10 +93,23 @@ const getGroups = lruMemoize((groups, locale, createChildren = false) => { * @param {String} locale * @returns {Array} list of top-level groups (augmented with localized fullName and isAdmin flag, and children list) */ -export const getTopLevelGroups = lruMemoize((groups, locale) => +export const getTopLevelGroups = lruMemoize((groups, locale, filter = null) => { + if (filter) { + // filter the groups first and make sure to keep the ancestors of matching groups + const filteredGroups = objectFilter(groups, filter); + Object.keys(filteredGroups).forEach(id => { + id = groups[id].parentGroupId; + while (id && !(id in filteredGroups)) { + filteredGroups[id] = groups[id]; + id = groups[id].parentGroupId; + } + }); + groups = filteredGroups; + } + // 1st level groups have immediate parent, but that parent is a root (has no parent itself) - getGroups(groups, locale, true).filter(group => group.parent && !group.parent.parent) -); + return getGroups(groups, locale, true).filter(group => group.parent && !group.parent.parent); +}); const getAttrValues = (group, key) => { const values = group?.attributes?.[key]; diff --git a/src/components/forms/AddAttributeForm/AddAttributeForm.js b/src/components/forms/AddAttributeForm/AddAttributeForm.js index 99f8406..60200b7 100644 --- a/src/components/forms/AddAttributeForm/AddAttributeForm.js +++ b/src/components/forms/AddAttributeForm/AddAttributeForm.js @@ -5,12 +5,17 @@ import { FormattedMessage } from 'react-intl'; import { CloseIcon, LoadingIcon, SaveIcon } from '../../icons'; import Button, { TheButtonGroup } from '../../widgets/TheButton'; -import { TextField, StandaloneRadioField } from '../fields'; +import { TextField, SelectField, StandaloneRadioField } from '../fields'; import Explanation from '../../widgets/Explanation'; import { lruMemoize } from 'reselect'; import { EMPTY_OBJ } from '../../../helpers/common'; import Callout from '../../widgets/Callout'; +const termOptions = [ + { name: , key: '1' }, + { name: , key: '2' }, +]; + const empty = values => { const mode = values.mode === 'other' ? 'key' : values.mode; return !values[mode]; @@ -75,6 +80,7 @@ const validate = lruMemoize(attributes => values => { export const INITIAL_VALUES = { mode: 'course', course: '', + 'for-term': '1', term: '', group: '', key: '', @@ -121,6 +127,32 @@ const AddAttributeForm = ({ initialValues, onSubmit, onClose, attributes = EMPTY + + + + + + + + + + + + } + /> + + + diff --git a/src/components/forms/PlantTermGroupsForm/PlantTermGroupsForm.js b/src/components/forms/PlantTermGroupsForm/PlantTermGroupsForm.js index ae1b2b5..0ca545e 100644 --- a/src/components/forms/PlantTermGroupsForm/PlantTermGroupsForm.js +++ b/src/components/forms/PlantTermGroupsForm/PlantTermGroupsForm.js @@ -2,237 +2,150 @@ import React from 'react'; import PropTypes from 'prop-types'; import { Form, Field, FormSpy } from 'react-final-form'; import { FormattedMessage } from 'react-intl'; +import { Row, Col } from 'react-bootstrap'; +import { lruMemoize } from 'reselect'; -import { CloseIcon, LoadingIcon, SaveIcon } from '../../icons'; +import Icon, { CloseIcon } from '../../icons'; import Button, { TheButtonGroup } from '../../widgets/TheButton'; -import { TextField, StandaloneRadioField } from '../fields'; -import Explanation from '../../widgets/Explanation'; -import { lruMemoize } from 'reselect'; -import { EMPTY_OBJ } from '../../../helpers/common'; -import Callout from '../../widgets/Callout'; +import { TextField, TextAreaField } from '../fields'; -const empty = values => { - const mode = values.mode === 'other' ? 'key' : values.mode; - return !values[mode]; -}; +const validate = values => { + const errors = { cs: {}, en: {} }; -const validate = lruMemoize(attributes => values => { - const errors = {}; - if (values.mode === 'course') { - if (values.course && !/^[A-Z0-9]{3,9}$/.test(values.course)) { - errors.course = ( - - ); - } - } else if (values.mode === 'term') { - if (values.term && !/^20[0-9]{2}-[12]$/.test(values.term)) { - errors.term = ( - - ); - } - } else if (values.mode === 'group') { - if (values.group && !/^[a-zA-Z0-9]{8,16}$/.test(values.group)) { - errors.group = ( - - ); - } - } else if (values.mode === 'other') { - if (values.key && !/^[-_a-zA-Z0-9]+$/.test(values.key)) { - errors.key = ( - - ); - } + if (!values?.cs?.name?.trim()) { + errors.cs.name = ( + + ); } - - if (Object.keys(errors).length === 0) { - const key = values.mode === 'other' ? values.key : values.mode; - const value = values.mode === 'other' ? values.value : values[key]; - if (key && attributes && attributes[key] && attributes[key].includes(value)) { - errors[values.mode === 'other' ? 'value' : key] = ( - - ); - } + if (!values.cs?.description?.trim()) { + errors.cs.description = ( + + ); + } + if (!values?.en?.name?.trim()) { + errors.en.name = ( + + ); + } + if (!values?.en?.description?.trim()) { + errors.en.description = ( + + ); } + return errors; -}); +}; -export const INITIAL_VALUES = {}; +export const initialValuesCreator = lruMemoize(term => { + const year = `${term.year}/${(term.year + 1).toString().slice(-2)}`; + const termLabels = { cs: { 1: 'ZS', 2: 'LS' }, en: { 1: 'Winter', 2: 'Summer' } }; + const termNames = { cs: { 1: 'Zimní semestr', 2: 'Letní semestr' }, en: { 1: 'Winter term', 2: 'Summer term' } }; + return { + cs: { + name: `${year} 1-${termLabels.cs[term.term]}`, + description: `${termNames.cs[term.term]} ${year}`, + }, + en: { + name: `${year} 1-${termLabels.en[term.term]}`, + description: `${termNames.en[term.term]} ${year}`, + }, + }; +}); -const PlantTermGroupsForm = ({ initialValues, onSubmit, onClose, attributes = EMPTY_OBJ }) => { +const PlantTermGroupsForm = ({ initialValues, onSubmit, onClose }) => { return (
    ( + validate={validate} + render={({ handleSubmit }) => ( - - - - {({ values: { mode } }) => ( - <> - - - - + + +

    + +

    - - - - - - - - - + + [cs]: + + } + /> - - - - - + + {' '} + [cs]: - )} - - -
    - - - - : - - - - - } - /> -
    - - - - : - - - - - } - /> -
    - - - - - : - - - - - } - /> -
    - - - - : - - - - - } - /> - - - : - - } - /> -
    + } + /> + + +

    + +

    - - {({ errors: { students } }) => students && {students}} - + + [en]: + + } + /> - {submitError && {submitError}} + + {' '} + [en]: + + } + /> + + +
    - - {({ values, valid }) => ( - )} {onClose && ( - diff --git a/src/components/forms/PlantTermGroupsForm/index.js b/src/components/forms/PlantTermGroupsForm/index.js index d9735cf..f841b65 100644 --- a/src/components/forms/PlantTermGroupsForm/index.js +++ b/src/components/forms/PlantTermGroupsForm/index.js @@ -1,3 +1,3 @@ -import PlantTermGroupsForm, { INITIAL_VALUES } from './PlantTermGroupsForm.js'; +import PlantTermGroupsForm, { initialValuesCreator } from './PlantTermGroupsForm.js'; export default PlantTermGroupsForm; -export { INITIAL_VALUES }; +export { initialValuesCreator }; diff --git a/src/components/forms/fields/TextAreaField.js b/src/components/forms/fields/TextAreaField.js new file mode 100644 index 0000000..05be523 --- /dev/null +++ b/src/components/forms/fields/TextAreaField.js @@ -0,0 +1,63 @@ +import React from 'react'; +import PropTypes from 'prop-types'; +import { Form, FormControl, FormLabel } from 'react-bootstrap'; +import classnames from 'classnames'; + +import * as styles from './commonStyles.less'; + +const TextAreaField = ({ + input, + meta: { active, dirty, error, warning }, + type = 'text', + label = null, + children, + ignoreDirty = false, + className = '', + ...props +}) => ( + <> + {Boolean(label) && ( + {label} + )} + + {error && {error} } + {!error && warning && {warning} } + {children} + +); + +TextAreaField.propTypes = { + type: PropTypes.string, + input: PropTypes.shape({ + name: PropTypes.string.isRequired, + }).isRequired, + label: PropTypes.oneOfType([PropTypes.string, PropTypes.element]), + children: PropTypes.any, + meta: PropTypes.shape({ + active: PropTypes.bool, + dirty: PropTypes.bool, + error: PropTypes.any, + warning: PropTypes.any, + }).isRequired, + ignoreDirty: PropTypes.bool, + className: PropTypes.string, +}; + +export default TextAreaField; diff --git a/src/components/forms/fields/index.js b/src/components/forms/fields/index.js index 5b75bd5..fa72d1c 100644 --- a/src/components/forms/fields/index.js +++ b/src/components/forms/fields/index.js @@ -4,3 +4,4 @@ export { default as NumericTextField } from './NumericTextField.js'; export { default as SelectField } from './SelectField.js'; export { default as StandaloneRadioField } from './StandaloneRadioField.js'; export { default as TextField } from './TextField.js'; +export { default as TextAreaField } from './TextAreaField.js'; diff --git a/src/components/icons/index.js b/src/components/icons/index.js index ed5856c..e64674e 100644 --- a/src/components/icons/index.js +++ b/src/components/icons/index.js @@ -88,6 +88,7 @@ export const PersonalDataIcon = props => ; export const PipelineIcon = props => ; export const PipelineStructureIcon = props => ; export const PlagiarismIcon = props => ; +export const PlantIcon = props => ; export const PointsIcon = props => ; export const PointsDecreasedIcon = props => ; export const PointsGraphIcon = props => ; diff --git a/src/locales/cs.json b/src/locales/cs.json index fa814b7..f7d694b 100644 --- a/src/locales/cs.json +++ b/src/locales/cs.json @@ -1,6 +1,8 @@ { "app.addAttributeForm.course": "Předmět", "app.addAttributeForm.course.explanation": "Přídání atributu předmětu umožní vazby a vytváření skupin pro SIS události tohoto předmětu v celém podstromu.", + "app.addAttributeForm.for-term": "Osazovat termíny", + "app.addAttributeForm.for-term.explanation": "Pomocný atribut, který se používá v kombinaci s atributem předmětu a označuje skupinu jako vhodnou pro osazování skupin pro konkrétní semestr (tj. ve kterém semestru je předmět vyučován).", "app.addAttributeForm.group": "SIS Rozvrhový lístek", "app.addAttributeForm.group.explanation": "Navazování rozvrhových lístků se obvykle řešeí na stránce Vytváření skupin. Vytvoření zde umožnuje obejít tradiční kontroly, takže jakýkoli SIS rozvrhový lístek může být spojen s touto skupinou. Prosíme, zacházejte s tímto s extrémní opatrností.", "app.addAttributeForm.key": "Vlastní klíč", @@ -88,10 +90,17 @@ "app.groupsStudent.title": "Připojení ke skupinám jako student", "app.groupsSupervisor.addAttributeModal.existingAttributes": "Existující atributy", "app.groupsSupervisor.addAttributeModal.title": "Přidat atribut ke skupině", + "app.groupsSupervisor.cancelPlantTermButton": "Zrušit osazování skupin", "app.groupsSupervisor.currentlyManagedGroups": "Skupiny", "app.groupsSupervisor.notSuperadmin": "Tato stránka je k dispozici pouze administrátorům ReCodExu.", "app.groupsSupervisor.plantTermButton": "Osadit skupiny pro", - "app.groupsSupervisor.plantTermGroupsModal.title": "Osadit skupiny pro semestr", + "app.groupsSupervisor.plantTermGroupsConfirmButton": "Osadit skupiny semestrů", + "app.groupsSupervisor.plantTermGroupsExplanation": "Organizační skupiny budou vytvořeny pro vybraný semestr s následujícími názvy a popisy:", + "app.groupsSupervisor.plantTermGroupsInfo": "Osazování skupin pro semestr {termLabel}", + "app.groupsSupervisor.plantTermGroupsModal.title": "Nastavit parametry skupin pro semestr", + "app.groupsSupervisor.plantTermGroupsSelectParents": "Prosíme, vybertete předměty, do kterých budou semestrální skupiny osazeny.", + "app.groupsSupervisor.plantingFailed": "Osazování selhalo. Některé skupiny nemohly být vytvořeny. Jejich rodičovské skupiny jsou označeny níže.", + "app.groupsSupervisor.plantingSucceeded": "Úspěšně {count, plural, one {byla osazena} =2 {byly osazeny} =3 {byly osazeny} =4 {byly osazeny} other {bylo osazeno}} {count} {count, plural, one {skupina} =2 {skupiny} =3 {skupiny} =4 {skupiny} other {skupin}}.", "app.groupsSupervisor.title": "Spravovat všechny skupiny a jejich vazby", "app.groupsTeacher.aboutStudentsInfo": "Studenti nejsou do skupiny přidáni automaticky, ale mohou se do skupiny připojit sami prostřednictvím rozšíření SIS-CodEx.", "app.groupsTeacher.bindGroupInfo": "Vybraný událost bude svázána s existující cílovou skupinou, kterou můžete vybrat níže.", @@ -143,6 +152,12 @@ "app.page.failedPage.explain": "Tento problém mohl být způsoben výpadkem sítě nebo interní chybou na straně serveru. Rovněž je možné, že požadované datové objekty pro zobrazení této stránky byly smazány.", "app.page.failedPage.sorry": "Prosíme, zkuste to později. Omlouváme se za způsobené problémy. Pokud problém přetrvává ověřte, že zobrazovaný objekt stále", "app.page.loadingDescription": "Prosíme počkejte než bude vše připraveno.", + "app.plantTermGroupsForm.czech": "Česky", + "app.plantTermGroupsForm.english": "Anglicky", + "app.plantTermGroupsForm.groupDescription": "Popis skupiny", + "app.plantTermGroupsForm.groupName": "Název skupiny", + "app.plantTermGroupsForm.submitButton": "Pokračovat výběrem skupin...", + "app.plantTermGroupsForm.validate.required": "Toto pole nesmí být prázdné.", "app.resourceRenderer.loadingFailed": "Načítání se nezdařilo.", "app.roles.description.empoweredSupervisor": "Privilegovaná verze role vedoucího, která navíc přidává možnost vytvářet vlastní pipelines a použít tyto pipelines pro složitější konfigurace úloh.", "app.roles.description.student": "Student je nejméně privilegovanou rolí, která má práva nahlížet pouze do skupin jejichž je členem a v těchto skupinách odevzdávat řešení úloh.", diff --git a/src/locales/en.json b/src/locales/en.json index d3030ff..4f5f977 100644 --- a/src/locales/en.json +++ b/src/locales/en.json @@ -1,6 +1,8 @@ { "app.addAttributeForm.course": "Course", "app.addAttributeForm.course.explanation": "Associating course identifier enables bindings and group creations for SIS events of that course in the whole sub-tree.", + "app.addAttributeForm.for-term": "Plant term", + "app.addAttributeForm.for-term.explanation": "Helper attribute that accompanies the course identifier and marks the group suitable for planting term groups for a specific semester (i.e., in which term the course is taught).", "app.addAttributeForm.group": "SIS Scheduling Event", "app.addAttributeForm.group.explanation": "Association between groups and SIS events is usually done by binding or creating new groups from SIS events. This circumvents traditional checks, so any SIS event ID can be associated with this group. Please, handle with extreme care.", "app.addAttributeForm.key": "Custom Key", @@ -88,10 +90,17 @@ "app.groupsStudent.title": "Joining Groups as Student", "app.groupsSupervisor.addAttributeModal.existingAttributes": "Existing attributes", "app.groupsSupervisor.addAttributeModal.title": "Add Attribute to Group", + "app.groupsSupervisor.cancelPlantTermButton": "Cancel Group Planting", "app.groupsSupervisor.currentlyManagedGroups": "Groups", "app.groupsSupervisor.notSuperadmin": "This page is available to ReCodEx administrators only.", "app.groupsSupervisor.plantTermButton": "Plant groups for", - "app.groupsSupervisor.plantTermGroupsModal.title": "Plant Groups for Term", + "app.groupsSupervisor.plantTermGroupsConfirmButton": "Plant Term Groups", + "app.groupsSupervisor.plantTermGroupsExplanation": "Organizational groups will be created for the selected term using the following names and descriptions:", + "app.groupsSupervisor.plantTermGroupsInfo": "Planting groups for term {termLabel}", + "app.groupsSupervisor.plantTermGroupsModal.title": "Define Group Parameters for Term", + "app.groupsSupervisor.plantTermGroupsSelectParents": "Please, select courses in which the term groups will be planted.", + "app.groupsSupervisor.plantingFailed": "Planting has failed. Some of the groups could not be created. Their parent groups are marked below.", + "app.groupsSupervisor.plantingSucceeded": "Total {count} {count, plural, one {group} other {groups}} have been successfully planted.", "app.groupsSupervisor.title": "Manage All Groups and Their Associations", "app.groupsTeacher.aboutStudentsInfo": "The students are not automatically added to the group, but they can join the group themselves via SIS-CodEx extension.", "app.groupsTeacher.bindGroupInfo": "The selected event will be bound to an existing target group which you can select below.", @@ -143,6 +152,12 @@ "app.page.failedPage.explain": "This problem might have been caused by network failure or by internal error at server side. It is also possible that some of the resources required for displaying this page have been deleted.", "app.page.failedPage.sorry": "We are sorry for the inconvenience, please try again later. If the problem prevails, verify that the requested resource still exists.", "app.page.loadingDescription": "Please wait while we are getting things ready.", + "app.plantTermGroupsForm.czech": "Czech", + "app.plantTermGroupsForm.english": "English", + "app.plantTermGroupsForm.groupDescription": "Group Description", + "app.plantTermGroupsForm.groupName": "Group Name", + "app.plantTermGroupsForm.submitButton": "Continue with Group Selection...", + "app.plantTermGroupsForm.validate.required": "This field must not be empty.", "app.resourceRenderer.loadingFailed": "Loading failed.", "app.roles.description.empoweredSupervisor": "A more privileged version of supervisor who is also capable of creating custom pipelines and configure exercises using these pipelines.", "app.roles.description.student": "Student is the least privileged user who can see only groups he/she is member of and solve assignments inside these groups.", diff --git a/src/pages/GroupsSuperadmin/GroupsSuperadmin.js b/src/pages/GroupsSuperadmin/GroupsSuperadmin.js index 60e82e5..84c2880 100644 --- a/src/pages/GroupsSuperadmin/GroupsSuperadmin.js +++ b/src/pages/GroupsSuperadmin/GroupsSuperadmin.js @@ -2,9 +2,10 @@ import React, { Component } from 'react'; import PropTypes from 'prop-types'; import ImmutablePropTypes from 'react-immutable-proptypes'; import { connect } from 'react-redux'; -import { Modal, Badge, Dropdown, ButtonGroup } from 'react-bootstrap'; -import { FormattedMessage } from 'react-intl'; +import { Modal, Badge, Dropdown, ButtonGroup, Row, Col } from 'react-bootstrap'; +import { FormattedMessage, injectIntl } from 'react-intl'; import { FORM_ERROR } from 'final-form'; +import { lruMemoize } from 'reselect'; import Page from '../../components/layout/Page'; import Box from '../../components/widgets/Box'; @@ -12,14 +13,20 @@ import GroupsTreeView from '../../components/Groups/GroupsTreeView'; import TermLabel from '../../components/Terms/TermLabel'; import AddAttributeForm, { INITIAL_VALUES as ADD_FORM_INITIAL_VALUES } from '../../components/forms/AddAttributeForm'; import PlantTermGroupsForm, { - INITIAL_VALUES as PLANT_FORM_INITIAL_VALUES, + initialValuesCreator as plantFormInitialValuesCreator, } from '../../components/forms/PlantTermGroupsForm'; -import Icon, { GroupIcon, ManagementIcon } from '../../components/icons'; +import Icon, { CloseIcon, GroupIcon, LoadingIcon, ManagementIcon, PlantIcon } from '../../components/icons'; import ResourceRenderer from '../../components/helpers/ResourceRenderer'; -import Button from '../../components/widgets/TheButton'; +import Button, { TheButtonGroup } from '../../components/widgets/TheButton'; import Callout from '../../components/widgets/Callout'; +import Markdown from '../../components/widgets/Markdown'; -import { fetchAllGroups, addGroupAttribute, removeGroupAttribute } from '../../redux/modules/groups.js'; +import { + fetchAllGroups, + addGroupAttribute, + removeGroupAttribute, + createTermGroup, +} from '../../redux/modules/groups.js'; import { fetchAllTerms } from '../../redux/modules/terms.js'; import { fetchUserIfNeeded } from '../../redux/modules/users.js'; import { addNotification } from '../../redux/modules/notifications.js'; @@ -29,9 +36,34 @@ import { termsSelector } from '../../redux/selectors/terms.js'; import { loggedInUserSelector } from '../../redux/selectors/users.js'; import { isSuperadminRole } from '../../components/helpers/usersRoles.js'; +import { getErrorMessage } from '../../locales/apiErrorMessages.js'; +import { getGroups as getGroupsHelper } from '../../components/Groups/helpers.js'; const DEFAULT_EXPIRATION = 7; // days +// keep only courses (term parents) and term groups +const plantingGroupFilter = group => group.attributes?.course?.length > 0 || group.attributes?.term?.length > 0; + +const groupCheckboxPredicate = (group, term) => + group.attributes?.course?.length > 0 && + !group.children.some(g => g.attributes?.term?.includes(`${term.year}-${term.term}`)); + +const plantingCheckboxSelector = lruMemoize(term => group => groupCheckboxPredicate(group, term)); + +const highlightClassGenerator = lruMemoize( + term => group => (group.attributes?.term?.includes(`${term.year}-${term.term}`) ? 'text-success fw-bold' : '') +); + +const getPlantingCandidates = (groups, term) => { + const candidates = {}; + getGroupsHelper(groups, 'en', true) + .filter(g => groupCheckboxPredicate(g, term) && g.attributes?.['for-term']?.includes(`${term.term}`)) + .forEach(g => { + candidates[g.id] = true; + }); + return candidates; +}; + class GroupsSuperadmin extends Component { state = { modalGroup: null, @@ -39,6 +71,12 @@ class GroupsSuperadmin extends Component { modalGroupError: null, plantTerm: null, modalPlant: false, + plantTexts: null, + plantGroups: null, + plantGroupsCount: 0, + plantGroupsPending: false, + plantGroupsErrors: null, + plantedGroups: 0, }; openModalGroup = modalGroup => @@ -51,9 +89,34 @@ class GroupsSuperadmin extends Component { modalGroupError: null, }); - openModalPlant = () => this.setState({ modalPlant: true, modalGroup: null }); + openModalPlant = (groups, term) => { + const plantGroups = getPlantingCandidates(groups, term); + this.setState({ + modalPlant: true, + modalGroup: null, + plantGroups, + plantTexts: null, + plantGroupsCount: Object.keys(plantGroups).length, + plantGroupsPending: false, + plantGroupsErrors: null, + plantedGroups: 0, + }); + }; + + closeModalPlant = () => this.setState({ modalPlant: false, plantGroups: null, plantGroupsCount: 0 }); - closeModalPlant = () => this.setState({ modalPlant: false }); + cancelGroupPlanting = () => { + if (!this.state.plantGroupsPending) { + this.setState({ + plantTexts: null, + plantGroups: null, + plantGroupsCount: 0, + plantGroupsPending: false, + plantGroupsErrors: null, + plantedGroups: 0, + }); + } + }; addAttributeFormSubmit = async values => { if (this.state.modalGroup) { @@ -71,6 +134,74 @@ class GroupsSuperadmin extends Component { } }; + plantTermGroupsFormSubmit = plantTexts => { + this.setState({ + plantTexts, + plantGroupsPending: false, + plantGroupsErrors: null, + plantedGroups: 0, + modalPlant: false, + }); + }; + + changePlantGroups = (id, newState) => { + if ( + this.state.plantGroups && + !this.state.plantGroupsPending && + Boolean(this.state.plantGroups[id]) !== Boolean(newState) + ) { + const plantGroups = { ...this.state.plantGroups, [id]: Boolean(newState) }; + const plantGroupsCount = Object.values(plantGroups).filter(v => v).length; + this.setState({ plantGroups, plantGroupsCount }); + } + }; + + plantGroups = async term => { + const { + createTermGroup, + reloadGroups, + intl: { formatMessage }, + } = this.props; + if (this.state.plantGroupsCount === 0) { + return; + } + + this.setState({ plantGroupsPending: true, plantGroupsErrors: null, plantedGroups: 0 }); + + // start creating the groups + const termId = `${term.year}-${term.term}`; + const promises = {}; + Object.keys(this.state.plantGroups) + .filter(id => this.state.plantGroups[id]) + .forEach(id => { + promises[id] = createTermGroup(id, termId, this.state.plantTexts); + }); + + // wait for all promises and handle errors + const plantGroupsErrors = {}; + let plantedGroups = 0; + for (const id of Object.keys(promises)) { + try { + await promises[id]; + ++plantedGroups; + } catch (err) { + plantGroupsErrors[id] = getErrorMessage(formatMessage)(err); + } + } + + await reloadGroups(); + + if (Object.keys(plantGroupsErrors).length > 0) { + // still planting, but also show errors + this.setState({ plantGroupsErrors, plantGroups: {} }); + } else { + // terminate planing + this.setState({ plantTexts: null, plantGroups: null }); + } + + this.setState({ plantGroupsPending: false, plantGroupsCount: 0, plantedGroups }); + }; + componentDidMount() { this.props.loadAsync(this.props.loggedInUserId); } @@ -109,18 +240,123 @@ class GroupsSuperadmin extends Component { }> <> + {this.state.plantTexts && ( + +
    + }} + /> +
    + + + + + +
    +
    + + +
    + {this.state.plantTexts.cs.name} + +
    + + +
    +
    + + +
    + {this.state.plantTexts.en.name} + +
    + +
    + +

    + +

    + + + + + +
    + )} + + {this.state.plantedGroups > 0 && ( + + this.setState({ plantedGroups: 0 })} + className="float-end clickable pt-1" + /> + + + )} + + {this.state.plantGroupsErrors && ( + + + + )} + +
    - {terms && terms.length > 0 && ( + {terms && terms.length > 0 && !this.state.plantTexts && ( - + + + )}
    @@ -203,29 +464,32 @@ class GroupsSuperadmin extends Component { - - - - - - + {terms && terms.length > 0 && ( + + + + {' '} + + + - - - - + + + + + )} )} @@ -251,6 +515,9 @@ GroupsSuperadmin.propTypes = { loadAsync: PropTypes.func.isRequired, addAttribute: PropTypes.func.isRequired, removeAttribute: PropTypes.func.isRequired, + createTermGroup: PropTypes.func.isRequired, + reloadGroups: PropTypes.func.isRequired, + intl: PropTypes.object, }; export default connect( @@ -268,5 +535,7 @@ export default connect( dispatch(removeGroupAttribute(groupId, key, value)).catch(err => dispatch(addNotification(err?.message || err.toString(), false)) ), + createTermGroup: (parentId, term, texts) => dispatch(createTermGroup(parentId, term, texts)), + reloadGroups: () => dispatch(fetchAllGroups()), }) -)(GroupsSuperadmin); +)(injectIntl(GroupsSuperadmin)); diff --git a/src/redux/modules/groups.js b/src/redux/modules/groups.js index f92994e..cafe87b 100644 --- a/src/redux/modules/groups.js +++ b/src/redux/modules/groups.js @@ -9,6 +9,7 @@ export const additionalActionTypes = { ...createActionsWithPostfixes('BIND', 'siscodex/groups'), ...createActionsWithPostfixes('UNBIND', 'siscodex/groups'), ...createActionsWithPostfixes('CREATE', 'siscodex/groups'), + ...createActionsWithPostfixes('CREATE_TERM', 'siscodex/groups'), ...createActionsWithPostfixes('JOIN', 'siscodex/groups'), ...createActionsWithPostfixes('ADD_ATTRIBUTE', 'siscodex/groups'), ...createActionsWithPostfixes('REMOVE_ATTRIBUTE', 'siscodex/groups'), @@ -55,6 +56,15 @@ export const createGroup = (parentId, event) => meta: { parentId, eventId: event.id, eventSisId: event.sisId }, }); +export const createTermGroup = (parentId, term, texts) => + createApiAction({ + type: additionalActionTypes.CREATE_TERM, + endpoint: `/groups/${parentId}/create-term/${term}`, + method: 'POST', + meta: { parentId, term }, + body: { texts }, + }); + export const joinGroup = groupId => createApiAction({ type: additionalActionTypes.JOIN,