From ae683576836c5e4b7419dc8da16d867f16e504cb Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Martin=20Kruli=C5=A1?= Date: Mon, 5 Jan 2026 00:21:28 +0100 Subject: [PATCH 1/9] Finishing plant groups texts form. --- .../PlantTermGroupsForm.js | 321 +++++++----------- .../forms/PlantTermGroupsForm/index.js | 4 +- src/locales/cs.json | 10 +- src/locales/en.json | 10 +- .../GroupsSuperadmin/GroupsSuperadmin.js | 90 +++-- 5 files changed, 201 insertions(+), 234 deletions(-) diff --git a/src/components/forms/PlantTermGroupsForm/PlantTermGroupsForm.js b/src/components/forms/PlantTermGroupsForm/PlantTermGroupsForm.js index ae1b2b5..4762355 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 } 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/locales/cs.json b/src/locales/cs.json index fa814b7..615a1ee 100644 --- a/src/locales/cs.json +++ b/src/locales/cs.json @@ -88,10 +88,12 @@ "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.plantTermGroupsInfo": "Osazování skupin pro semestr {termLabel}", + "app.groupsSupervisor.plantTermGroupsModal.title": "Nastavit parametry skupin pro semestr", "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 +145,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..9dd2e7d 100644 --- a/src/locales/en.json +++ b/src/locales/en.json @@ -88,10 +88,12 @@ "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.plantTermGroupsInfo": "Planting groups for term {termLabel}", + "app.groupsSupervisor.plantTermGroupsModal.title": "Define Group Parameters for Term", "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 +145,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..3ce4941 100644 --- a/src/pages/GroupsSuperadmin/GroupsSuperadmin.js +++ b/src/pages/GroupsSuperadmin/GroupsSuperadmin.js @@ -12,9 +12,9 @@ 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, ManagementIcon } from '../../components/icons'; import ResourceRenderer from '../../components/helpers/ResourceRenderer'; import Button from '../../components/widgets/TheButton'; import Callout from '../../components/widgets/Callout'; @@ -39,6 +39,7 @@ class GroupsSuperadmin extends Component { modalGroupError: null, plantTerm: null, modalPlant: false, + plantTexts: null, }; openModalGroup = modalGroup => @@ -55,6 +56,8 @@ class GroupsSuperadmin extends Component { closeModalPlant = () => this.setState({ modalPlant: false }); + cancelGroupPlanting = () => this.setState({ plantTexts: null }); + addAttributeFormSubmit = async values => { if (this.state.modalGroup) { const key = values.mode === 'other' ? values.key.trim() : values.mode; @@ -71,6 +74,11 @@ class GroupsSuperadmin extends Component { } }; + plantTermGroupsFormSubmit = plantTexts => { + this.setState({ plantTexts }); + this.closeModalPlant(); + }; + componentDidMount() { this.props.loadAsync(this.props.loggedInUserId); } @@ -109,6 +117,23 @@ class GroupsSuperadmin extends Component { }> <> + {this.state.plantTexts && ( + +
+ }} + /> +
+ + +
+ )} +
- {terms && terms.length > 0 && ( + {terms && terms.length > 0 && !this.state.plantTexts && ( - + )}
@@ -203,29 +238,32 @@ class GroupsSuperadmin extends Component { - - - - - - + {terms && terms.length > 0 && ( + + + + {' '} + + + - - - - + + + + + )} )} From cb9bf76567290ec7aa788f856d1b856ce75c83b7 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Martin=20Kruli=C5=A1?= Date: Wed, 7 Jan 2026 00:13:30 +0100 Subject: [PATCH 2/9] Showing proper callout information when group planting is in progress. --- src/locales/cs.json | 2 + src/locales/en.json | 2 + .../GroupsSuperadmin/GroupsSuperadmin.js | 38 ++++++++++++++++++- 3 files changed, 41 insertions(+), 1 deletion(-) diff --git a/src/locales/cs.json b/src/locales/cs.json index 615a1ee..10e2dca 100644 --- a/src/locales/cs.json +++ b/src/locales/cs.json @@ -92,8 +92,10 @@ "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.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.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.", diff --git a/src/locales/en.json b/src/locales/en.json index 9dd2e7d..f8939d0 100644 --- a/src/locales/en.json +++ b/src/locales/en.json @@ -92,8 +92,10 @@ "app.groupsSupervisor.currentlyManagedGroups": "Groups", "app.groupsSupervisor.notSuperadmin": "This page is available to ReCodEx administrators only.", "app.groupsSupervisor.plantTermButton": "Plant groups for", + "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.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.", diff --git a/src/pages/GroupsSuperadmin/GroupsSuperadmin.js b/src/pages/GroupsSuperadmin/GroupsSuperadmin.js index 3ce4941..377ba4f 100644 --- a/src/pages/GroupsSuperadmin/GroupsSuperadmin.js +++ b/src/pages/GroupsSuperadmin/GroupsSuperadmin.js @@ -2,7 +2,7 @@ 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 { Modal, Badge, Dropdown, ButtonGroup, Row, Col } from 'react-bootstrap'; import { FormattedMessage } from 'react-intl'; import { FORM_ERROR } from 'final-form'; @@ -18,6 +18,7 @@ import Icon, { CloseIcon, GroupIcon, ManagementIcon } from '../../components/ico import ResourceRenderer from '../../components/helpers/ResourceRenderer'; import Button 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 { fetchAllTerms } from '../../redux/modules/terms.js'; @@ -127,6 +128,41 @@ class GroupsSuperadmin extends Component { /> + + + + +
+
+ + +
+ {this.state.plantTexts.cs.name} + +
+ + +
+
+ + +
+ {this.state.plantTexts.en.name} + +
+ +
+ +

+ +

+ - - )} - - {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={ +const GroupsTreeNode = React.memo( + ({ + group, + checkboxes = null, + checked = 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; + + const admins = Array.isArray(adminsRaw) ? adminsRaw : Object.values(adminsRaw || {}); + const leafNode = children.length === 0; + const [isOpen, setOpen] = useState(isExpanded); + const hasCheckbox = checkboxes ? checkboxes(group) : false; + const isChecked = checked ? Boolean(checked[id]) : false; + const clickHandler = hasCheckbox ? () => setChecked(id, !isChecked) : () => setOpen(!isOpen); + + return ( +
  • + + {hasCheckbox ? ( + + ) : checkboxes ? ( + + ) : ( + + )} + + {getLocalizedName(name, id, locale)} + + {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}: `} + {value} + + {removeAttribute && !pending && ( + removeAttribute(id, key, value)} + question={ + + }> + + + )} + {pending && } + + )) + )} + + )} + + + {!leafNode && ( + + + )} - - - {!leafNode && ( - - - - )} -
  • - ); -}); + + ); + } +); GroupsTreeNode.propTypes = { group: PropTypes.shape({ @@ -206,11 +238,13 @@ GroupsTreeNode.propTypes = { children: PropTypes.arrayOf(PropTypes.object), pending: PropTypes.bool, }), + checkboxes: PropTypes.func, + checked: 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..3697398 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,18 @@ 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, + checked = null, + setChecked = null, + isExpanded = false, + addAttribute = null, + removeAttribute = null, + intl: { locale }, +}) => { + const topLevelGroups = getTopLevelGroups(groups, locale, filter); return topLevelGroups.length === 0 ? ( ) : ( @@ -23,12 +32,19 @@ const GroupsTreeView = ({ groups, isExpanded = false, intl: { locale }, addAttri locale={locale} addAttribute={addAttribute} removeAttribute={removeAttribute} + checkboxes={checkboxes} + checked={checked} + setChecked={setChecked} /> ); }; GroupsTreeView.propTypes = { - groups: ImmutablePropTypes.map, + groups: PropTypes.object.isRequired, // plain object with groupId -> group mappings + filter: PropTypes.func, + checkboxes: PropTypes.func, + checked: 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..250b7ac 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). @@ -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/pages/GroupsSuperadmin/GroupsSuperadmin.js b/src/pages/GroupsSuperadmin/GroupsSuperadmin.js index 377ba4f..8663a90 100644 --- a/src/pages/GroupsSuperadmin/GroupsSuperadmin.js +++ b/src/pages/GroupsSuperadmin/GroupsSuperadmin.js @@ -5,6 +5,7 @@ import { connect } from 'react-redux'; import { Modal, Badge, Dropdown, ButtonGroup, Row, Col } from 'react-bootstrap'; import { FormattedMessage } 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'; @@ -33,6 +34,15 @@ import { isSuperadminRole } from '../../components/helpers/usersRoles.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 plantingCheckboxSelector = lruMemoize( + term => group => + group.attributes?.course?.length > 0 && + !group.children.some(g => g.attributes?.term?.includes(`${term.year}-${term.term}`)) +); + class GroupsSuperadmin extends Component { state = { modalGroup: null, @@ -41,6 +51,7 @@ class GroupsSuperadmin extends Component { plantTerm: null, modalPlant: false, plantTexts: null, + plantGroups: null, }; openModalGroup = modalGroup => @@ -53,11 +64,11 @@ class GroupsSuperadmin extends Component { modalGroupError: null, }); - openModalPlant = () => this.setState({ modalPlant: true, modalGroup: null }); + openModalPlant = () => this.setState({ modalPlant: true, modalGroup: null, plantGroups: null }); closeModalPlant = () => this.setState({ modalPlant: false }); - cancelGroupPlanting = () => this.setState({ plantTexts: null }); + cancelGroupPlanting = () => this.setState({ plantTexts: null, plantGroups: null }); addAttributeFormSubmit = async values => { if (this.state.modalGroup) { @@ -76,10 +87,17 @@ class GroupsSuperadmin extends Component { }; plantTermGroupsFormSubmit = plantTexts => { - this.setState({ plantTexts }); + this.setState({ plantTexts, plantGroups: {} }); this.closeModalPlant(); }; + changePlantGroups = (id, newState) => { + if (this.state.plantGroups && Boolean(this.state.plantGroups[id]) !== Boolean(newState)) { + const plantGroups = { ...this.state.plantGroups, [id]: Boolean(newState) }; + this.setState({ plantGroups }); + } + }; + componentDidMount() { this.props.loadAsync(this.props.loggedInUserId); } @@ -172,9 +190,16 @@ class GroupsSuperadmin extends Component { +
    From b11b07da89b9a913023a9789ace5ac76764bda73 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Martin=20Kruli=C5=A1?= Date: Tue, 13 Jan 2026 00:15:19 +0100 Subject: [PATCH 4/9] Implementing term group creation. --- src/locales/cs.json | 3 + src/locales/en.json | 3 + .../GroupsSuperadmin/GroupsSuperadmin.js | 183 ++++++++++++++++-- src/redux/modules/groups.js | 10 + 4 files changed, 180 insertions(+), 19 deletions(-) diff --git a/src/locales/cs.json b/src/locales/cs.json index 10e2dca..c98284d 100644 --- a/src/locales/cs.json +++ b/src/locales/cs.json @@ -92,10 +92,13 @@ "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.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.", diff --git a/src/locales/en.json b/src/locales/en.json index f8939d0..f15eea6 100644 --- a/src/locales/en.json +++ b/src/locales/en.json @@ -92,10 +92,13 @@ "app.groupsSupervisor.currentlyManagedGroups": "Groups", "app.groupsSupervisor.notSuperadmin": "This page is available to ReCodEx administrators only.", "app.groupsSupervisor.plantTermButton": "Plant groups for", + "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.", diff --git a/src/pages/GroupsSuperadmin/GroupsSuperadmin.js b/src/pages/GroupsSuperadmin/GroupsSuperadmin.js index 8663a90..b22eda5 100644 --- a/src/pages/GroupsSuperadmin/GroupsSuperadmin.js +++ b/src/pages/GroupsSuperadmin/GroupsSuperadmin.js @@ -15,13 +15,18 @@ import AddAttributeForm, { INITIAL_VALUES as ADD_FORM_INITIAL_VALUES } from '../ import PlantTermGroupsForm, { initialValuesCreator as plantFormInitialValuesCreator, } from '../../components/forms/PlantTermGroupsForm'; -import Icon, { CloseIcon, GroupIcon, ManagementIcon } from '../../components/icons'; +import Icon, { CloseIcon, GroupIcon, LoadingIcon, ManagementIcon } 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'; @@ -31,6 +36,7 @@ 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'; const DEFAULT_EXPIRATION = 7; // days @@ -52,6 +58,10 @@ class GroupsSuperadmin extends Component { modalPlant: false, plantTexts: null, plantGroups: null, + plantGroupsCount: 0, + plantGroupsPending: false, + plantGroupsErrors: null, + plantedGroups: 5, }; openModalGroup = modalGroup => @@ -64,11 +74,32 @@ class GroupsSuperadmin extends Component { modalGroupError: null, }); - openModalPlant = () => this.setState({ modalPlant: true, modalGroup: null, plantGroups: null }); + openModalPlant = () => + this.setState({ + modalPlant: true, + modalGroup: null, + plantGroups: null, + plantTexts: null, + plantGroupsCount: 0, + plantGroupsPending: false, + plantGroupsErrors: null, + plantedGroups: 0, + }); closeModalPlant = () => this.setState({ modalPlant: false }); - cancelGroupPlanting = () => this.setState({ plantTexts: null, plantGroups: null }); + 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) { @@ -87,15 +118,69 @@ class GroupsSuperadmin extends Component { }; plantTermGroupsFormSubmit = plantTexts => { - this.setState({ plantTexts, plantGroups: {} }); + this.setState({ + plantTexts, + plantGroups: {}, + plantGroupsCount: 0, + plantGroupsPending: false, + plantGroupsErrors: null, + plantedGroups: 0, + }); this.closeModalPlant(); }; changePlantGroups = (id, newState) => { - if (this.state.plantGroups && Boolean(this.state.plantGroups[id]) !== Boolean(newState)) { + if ( + this.state.plantGroups && + !this.state.plantGroupsPending && + Boolean(this.state.plantGroups[id]) !== Boolean(newState) + ) { const plantGroups = { ...this.state.plantGroups, [id]: Boolean(newState) }; - this.setState({ plantGroups }); + const plantGroupsCount = Object.values(plantGroups).filter(v => v).length; + this.setState({ plantGroups, plantGroupsCount }); + } + }; + + plantGroups = async term => { + const { createTermGroup, reloadGroups } = 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(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() { @@ -181,15 +266,56 @@ class GroupsSuperadmin extends Component { />

    - + + + + + + )} + + {this.state.plantedGroups > 0 && ( + + this.setState({ plantedGroups: 0 })} + className="float-end clickable pt-1" + /> + + + )} + + {this.state.plantGroupsErrors && ( + + )} - - - + + + + )}
    @@ -350,6 +491,8 @@ GroupsSuperadmin.propTypes = { loadAsync: PropTypes.func.isRequired, addAttribute: PropTypes.func.isRequired, removeAttribute: PropTypes.func.isRequired, + createTermGroup: PropTypes.func.isRequired, + reloadGroups: PropTypes.func.isRequired, }; export default connect( @@ -367,5 +510,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); 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, From b3e7ae8060369121ac63bdf2e712676fcb719286 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Martin=20Kruli=C5=A1?= Date: Tue, 20 Jan 2026 18:51:55 +0100 Subject: [PATCH 5/9] Better error reporting and group highlighting during term planting. --- .../Groups/GroupsTreeView/GroupsTreeList.js | 17 +++++- .../Groups/GroupsTreeView/GroupsTreeNode.js | 53 ++++++++++++------- .../Groups/GroupsTreeView/GroupsTreeView.js | 6 +++ .../GroupsSuperadmin/GroupsSuperadmin.js | 22 ++++++-- 4 files changed, 73 insertions(+), 25 deletions(-) diff --git a/src/components/Groups/GroupsTreeView/GroupsTreeList.js b/src/components/Groups/GroupsTreeView/GroupsTreeList.js index c04535b..50203b7 100644 --- a/src/components/Groups/GroupsTreeView/GroupsTreeList.js +++ b/src/components/Groups/GroupsTreeView/GroupsTreeList.js @@ -4,7 +4,18 @@ import PropTypes from 'prop-types'; import GroupsTreeNode from './GroupsTreeNode.js'; const GroupsTreeList = React.memo( - ({ groups, checkboxes, checked, setChecked, isExpanded = false, addAttribute, removeAttribute, locale }) => ( + ({ + groups, + checkboxes, + highlight, + checked, + errors, + setChecked, + isExpanded = false, + addAttribute, + removeAttribute, + locale, + }) => (
      {groups.map(group => ( ))}
    @@ -26,7 +39,9 @@ const GroupsTreeList = React.memo( 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, diff --git a/src/components/Groups/GroupsTreeView/GroupsTreeNode.js b/src/components/Groups/GroupsTreeView/GroupsTreeNode.js index d895367..d42296f 100644 --- a/src/components/Groups/GroupsTreeView/GroupsTreeNode.js +++ b/src/components/Groups/GroupsTreeView/GroupsTreeNode.js @@ -8,7 +8,7 @@ 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 Icon, { AddIcon, CloseIcon, GroupIcon, LectureIcon, LoadingIcon, TermIcon, WarningIcon } from '../../icons'; import { EMPTY_OBJ } from '../../../helpers/common.js'; const DEFAULT_ICON = ['far', 'square']; @@ -48,7 +48,9 @@ const GroupsTreeNode = React.memo( ({ group, checkboxes = null, + highlight = null, checked = null, + errors = null, setChecked = null, isExpanded = false, addAttribute, @@ -73,29 +75,38 @@ const GroupsTreeNode = React.memo( const hasCheckbox = checkboxes ? checkboxes(group) : false; const isChecked = checked ? Boolean(checked[id]) : false; const clickHandler = hasCheckbox ? () => 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 ? ( - - ) : ( - - )} + + {hasCheckbox ? ( + + ) : checkboxes ? ( + + ) : ( + + )} + + {error && ( + + )} - {getLocalizedName(name, id, locale)} + {getLocalizedName(name, id, locale)} + {admins && admins.length > 0 && ( @@ -216,8 +227,10 @@ const GroupsTreeNode = React.memo( removeAttribute={removeAttribute} locale={locale} checkboxes={checkboxes} + highlight={highlight} checked={checked} setChecked={setChecked} + errors={errors} /> )} @@ -239,7 +252,9 @@ GroupsTreeNode.propTypes = { pending: PropTypes.bool, }), checkboxes: PropTypes.func, + highlight: PropTypes.func, checked: PropTypes.object, + errors: PropTypes.object, setChecked: PropTypes.func, isExpanded: PropTypes.bool, addAttribute: PropTypes.func, diff --git a/src/components/Groups/GroupsTreeView/GroupsTreeView.js b/src/components/Groups/GroupsTreeView/GroupsTreeView.js index 3697398..8245ed3 100644 --- a/src/components/Groups/GroupsTreeView/GroupsTreeView.js +++ b/src/components/Groups/GroupsTreeView/GroupsTreeView.js @@ -14,11 +14,13 @@ 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); @@ -33,8 +35,10 @@ const GroupsTreeView = ({ addAttribute={addAttribute} removeAttribute={removeAttribute} checkboxes={checkboxes} + highlight={highlight} checked={checked} setChecked={setChecked} + errors={errors} /> ); }; @@ -43,7 +47,9 @@ GroupsTreeView.propTypes = { 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, diff --git a/src/pages/GroupsSuperadmin/GroupsSuperadmin.js b/src/pages/GroupsSuperadmin/GroupsSuperadmin.js index b22eda5..276758a 100644 --- a/src/pages/GroupsSuperadmin/GroupsSuperadmin.js +++ b/src/pages/GroupsSuperadmin/GroupsSuperadmin.js @@ -3,7 +3,7 @@ import PropTypes from 'prop-types'; import ImmutablePropTypes from 'react-immutable-proptypes'; import { connect } from 'react-redux'; import { Modal, Badge, Dropdown, ButtonGroup, Row, Col } from 'react-bootstrap'; -import { FormattedMessage } from 'react-intl'; +import { FormattedMessage, injectIntl } from 'react-intl'; import { FORM_ERROR } from 'final-form'; import { lruMemoize } from 'reselect'; @@ -49,6 +49,10 @@ const plantingCheckboxSelector = lruMemoize( !group.children.some(g => g.attributes?.term?.includes(`${term.year}-${term.term}`)) ); +const highlightClassGenerator = lruMemoize( + term => group => (group.attributes?.term?.includes(`${term.year}-${term.term}`) ? 'text-success fw-bold' : '') +); + class GroupsSuperadmin extends Component { state = { modalGroup: null, @@ -61,7 +65,7 @@ class GroupsSuperadmin extends Component { plantGroupsCount: 0, plantGroupsPending: false, plantGroupsErrors: null, - plantedGroups: 5, + plantedGroups: 0, }; openModalGroup = modalGroup => @@ -142,7 +146,11 @@ class GroupsSuperadmin extends Component { }; plantGroups = async term => { - const { createTermGroup, reloadGroups } = this.props; + const { + createTermGroup, + reloadGroups, + intl: { formatMessage }, + } = this.props; if (this.state.plantGroupsCount === 0) { return; } @@ -166,7 +174,7 @@ class GroupsSuperadmin extends Component { await promises[id]; ++plantedGroups; } catch (err) { - plantGroupsErrors[id] = getErrorMessage(err); + plantGroupsErrors[id] = getErrorMessage(formatMessage)(err); } } @@ -320,6 +328,9 @@ class GroupsSuperadmin extends Component { checkboxes={ this.state.plantTexts ? plantingCheckboxSelector(this.state.plantTerm || terms[0]) : null } + highlight={ + this.state.plantTexts ? highlightClassGenerator(this.state.plantTerm || terms[0]) : null + } checked={this.state.plantGroups} addAttribute={!this.state.plantTexts ? this.openModalGroup : null} removeAttribute={!this.state.plantTexts ? removeAttribute : null} @@ -493,6 +504,7 @@ GroupsSuperadmin.propTypes = { removeAttribute: PropTypes.func.isRequired, createTermGroup: PropTypes.func.isRequired, reloadGroups: PropTypes.func.isRequired, + intl: PropTypes.object, }; export default connect( @@ -513,4 +525,4 @@ export default connect( createTermGroup: (parentId, term, texts) => dispatch(createTermGroup(parentId, term, texts)), reloadGroups: () => dispatch(fetchAllGroups()), }) -)(GroupsSuperadmin); +)(injectIntl(GroupsSuperadmin)); From b9af4b24889cdcdff73b4a8c674aaff0f9c1342b Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Martin=20Kruli=C5=A1?= Date: Tue, 20 Jan 2026 19:00:04 +0100 Subject: [PATCH 6/9] Changing description fields to text areas in plant term groups form. --- .../PlantTermGroupsForm.js | 6 +- src/components/forms/fields/TextAreaField.js | 63 +++++++++++++++++++ src/components/forms/fields/index.js | 1 + src/locales/cs.json | 2 +- 4 files changed, 68 insertions(+), 4 deletions(-) create mode 100644 src/components/forms/fields/TextAreaField.js diff --git a/src/components/forms/PlantTermGroupsForm/PlantTermGroupsForm.js b/src/components/forms/PlantTermGroupsForm/PlantTermGroupsForm.js index 4762355..0ca545e 100644 --- a/src/components/forms/PlantTermGroupsForm/PlantTermGroupsForm.js +++ b/src/components/forms/PlantTermGroupsForm/PlantTermGroupsForm.js @@ -7,7 +7,7 @@ import { lruMemoize } from 'reselect'; import Icon, { CloseIcon } from '../../icons'; import Button, { TheButtonGroup } from '../../widgets/TheButton'; -import { TextField } from '../fields'; +import { TextField, TextAreaField } from '../fields'; const validate = values => { const errors = { cs: {}, en: {} }; @@ -79,7 +79,7 @@ const PlantTermGroupsForm = ({ initialValues, onSubmit, onClose }) => { /> { /> ( + <> + {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/locales/cs.json b/src/locales/cs.json index c98284d..f021cd0 100644 --- a/src/locales/cs.json +++ b/src/locales/cs.json @@ -255,4 +255,4 @@ "generic.operationFailed": "Operace selhala", "generic.reset": "Resetovat", "generic.save": "Uložit" -} +} \ No newline at end of file From 940f99f185d4d744214d4fc0b9c2d97eb3b16df9 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Martin=20Kruli=C5=A1?= Date: Wed, 21 Jan 2026 00:00:08 +0100 Subject: [PATCH 7/9] Pre-selecting parent groups for planting based on their `for-term` attributes. --- src/components/Groups/helpers.js | 2 +- .../GroupsSuperadmin/GroupsSuperadmin.js | 39 ++++++++++++------- 2 files changed, 27 insertions(+), 14 deletions(-) diff --git a/src/components/Groups/helpers.js b/src/components/Groups/helpers.js index 250b7ac..cc2a6f9 100644 --- a/src/components/Groups/helpers.js +++ b/src/components/Groups/helpers.js @@ -57,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 => { diff --git a/src/pages/GroupsSuperadmin/GroupsSuperadmin.js b/src/pages/GroupsSuperadmin/GroupsSuperadmin.js index 276758a..ba433b0 100644 --- a/src/pages/GroupsSuperadmin/GroupsSuperadmin.js +++ b/src/pages/GroupsSuperadmin/GroupsSuperadmin.js @@ -37,22 +37,33 @@ 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 plantingCheckboxSelector = lruMemoize( - term => group => - group.attributes?.course?.length > 0 && - !group.children.some(g => g.attributes?.term?.includes(`${term.year}-${term.term}`)) -); +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, @@ -78,19 +89,21 @@ class GroupsSuperadmin extends Component { modalGroupError: null, }); - openModalPlant = () => + openModalPlant = (groups, term) => { + const plantGroups = getPlantingCandidates(groups, term); this.setState({ modalPlant: true, modalGroup: null, - plantGroups: null, + plantGroups, plantTexts: null, - plantGroupsCount: 0, + plantGroupsCount: Object.keys(plantGroups).length, plantGroupsPending: false, plantGroupsErrors: null, plantedGroups: 0, }); + }; - closeModalPlant = () => this.setState({ modalPlant: false }); + closeModalPlant = () => this.setState({ modalPlant: false, plantGroups: null, plantGroupsCount: 0 }); cancelGroupPlanting = () => { if (!this.state.plantGroupsPending) { @@ -124,13 +137,11 @@ class GroupsSuperadmin extends Component { plantTermGroupsFormSubmit = plantTexts => { this.setState({ plantTexts, - plantGroups: {}, - plantGroupsCount: 0, plantGroupsPending: false, plantGroupsErrors: null, plantedGroups: 0, + modalPlant: false, }); - this.closeModalPlant(); }; changePlantGroups = (id, newState) => { @@ -342,7 +353,9 @@ class GroupsSuperadmin extends Component {
    {terms && terms.length > 0 && !this.state.plantTexts && ( -