diff --git a/src/components/Groups/CoursesGroupsList/CoursesGroupsList.css b/src/components/Groups/CoursesGroupsList/CoursesGroupsList.css
new file mode 100644
index 0000000..49c7dfb
--- /dev/null
+++ b/src/components/Groups/CoursesGroupsList/CoursesGroupsList.css
@@ -0,0 +1,25 @@
+table.coursesGroupsList td {
+ padding: 0.25rem 0.5rem;
+ vertical-align: middle;
+}
+
+table.coursesGroupsList tbody tr:first-of-type td {
+ border-bottom: 1px solid #ddd;
+ background-color: #eee;
+}
+
+table.coursesGroupsList tbody:first-of-type tr:first-of-type td {
+ border-top: 1px solid #ddd;
+}
+
+table.coursesGroupsList tbody tr:first-of-type:hover td {
+ background-color: #e4e4e4;
+}
+
+table.coursesGroupsList tbody tr:nth-of-type(n+2):hover td {
+ background-color: #f7f7f7;
+}
+
+table.coursesGroupsList tbody tr:last-of-type td {
+ border-bottom: 1px solid #ddd;
+}
diff --git a/src/components/Groups/CoursesGroupsList/CoursesGroupsList.js b/src/components/Groups/CoursesGroupsList/CoursesGroupsList.js
new file mode 100644
index 0000000..4670999
--- /dev/null
+++ b/src/components/Groups/CoursesGroupsList/CoursesGroupsList.js
@@ -0,0 +1,315 @@
+import React, { useState } from 'react';
+import PropTypes from 'prop-types';
+import ImmutablePropTypes from 'react-immutable-proptypes';
+import { FormattedMessage, injectIntl } from 'react-intl';
+import { Badge } from 'react-bootstrap';
+
+import ResourceRenderer from '../../helpers/ResourceRenderer';
+import DayOfWeek from '../../widgets/DayOfWeek';
+import {
+ AddIcon,
+ AddUserIcon,
+ BindIcon,
+ GroupIcon,
+ LabsIcon,
+ LectureIcon,
+ LinkIcon,
+ LoadingIcon,
+ UnbindIcon,
+ VisibleIcon,
+} from '../../icons';
+import GroupMembershipIcon from '../GroupMembershipIcon';
+
+import './CoursesGroupsList.css';
+import Button, { TheButtonGroup } from '../../widgets/TheButton';
+import {
+ recodexGroupAssignmentsLink,
+ recodexGroupEditLink,
+ recodexGroupStudentsLink,
+} from '../../helpers/recodexLinks.js';
+import { minutesToTimeStr } from '../../helpers/stringFormatters.js';
+import { getSisGroups, getSortedSisEvents, getBindingCandidates, getParentCandidates } from '../helpers.js';
+
+/*
+ * Component displaying list of sisEvents (with scheduling events) and their groups.
+ */
+const CoursesGroupsList = ({
+ sisEvents,
+ groups,
+ allowHiding = false,
+ joinGroup = null,
+ bind = null,
+ unbind = null,
+ create = null,
+ intl: { locale },
+}) => {
+ const [showAllState, setShowAll] = useState(false);
+ const showAll = allowHiding ? showAllState : true;
+
+ return (
+
+ {groups => {
+ const sisGroups = getSisGroups(groups || {}, locale);
+
+ return (
+
+ {sisEvents => {
+ if (!sisEvents || sisEvents.length === 0) {
+ return (
+
+
+
+ );
+ }
+
+ const sortedSisEvents = getSortedSisEvents(sisEvents, locale);
+ const filteredSisEvents = sortedSisEvents.filter(sisEvent => sisGroups[sisEvent.sisId]?.length > 0);
+
+ return (
+ <>
+
+ {(showAll ? sortedSisEvents : filteredSisEvents).map(sisEvent => (
+
+
+ |
+ {sisEvent.type === 'lecture' && (
+
+ }
+ tooltipId={sisEvent.id}
+ tooltipPlacement="bottom"
+ />
+ )}
+ {sisEvent.type === 'labs' && (
+ }
+ tooltipId={sisEvent.id}
+ tooltipPlacement="bottom"
+ />
+ )}
+ |
+
+ {sisEvent.dayOfWeek === null && sisEvent.time === null ? (
+
+ (
+
+ )
+ |
+ ) : (
+ <>
+
+
+ |
+ {minutesToTimeStr(sisEvent.time)} |
+
+ {sisEvent.fortnight && (
+ <>
+ (
+ {sisEvent.firstWeek % 2 === 1 ? (
+
+ ) : (
+
+ )}
+ )
+ >
+ )}
+ |
+ >
+ )}
+
+ {sisEvent.room} |
+ {sisEvent.fullName} |
+
+ {sisEvent.sisId}
+ |
+
+
+ {create && (
+
+ )}
+ {bind && (
+
+ )}
+
+ |
+
+
+ {sisGroups[sisEvent.sisId]?.map(group => (
+
+ |
+
+ {group.pending && }
+
+
+ }
+ tooltipId={group.id}
+ tooltipPlacement="bottom"
+ />
+ |
+
+ {group.fullName}
+ |
+
+ {group.pending ? (
+
+ ) : (
+
+ {joinGroup && group.membership === null && (
+
+ )}
+
+ {recodexGroupAssignmentsLink(group.id) && group.membership === 'student' && (
+
+
+
+ )}
+
+ {recodexGroupStudentsLink(group.id) &&
+ group.membership &&
+ group.membership !== 'student' && (
+
+
+
+ )}
+
+ {(group.isAdmin || group.membership === 'supervisor') && (
+ <>
+ {recodexGroupEditLink(group.id) && (
+
+
+
+ )}
+ {unbind && (
+
+ )}
+ >
+ )}
+
+ )}
+ |
+
+ ))}
+
+ ))}
+
+
+ {allowHiding && filteredSisEvents.length < sortedSisEvents.length && (
+
+
+
+
+
+ )}
+ >
+ );
+ }}
+
+ );
+ }}
+
+ );
+};
+
+CoursesGroupsList.propTypes = {
+ sisEvents: ImmutablePropTypes.list,
+ groups: ImmutablePropTypes.map,
+ allowHiding: PropTypes.bool,
+ joinGroup: PropTypes.func,
+ bind: PropTypes.func,
+ unbind: PropTypes.func,
+ create: PropTypes.func,
+ intl: PropTypes.shape({ locale: PropTypes.string.isRequired }).isRequired,
+};
+
+export default injectIntl(CoursesGroupsList);
diff --git a/src/components/Groups/CoursesGroupsList/index.js b/src/components/Groups/CoursesGroupsList/index.js
new file mode 100644
index 0000000..992a195
--- /dev/null
+++ b/src/components/Groups/CoursesGroupsList/index.js
@@ -0,0 +1,2 @@
+import CoursesGroupsList from './CoursesGroupsList.js';
+export default CoursesGroupsList;
diff --git a/src/components/Groups/GroupMembershipIcon/GroupMembershipIcon.js b/src/components/Groups/GroupMembershipIcon/GroupMembershipIcon.js
new file mode 100644
index 0000000..595380c
--- /dev/null
+++ b/src/components/Groups/GroupMembershipIcon/GroupMembershipIcon.js
@@ -0,0 +1,67 @@
+import React from 'react';
+import PropTypes from 'prop-types';
+import { FormattedMessage } from 'react-intl';
+
+import { AdminRoleIcon, ObserverIcon, SupervisorIcon, StudentsIcon } from '../../icons';
+
+const GroupMembershipIcon = ({ id, membership, ...props }) => (
+ <>
+ {membership === 'admin' && (
+
+ }
+ {...props}
+ />
+ )}
+ {membership === 'supervisor' && (
+
+ }
+ {...props}
+ />
+ )}
+ {membership === 'observer' && (
+
+ }
+ {...props}
+ />
+ )}
+ {membership === 'student' && (
+
+ }
+ {...props}
+ />
+ )}
+ >
+);
+
+GroupMembershipIcon.propTypes = {
+ id: PropTypes.string.isRequired,
+ membership: PropTypes.string,
+};
+
+export default GroupMembershipIcon;
diff --git a/src/components/Groups/GroupMembershipIcon/index.js b/src/components/Groups/GroupMembershipIcon/index.js
new file mode 100644
index 0000000..9db0589
--- /dev/null
+++ b/src/components/Groups/GroupMembershipIcon/index.js
@@ -0,0 +1,2 @@
+import GroupMembershipIcon from './GroupMembershipIcon.js';
+export default GroupMembershipIcon;
diff --git a/src/components/Groups/GroupsTreeView/GroupsTreeList.js b/src/components/Groups/GroupsTreeView/GroupsTreeList.js
new file mode 100644
index 0000000..9b36459
--- /dev/null
+++ b/src/components/Groups/GroupsTreeView/GroupsTreeList.js
@@ -0,0 +1,29 @@
+import React from 'react';
+import PropTypes from 'prop-types';
+
+import GroupsTreeNode from './GroupsTreeNode.js';
+
+const GroupsTreeList = React.memo(({ groups, isExpanded = false, addAttribute, removeAttribute, locale }) => (
+
+ {groups.map(group => (
+
+ ))}
+
+));
+
+GroupsTreeList.propTypes = {
+ groups: PropTypes.array.isRequired,
+ isExpanded: PropTypes.bool,
+ addAttribute: PropTypes.func,
+ removeAttribute: PropTypes.func,
+ locale: PropTypes.string.isRequired,
+};
+
+export default GroupsTreeList;
diff --git a/src/components/Groups/GroupsTreeView/GroupsTreeNode.js b/src/components/Groups/GroupsTreeView/GroupsTreeNode.js
new file mode 100644
index 0000000..6b2feae
--- /dev/null
+++ b/src/components/Groups/GroupsTreeView/GroupsTreeNode.js
@@ -0,0 +1,216 @@
+import React, { useState } from 'react';
+import PropTypes from 'prop-types';
+import { FormattedMessage } from 'react-intl';
+import { Badge, OverlayTrigger, Popover } from 'react-bootstrap';
+import Collapse from 'react-collapse';
+
+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 { EMPTY_OBJ } from '../../../helpers/common.js';
+
+const DEFAULT_ICON = ['far', 'square'];
+
+const clickEventDissipator = ev => ev.stopPropagation();
+
+const adminsList = admins =>
+ Object.values(admins)
+ .map(({ firstName, lastName }) => `${firstName} ${lastName}`)
+ .join(', ');
+
+const getLocalizedName = (name, id, locale) => {
+ if (typeof name === 'string') {
+ return name;
+ }
+
+ if (typeof name === 'object' && Object.keys(name).length > 0) {
+ return name[locale] || name.en || name[Object.keys(name)[0]] || `<${id}>`;
+ }
+
+ return `<${id}>`;
+};
+
+const KNOWN_ATTR_KEYS = {
+ course: 'primary',
+ term: 'info',
+ group: 'warning',
+};
+
+const ATTR_ICONS = {
+ course: ,
+ 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)}
+ )}
+ )
+
+ )}
+
+ {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 && (
+
+
+
+ )}
+
+ );
+});
+
+GroupsTreeNode.propTypes = {
+ group: PropTypes.shape({
+ id: PropTypes.string.isRequired,
+ admins: PropTypes.arrayOf(PropTypes.object),
+ name: PropTypes.object,
+ organizational: PropTypes.bool,
+ exam: PropTypes.bool,
+ attributes: PropTypes.object,
+ membership: PropTypes.string,
+ children: PropTypes.arrayOf(PropTypes.object),
+ pending: PropTypes.bool,
+ }),
+ isExpanded: PropTypes.bool,
+ addAttribute: PropTypes.func,
+ removeAttribute: PropTypes.func,
+ locale: PropTypes.string.isRequired,
+ links: PropTypes.object,
+};
+
+export default withLinks(GroupsTreeNode);
diff --git a/src/components/Groups/GroupsTreeView/GroupsTreeView.css b/src/components/Groups/GroupsTreeView/GroupsTreeView.css
new file mode 100644
index 0000000..b42ba20
--- /dev/null
+++ b/src/components/Groups/GroupsTreeView/GroupsTreeView.css
@@ -0,0 +1,17 @@
+ul.groupTree.nav.flex-column > * {
+ border-bottom: 0 !important;
+}
+
+ul.groupTree.nav.flex-column ul.groupTree.nav.flex-column > * {
+ padding-left: 1.5rem;
+}
+
+ul.groupTree.nav.flex-column > li > span {
+ display: block;
+ padding-top: 0.5rem;
+ padding-bottom: 0.5rem;
+}
+
+ul.groupTree.nav.flex-column > li > span:hover {
+ background-color: rgba(0,0,0,.075);
+}
diff --git a/src/components/Groups/GroupsTreeView/GroupsTreeView.js b/src/components/Groups/GroupsTreeView/GroupsTreeView.js
new file mode 100644
index 0000000..83c8800
--- /dev/null
+++ b/src/components/Groups/GroupsTreeView/GroupsTreeView.js
@@ -0,0 +1,38 @@
+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';
+
+import { getTopLevelGroups } from '../helpers.js';
+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);
+ return topLevelGroups.length === 0 ? (
+
+ ) : (
+
+ );
+};
+
+GroupsTreeView.propTypes = {
+ groups: ImmutablePropTypes.map,
+ isExpanded: PropTypes.bool,
+ addAttribute: PropTypes.func,
+ removeAttribute: PropTypes.func,
+ intl: PropTypes.shape({ locale: PropTypes.string.isRequired }).isRequired,
+};
+
+export default injectIntl(GroupsTreeView);
diff --git a/src/components/Groups/GroupsTreeView/index.js b/src/components/Groups/GroupsTreeView/index.js
new file mode 100644
index 0000000..9a44981
--- /dev/null
+++ b/src/components/Groups/GroupsTreeView/index.js
@@ -0,0 +1,2 @@
+import GroupsTreeView from './GroupsTreeView.js';
+export default GroupsTreeView;
diff --git a/src/components/Groups/helpers.js b/src/components/Groups/helpers.js
new file mode 100644
index 0000000..fc6ccd7
--- /dev/null
+++ b/src/components/Groups/helpers.js
@@ -0,0 +1,180 @@
+import { lruMemoize } from 'reselect';
+
+/**
+ * Retrieve course name in the given locale (with fallbacks).
+ * @param {Object} sisEvent (with course sub-object)
+ * @param {String} locale
+ * @returns {String}
+ */
+const getCourseName = ({ course }, locale) => {
+ const key = `caption_${locale}`;
+ return course?.[key] || course?.caption_en || course?.caption_cs || '???';
+};
+
+/**
+ * Return augmented sisEvents (with localized fullName) sorted by their fullName.
+ * @param {Array} sisEvents
+ * @param {String} locale
+ * @returns {Array}
+ */
+export const getSortedSisEvents = lruMemoize((sisEvents, locale) =>
+ sisEvents
+ .map(event => ({ ...event, fullName: getCourseName(event, locale) }))
+ .sort((a, b) => a.fullName.localeCompare(b.fullName, locale))
+);
+
+const augmentGroupObject = (groups, id, locale) => {
+ const group = groups[id];
+ if (group && !('fullName' in group)) {
+ if (group.parentGroupId) {
+ // handle ancestors recursively
+ augmentGroupObject(groups, group.parentGroupId, locale);
+
+ const parent = groups[group.parentGroupId];
+ group.parent = parent;
+
+ // use the parent to update our values
+ group.fullName = parent.fullName || '';
+ if (group.fullName) {
+ group.fullName += ' ❭ ';
+ }
+ group.fullName += group.name?.[locale] || group.name?.en || group.name?.cs || '???';
+ group.isAdmin = parent.isAdmin || group.membership === 'admin';
+ } else {
+ // root group has special treatment
+ groups[id].fullName = '';
+ groups[id].isAdmin = false;
+ }
+ }
+};
+
+/**
+ * Preprocessing function that returns a sorted array of all groups
+ * (augmented with localized fullName and isAdmin flag).
+ * @param {Object} groups
+ * @param {String} locale (used for sorting)
+ * @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) => {
+ // make a copy of groups so we can augment it
+ const result = {};
+ Object.keys(groups).forEach(id => {
+ result[id] = { ...groups[id], parent: null };
+ if (createChildren) {
+ result[id].children = [];
+ }
+ });
+
+ Object.keys(result).forEach(id => {
+ if (!result[id].fullName) {
+ augmentGroupObject(result, id, locale);
+ }
+ });
+
+ const sortedResult = Object.values(result).sort((a, b) => a.fullName.localeCompare(b.fullName, locale));
+
+ if (createChildren) {
+ // the children lists are assembled only after all groups are processed and sorted (!)
+ sortedResult.forEach(group => {
+ if (group.parent) {
+ group.parent.children.push(group);
+ }
+ });
+ }
+
+ return sortedResult;
+});
+
+/**
+ * Get top-level groups (those directly under the root group) with their children lists built-in.
+ * @param {Object} groups
+ * @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) =>
+ // 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)
+);
+
+const getAttrValues = (group, key) => {
+ const values = group?.attributes?.[key];
+ return Array.isArray(values) ? values : []; // ensure we always return an array
+};
+
+/**
+ * Construct a structure of sisId => [groups] where each group is augmented with localized fullName.
+ * Each list of groups is sorted by their fullName.
+ * @param {Object} groups
+ * @param {String} locale
+ * @returns {Object} key is sisId (of a scheduling event), value is array of (augmented) groups
+ */
+export const getSisGroups = lruMemoize((groups, locale) => {
+ const sisGroups = {};
+ getGroups(groups, locale).forEach(group => {
+ getAttrValues(group, 'group').forEach(sisId => {
+ if (!sisGroups[sisId]) {
+ sisGroups[sisId] = [];
+ }
+ sisGroups[sisId].push(group); // groups are already sorted, push preserves the order
+ });
+ });
+ return sisGroups;
+});
+
+/**
+ * Check if a group is suitable for a course in a specific term.
+ * @param {Object} sisEvent
+ * @param {Object} group
+ * @param {Object} groups map of all groups (to access ancestors), keys are group ids
+ * @returns {Boolean} true if the group is suitable for binding to the course
+ */
+const isSuitableForCourse = (sisEvent, group, groups) => {
+ const courseCode = sisEvent?.course?.code;
+ if (!courseCode || !sisEvent?.year || !sisEvent?.term) {
+ return false;
+ }
+ const termKey = `${sisEvent.year}-${sisEvent.term}`;
+
+ if (getAttrValues(group, 'group').includes(sisEvent.sisId)) {
+ return false; // already bound to this event
+ }
+ let courseCovered = false;
+ let termCovered = false;
+
+ while (group && (!courseCovered || !termCovered)) {
+ courseCovered = courseCovered || getAttrValues(group, 'course').includes(courseCode);
+ termCovered = termCovered || getAttrValues(group, 'term').includes(termKey);
+ group = groups[group.parentGroupId];
+ }
+
+ return courseCovered && termCovered;
+};
+
+/**
+ * Get a sorted list of groups suitable for being a parent of a new group
+ * for the given course in the given term.
+ * @param {Object} sisEvent
+ * @param {Object} groups
+ * @param {String} locale
+ * @returns {Array} list of suitable group objects (augmented with localized fullName and isAdmin flag)
+ */
+export const getParentCandidates = lruMemoize((sisEvent, groups, locale) =>
+ getGroups(groups, locale).filter(group => isSuitableForCourse(sisEvent, group, groups))
+);
+
+/**
+ * Get a sorted list of groups suitable for binding to the given course in the given term.
+ * @param {Object} sisEvent
+ * @param {Object} groups
+ * @param {String} locale
+ * @returns {Array} list of suitable group objects (augmented with localized fullName and isAdmin flag)
+ */
+export const getBindingCandidates = lruMemoize((sisEvent, groups, locale) =>
+ getGroups(groups, locale).filter(
+ group =>
+ !group.organizational &&
+ (group.isAdmin || group.membership === 'supervisor') &&
+ isSuitableForCourse(sisEvent, group, groups)
+ )
+);
diff --git a/src/components/forms/AddAttributeForm/AddAttributeForm.js b/src/components/forms/AddAttributeForm/AddAttributeForm.js
new file mode 100644
index 0000000..0de5014
--- /dev/null
+++ b/src/components/forms/AddAttributeForm/AddAttributeForm.js
@@ -0,0 +1,253 @@
+import React from 'react';
+import PropTypes from 'prop-types';
+import { Form, Field, FormSpy } from 'react-final-form';
+import { FormattedMessage } from 'react-intl';
+
+import { CloseIcon, LoadingIcon, SaveIcon } 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';
+
+const empty = values => {
+ const mode = values.mode === 'other' ? 'key' : values.mode;
+ return !values[mode];
+};
+
+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 (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] = (
+
+ );
+ }
+ }
+ return errors;
+});
+
+const AddAttributeForm = ({ initialValues, onSubmit, onClose, attributes = EMPTY_OBJ }) => {
+ return (
+
+ )}
+ />
+ );
+};
+
+AddAttributeForm.propTypes = {
+ initialValues: PropTypes.object.isRequired,
+ onSubmit: PropTypes.func.isRequired,
+ onClose: PropTypes.func.isRequired,
+ attributes: PropTypes.object,
+};
+
+export default AddAttributeForm;
diff --git a/src/components/forms/AddAttributeForm/index.js b/src/components/forms/AddAttributeForm/index.js
new file mode 100644
index 0000000..7359b24
--- /dev/null
+++ b/src/components/forms/AddAttributeForm/index.js
@@ -0,0 +1,2 @@
+import AddAttributeForm from './AddAttributeForm.js';
+export default AddAttributeForm;
diff --git a/src/components/forms/EditTermForm/EditTermForm.js b/src/components/forms/EditTermForm/EditTermForm.js
index a1dc8f8..fe43520 100644
--- a/src/components/forms/EditTermForm/EditTermForm.js
+++ b/src/components/forms/EditTermForm/EditTermForm.js
@@ -3,6 +3,7 @@ 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 moment from 'moment';
import Icon, { CloseIcon, LinkIcon, LoadingIcon, RefreshIcon, SaveIcon, VisibleIcon } from '../../icons';
import Button, { TheButtonGroup } from '../../widgets/TheButton';
@@ -17,6 +18,8 @@ const termOptions = [
{ name: , key: 2 },
];
+const getUnixTs = m => (m && moment.isMoment(m) ? m.unix() : null);
+
const validate = lruMemoize((terms, id) => values => {
const termIndex = arrayToObject(terms, ({ year, term }) => `${year}-${term}`);
const selectedTerm = id && terms.find(t => t.id === id);
@@ -38,8 +41,8 @@ const validate = lruMemoize((terms, id) => values => {
}
// beginning and end
- const beginningTs = values.beginning?.unix();
- const endTs = values.end?.unix();
+ const beginningTs = getUnixTs(values.beginning);
+ const endTs = getUnixTs(values.end);
if (!beginningTs && endTs) {
errors.beginning = (
values => {
}
// student dates
- const studentsFromTs = values.studentsFrom?.unix();
- const studentsUntilTs = values.studentsUntil?.unix();
+ const studentsFromTs = getUnixTs(values.studentsFrom);
+ const studentsUntilTs = getUnixTs(values.studentsUntil);
if (!studentsFromTs) {
errors.studentsFrom = (
@@ -89,8 +92,8 @@ const validate = lruMemoize((terms, id) => values => {
}
// teacher dates
- const teachersFromTs = values.teachersFrom?.unix();
- const teachersUntilTs = values.teachersUntil?.unix();
+ const teachersFromTs = getUnixTs(values.teachersFrom);
+ const teachersUntilTs = getUnixTs(values.teachersUntil);
if (!teachersFromTs) {
errors.teachersFrom = (
@@ -111,7 +114,7 @@ const validate = lruMemoize((terms, id) => values => {
);
}
- const archiveAfterTs = values.archiveAfter?.unix();
+ const archiveAfterTs = getUnixTs(values.archiveAfter);
if (
archiveAfterTs &&
studentsUntilTs &&
diff --git a/src/components/forms/fields/StandaloneRadioField.js b/src/components/forms/fields/StandaloneRadioField.js
new file mode 100644
index 0000000..c638819
--- /dev/null
+++ b/src/components/forms/fields/StandaloneRadioField.js
@@ -0,0 +1,21 @@
+import React from 'react';
+import PropTypes from 'prop-types';
+import { Field } from 'react-final-form';
+
+const StandaloneRadioField = ({ name, value }) => {
+ return (
+
+
+
+ );
+};
+
+StandaloneRadioField.propTypes = {
+ name: PropTypes.string.isRequired,
+ value: PropTypes.string.isRequired,
+};
+
+export default StandaloneRadioField;
diff --git a/src/components/forms/fields/index.js b/src/components/forms/fields/index.js
index 99bff53..5b75bd5 100644
--- a/src/components/forms/fields/index.js
+++ b/src/components/forms/fields/index.js
@@ -2,4 +2,5 @@ export { default as CheckboxField } from './CheckboxField.js';
export { default as DatetimeField } from './DatetimeField.js';
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';
diff --git a/src/components/helpers/recodexLinks.js b/src/components/helpers/recodexLinks.js
new file mode 100644
index 0000000..7781790
--- /dev/null
+++ b/src/components/helpers/recodexLinks.js
@@ -0,0 +1,12 @@
+import { getConfigVar } from '../../helpers/config.js';
+
+const RECODEX_URI = getConfigVar('RECODEX_URI') || null;
+
+const getLink = suffix => {
+ if (!RECODEX_URI) return null;
+ return `${RECODEX_URI.replace(/\/+$/, '')}/${suffix.replace(/^\/+/, '')}`;
+};
+
+export const recodexGroupAssignmentsLink = groupId => getLink(`app/group/${groupId}/assignments`);
+export const recodexGroupStudentsLink = groupId => getLink(`app/group/${groupId}/students`);
+export const recodexGroupEditLink = groupId => getLink(`app/group/${groupId}/edit`);
diff --git a/src/components/helpers/stringFormatters.js b/src/components/helpers/stringFormatters.js
index 232735f..5a929ae 100644
--- a/src/components/helpers/stringFormatters.js
+++ b/src/components/helpers/stringFormatters.js
@@ -29,3 +29,11 @@ export function prettyPrintPercent(percent) {
}
return (Math.round(percent * 1000) / 10).toString() + '%';
}
+
+/**
+ * Convert time in minutes (from midnight) to string H:MM
+ * @param {Number} minutes
+ * @returns {String}
+ */
+export const minutesToTimeStr = minutes =>
+ minutes ? `${Math.floor(minutes / 60)}:${(minutes % 60).toString().padStart(2, '0')}` : '';
diff --git a/src/components/helpers/usersRoles.js b/src/components/helpers/usersRoles.js
index ae65fd2..20c55e1 100644
--- a/src/components/helpers/usersRoles.js
+++ b/src/components/helpers/usersRoles.js
@@ -46,13 +46,13 @@ export const roleDescriptions = {
[STUDENT_ROLE]: (
),
[SUPERVISOR_STUDENT_ROLE]: (
),
[SUPERVISOR_ROLE]: (
@@ -64,7 +64,7 @@ export const roleDescriptions = {
[EMPOWERED_SUPERVISOR_ROLE]: (
),
[SUPERADMIN_ROLE]: (
diff --git a/src/components/icons/index.js b/src/components/icons/index.js
index dfa9c17..ed5856c 100644
--- a/src/components/icons/index.js
+++ b/src/components/icons/index.js
@@ -12,9 +12,9 @@ export const AbortIcon = props => ;
export const AcceptIcon = props => ;
export const AcceptedIcon = props => ;
export const AddIcon = props => ;
+export const AddUserIcon = props => ;
export const AdminIcon = props => ;
export const AdminRoleIcon = props => ;
-export const AdressIcon = props => ;
export const ArchiveIcon = ({ archived = false, ...props }) => (
);
@@ -35,6 +35,7 @@ export const CodeFileIcon = props => ;
export const CodeCompareIcon = props => ;
export const CopyIcon = props => ;
+export const CourseIcon = props => ;
export const ChatIcon = props => ;
export const DashboardIcon = props => ;
export const DeadlineIcon = props => ;
@@ -63,17 +64,22 @@ export const ForkIcon = props => ;
export const GroupIcon = ({ organizational = false, archived = false, exam = false, ...props }) => (
);
+export const GroupFocusIcon = props => ;
export const GroupExamsIcon = props => ;
export const HomeIcon = props => ;
export const InfoIcon = props => ;
export const InputIcon = props => ;
export const InstanceIcon = props => ;
+export const JoinGroupIcon = props => ;
+export const LabsIcon = props => ;
+export const LectureIcon = props => ;
export const InvertIcon = props => ;
export const LimitsIcon = props => ;
export const LinkIcon = props => ;
export const LoadingIcon = props => ;
export const LocalIcon = props => ;
export const MailIcon = props => ;
+export const ManagementIcon = props => ;
export const NoteIcon = props => ;
export const ObserverIcon = props => ;
export const OutputIcon = props => ;
@@ -89,7 +95,6 @@ export const PointsInterpolationIcon = props => (
);
export const RedoIcon = props => ;
-export const ReferenceSolutionIcon = props => ;
export const RefreshIcon = props => ;
export const RemoveIcon = props => ;
export const RemoveUserIcon = props => ;
@@ -158,12 +163,9 @@ export const UploadIcon = props => ;
export const UserIcon = props => ;
export const UserLockIcon = props => ;
export const UserProfileIcon = props => ;
-export const VisibleIcon = ({ visible = true, ...props }) =>
- visible ? (
-
- ) : (
-
- );
+export const VisibleIcon = ({ visible = true, ...props }) => (
+
+);
export const WarningIcon = props => ;
export const WorkingIcon = props => ;
export const ZipIcon = props => ;
diff --git a/src/components/layout/HeaderNotification/HeaderNotification.js b/src/components/layout/HeaderNotification/HeaderNotification.js
index bcb6139..96966f2 100644
--- a/src/components/layout/HeaderNotification/HeaderNotification.js
+++ b/src/components/layout/HeaderNotification/HeaderNotification.js
@@ -85,8 +85,8 @@ class HeaderNotification extends Component {
this.copy} placement="bottom">
diff --git a/src/components/layout/Navigation/linkCreators.js b/src/components/layout/Navigation/linkCreators.js
deleted file mode 100644
index 6e6e642..0000000
--- a/src/components/layout/Navigation/linkCreators.js
+++ /dev/null
@@ -1,101 +0,0 @@
-import React from 'react';
-import { FormattedMessage } from 'react-intl';
-import {
- AssignmentsIcon,
- EditIcon,
- GroupIcon,
- GroupExamsIcon,
- ExerciseIcon,
- LimitsIcon,
- ReferenceSolutionIcon,
- StudentsIcon,
- TestsIcon,
-} from '../../icons';
-
-export const createGroupLinks = (
- {
- GROUP_INFO_URI_FACTORY,
- GROUP_ASSIGNMENTS_URI_FACTORY,
- GROUP_STUDENTS_URI_FACTORY,
- GROUP_EDIT_URI_FACTORY,
- GROUP_EXAMS_URI_FACTORY,
- },
- groupId,
- canViewDetail = true,
- canEdit = false,
- canSeeExams = false
-) => [
- {
- caption: ,
- link: GROUP_INFO_URI_FACTORY(groupId),
- icon: ,
- },
- canViewDetail && {
- caption: ,
- link: GROUP_ASSIGNMENTS_URI_FACTORY(groupId),
- icon: ,
- },
- canViewDetail && {
- caption: ,
- link: GROUP_STUDENTS_URI_FACTORY(groupId),
- icon: ,
- },
- canEdit && {
- caption: ,
- link: GROUP_EDIT_URI_FACTORY(groupId),
- icon: ,
- },
- canSeeExams && {
- caption: ,
- link: GROUP_EXAMS_URI_FACTORY(groupId),
- icon: ,
- match: (link, pathname) => pathname.startsWith(link),
- },
-];
-
-export const createExerciseLinks = (
- {
- EXERCISE_URI_FACTORY,
- EXERCISE_EDIT_URI_FACTORY,
- EXERCISE_EDIT_CONFIG_URI_FACTORY,
- EXERCISE_EDIT_LIMITS_URI_FACTORY,
- EXERCISE_REFERENCE_SOLUTIONS_URI_FACTORY,
- EXERCISE_ASSIGNMENTS_URI_FACTORY,
- },
- exerciseId,
- canEdit = false,
- canViewTests = false,
- canViewLimits = false,
- canViewAssignments = false
-) => [
- {
- caption: ,
- link: EXERCISE_URI_FACTORY(exerciseId),
- icon: ,
- },
- canEdit && {
- caption: ,
- link: EXERCISE_EDIT_URI_FACTORY(exerciseId),
- icon: ,
- },
- canViewTests && {
- caption: ,
- link: EXERCISE_EDIT_CONFIG_URI_FACTORY(exerciseId),
- icon: ,
- },
- canViewLimits && {
- caption: ,
- link: EXERCISE_EDIT_LIMITS_URI_FACTORY(exerciseId),
- icon: ,
- },
- {
- caption: ,
- link: EXERCISE_REFERENCE_SOLUTIONS_URI_FACTORY(exerciseId),
- icon: ,
- },
- canViewAssignments && {
- caption: ,
- link: EXERCISE_ASSIGNMENTS_URI_FACTORY(exerciseId),
- icon: ,
- },
-];
diff --git a/src/components/layout/Sidebar/Sidebar.js b/src/components/layout/Sidebar/Sidebar.js
index cc3e8d8..9a8282f 100644
--- a/src/components/layout/Sidebar/Sidebar.js
+++ b/src/components/layout/Sidebar/Sidebar.js
@@ -10,7 +10,7 @@ import UserPanelContainer from '../../../containers/UserPanelContainer';
import MenuItem from '../../widgets/Sidebar/MenuItem';
import { LoadingIcon } from '../../icons';
import { getJsData } from '../../../redux/helpers/resourceManager';
-import { isSupervisorRole, isSuperadminRole } from '../../helpers/usersRoles.js';
+import { isStudentRole, isSupervisorRole, isSuperadminRole } from '../../helpers/usersRoles.js';
import withLinks from '../../../helpers/withLinks.js';
import { getConfigVar } from '../../../helpers/config.js';
import { getReturnUrl } from '../../../helpers/localStorage.js';
@@ -21,7 +21,12 @@ const URL_PREFIX = getConfigVar('URL_PATH_PREFIX');
const getUserData = lruMemoize(user => getJsData(user));
-const Sidebar = ({ pendingFetchOperations, loggedInUser, currentUrl, links: { HOME_URI, USER_URI, TERMS_URI } }) => {
+const Sidebar = ({
+ pendingFetchOperations,
+ loggedInUser,
+ currentUrl,
+ links: { HOME_URI, USER_URI, TERMS_URI, GROUPS_STUDENT_URI, GROUPS_TEACHER_URI, GROUPS_SUPERADMIN_URI },
+}) => {
const user = getUserData(loggedInUser);
return (
@@ -69,8 +74,33 @@ const Sidebar = ({ pendingFetchOperations, loggedInUser, currentUrl, links: { HO
link={USER_URI}
/>
- {isSupervisorRole(user.role) && <>>}
- {isSuperadminRole(user.role) && <>>}
+ {isStudentRole(user.role) && (
+ }
+ icon="people-group"
+ currentPath={currentUrl}
+ link={GROUPS_STUDENT_URI}
+ />
+ )}
+
+ {isSupervisorRole(user.role) && (
+ }
+ icon="users-viewfinder"
+ currentPath={currentUrl}
+ link={GROUPS_TEACHER_URI}
+ />
+ )}
+ {isSuperadminRole(user.role) && (
+
+ }
+ icon="screwdriver-wrench"
+ currentPath={currentUrl}
+ link={GROUPS_SUPERADMIN_URI}
+ />
+ )}
}
diff --git a/src/components/widgets/DayOfWeek/DayOfWeek.js b/src/components/widgets/DayOfWeek/DayOfWeek.js
new file mode 100644
index 0000000..686cf64
--- /dev/null
+++ b/src/components/widgets/DayOfWeek/DayOfWeek.js
@@ -0,0 +1,21 @@
+import React from 'react';
+import PropTypes from 'prop-types';
+import { FormattedMessage } from 'react-intl';
+
+const DAYS = {
+ 0: ,
+ 1: ,
+ 2: ,
+ 3: ,
+ 4: ,
+ 5: ,
+ 6: ,
+};
+
+const DayOfWeek = ({ dow }) => <>{DAYS[dow] || ''}>;
+
+DayOfWeek.propTypes = {
+ dow: PropTypes.number.isRequired,
+};
+
+export default DayOfWeek;
diff --git a/src/components/widgets/DayOfWeek/index.js b/src/components/widgets/DayOfWeek/index.js
new file mode 100644
index 0000000..7ab4438
--- /dev/null
+++ b/src/components/widgets/DayOfWeek/index.js
@@ -0,0 +1,2 @@
+import DayOfWeek from './DayOfWeek.js';
+export default DayOfWeek;
diff --git a/src/containers/App/App.js b/src/containers/App/App.js
index 2b6ad63..f6e57f2 100644
--- a/src/containers/App/App.js
+++ b/src/containers/App/App.js
@@ -1,7 +1,6 @@
import React, { Component } from 'react';
import PropTypes from 'prop-types';
import { connect } from 'react-redux';
-import { Route, Routes } from 'react-router-dom';
import { library } from '@fortawesome/fontawesome-svg-core';
import { far as regularIcons } from '@fortawesome/free-regular-svg-icons';
import { fas as solidIcons } from '@fortawesome/free-solid-svg-icons';
@@ -93,11 +92,7 @@ class App extends Component {
};
render() {
- return (
-
- } />
-
- );
+ return ;
}
}
diff --git a/src/containers/App/siscodex.css b/src/containers/App/siscodex.css
index 7c073f9..b06c764 100644
--- a/src/containers/App/siscodex.css
+++ b/src/containers/App/siscodex.css
@@ -289,6 +289,10 @@ select.form-control.is-invalid, .was-validated select.form-control:invalid { /*
background-position-x: right 1.2rem;
}
+table.bg-transparent td, table.bg-transparent th {
+ background: transparent !important;
+}
+
/*
* ACE Editor Overrides
*/
diff --git a/src/helpers/localStorage.js b/src/helpers/localStorage.js
index 34452ef..b9b042e 100644
--- a/src/helpers/localStorage.js
+++ b/src/helpers/localStorage.js
@@ -45,7 +45,7 @@ const localStorageAvailable = () => {
/**
* Wrapper for local storage writer.
* @param {string} key
- * @param {*} value value must be serializabe into JSON; if null or undedined is passed, the item is removed
+ * @param {*} value value must be serializable into JSON; if null or undefined is passed, the item is removed
*/
export const storageSetItem = (key, value) => {
const prefixedKey = `${PERSISTENT_TOKENS_KEY_PREFIX}${key}`;
@@ -94,7 +94,7 @@ const listenKeyMatch = (eventKey, key) =>
/**
* Register callback which is triggered when the value is changed in local storage.
- * @param {string|null} key which changes are observerd, null to observe all changes
+ * @param {string|null} key which changes are observed, null to observe all changes
* @param {Function} callback invoked with every change: (newVal [, oldVal [, key]]) => {}
* @returns {string|null} identifier of the listener, null if no listener was registered
*/
diff --git a/src/locales/cs.json b/src/locales/cs.json
index 30da9e8..50f32d1 100644
--- a/src/locales/cs.json
+++ b/src/locales/cs.json
@@ -1,4 +1,18 @@
{
+ "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.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íč",
+ "app.addAttributeForm.other.explanation": "Vytváření vlastních atributů je zmýšlenoo jako zjednodušení příprav na budoucí funkce. Vyvarujte se vytváření atributů, pokud si nejste naprosto jisti, co děláte.",
+ "app.addAttributeForm.term": "Semestr",
+ "app.addAttributeForm.term.explanation": "Asociování identifikátoru semestru umožňuje vazby a vytváření skupin pro SIS události tohoto semestru v celém podstromu.",
+ "app.addAttributeForm.validate.course": "Identifikátor předmětu může obsahovat pouze velká písmena a číslice a musí mít přiměřenou délku.",
+ "app.addAttributeForm.validate.duplicate": "Atribut [{key}: {value}] je již spojen s touto skupinou.",
+ "app.addAttributeForm.validate.group": "Identifikátor může obsahovat pouze písmena a číslice a musí být dlouhý 8-16 znaků.",
+ "app.addAttributeForm.validate.key": "Klíč může obsahovat pouze písmena, číslice, pomlčky a podtržítka.",
+ "app.addAttributeForm.validate.term": "Semestr musí být ve formátu YYYY-S, kde YYYY je rok a S je číslo semestru (1-2).",
+ "app.addAttributeForm.value": "Hodnota",
"app.apiErrorCodes.400": "Špatný požadavek",
"app.apiErrorCodes.400-001": "Uživatel má více registrovaných e-malových adres, které odpovídají více než jednomu existujícímu účtu. Asociace účtů není možná z důvodu nejednoznačnosti.",
"app.apiErrorCodes.400-003": "Název nahrávaného souboru obsahuje nepovolené znaky.",
@@ -42,6 +56,31 @@
"app.comments.warnings.isPublic": "Tento komentář bude veřejný, takže jej uvidí každý, kdo může číst toto vlákno.",
"app.confirm.no": "Ne",
"app.confirm.yes": "Ano",
+ "app.coursesGroupsList.bind": "Svázat existující skupinu",
+ "app.coursesGroupsList.create": "Vytvořit novou skupinu",
+ "app.coursesGroupsList.firstWeekEven": "sudé týdny",
+ "app.coursesGroupsList.firstWeekOdd": "liché týdny",
+ "app.coursesGroupsList.group": "Skupina z ReCodExu",
+ "app.coursesGroupsList.hideCourses": "Skrýt lístky bez skupin",
+ "app.coursesGroupsList.infoCoursesWithoutGroups": "Jste zapsáni na {count} {count, plural, one {rozvrhový lístek} =2 {rozvrhové lístky} =3 {rozvrhové lístky} =4 {rozvrhové lístky} other {rozvrhových lístků}}, které zatím nemají žádné skupiny.",
+ "app.coursesGroupsList.join": "Připojit se",
+ "app.coursesGroupsList.labs": "Cvičení",
+ "app.coursesGroupsList.lecture": "Přednáška",
+ "app.coursesGroupsList.noCourses": "V tuto chvíli nejsou pro tento semestr dostupné žádné rozvrhové lístky.",
+ "app.coursesGroupsList.notScheduled": "nerozvrženo",
+ "app.coursesGroupsList.recodexGroupAssignments": "Zadané úlohy",
+ "app.coursesGroupsList.recodexGroupEdit": "Upravit skupinu",
+ "app.coursesGroupsList.recodexGroupStudents": "Studenti skupiny",
+ "app.coursesGroupsList.showAllCourses": "Zobrazit všechny lístky",
+ "app.coursesGroupsList.unbind": "Odvázat skupinu",
+ "app.coursesGroupsList.unbindConfirm": "Opravdu chcete odvázat kurz od této skupiny? Skupina zůstane zachována, ale studenti se do ní již nebudou moci připojit prostřednictvím rozšíření SIS-CodEx.",
+ "app.dayOfWeek.friday": "Pá",
+ "app.dayOfWeek.monday": "Po",
+ "app.dayOfWeek.saturday": "So",
+ "app.dayOfWeek.sunday": "Ne",
+ "app.dayOfWeek.thursday": "Čt",
+ "app.dayOfWeek.tuesday": "Út",
+ "app.dayOfWeek.wednesday": "St",
"app.deadlineValidation.deadlineInFarFuture": "Deadline je příliš daleko v budoucnosti. V tuto chvíli je nejvzdálenější povolený deadline {deadlineFutureLimit, date} {deadlineFutureLimit, time, short}.",
"app.deadlineValidation.emptyDeadline": "Prosíme vyplňte datum a čas termínu odevzdání.",
"app.deadlineValidation.invalidDateTime": "Neplatný formát data nebo času.",
@@ -49,21 +88,64 @@
"app.deleteButton.confirm": "Opravdu chcete tuto položku smazat? Operace nemůže být vrácena.",
"app.footer.copyright": "Copyright © 2016-{year} ReCodEx. Všechna práva vyhrazena.",
"app.footer.version": "Verze {version} (log změn)",
+ "app.groupMembershipIcon.admin": "Jste administrátor této skupiny",
+ "app.groupMembershipIcon.observer": "Jste pozorovatel této skupiny",
+ "app.groupMembershipIcon.student": "Jste studentem této skupiny",
+ "app.groupMembershipIcon.supervisor": "Jste vedoucím této skupiny",
+ "app.groups.coursesRefetched": "Seznam rozvrhových lístků byl právě znovu načten ze SIS",
"app.groups.joinGroupButton": "Stát se členem",
"app.groups.leaveGroupButton": "Opustit skupinu",
+ "app.groups.refreshButton": "Znovu načíst ze SIS",
"app.groups.removeFromGroup": "Odebrat ze skupiny",
+ "app.groups.term.summer": "Letní semestr",
+ "app.groups.term.winter": "Zimní semestr",
+ "app.groupsStudent.lastRefreshInfo": "Seznam zapsaných rozvrhových lístků byl naposledy stažen ze SISu",
+ "app.groupsStudent.noActiveTerms": "V tuto chvíli nejsou studentům dostupné žádné semestry.",
+ "app.groupsStudent.notStudent": "Tato stránka je dostupná pouze studentům.",
+ "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.currentlyManagedGroups": "Skupiny",
+ "app.groupsSupervisor.notSuperadmin": "Tato stránka je k dispozici pouze administrátorům ReCodExu.",
+ "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.",
+ "app.groupsTeacher.bindTargetGroupLabel": "Cílová skupina",
+ "app.groupsTeacher.bindTargetGroupLabelExplanation": "Kromě toho skupina nesmí být organizační a musíte mít práva administrátora nebo vedoucího.",
+ "app.groupsTeacher.confirmButtonBind": "Svázat se se skupinou",
+ "app.groupsTeacher.confirmButtonCreate": "Vytvořit skupinu",
+ "app.groupsTeacher.courseName": "Předmět",
+ "app.groupsTeacher.createGroupInfo": "Nová skupina bude vytvořena jako podskupina vybrané rodičovské skupiny. Nově vytvořená skupina bude automaticky svázána s vybranou událostí a vy se stanete jejím správcem. Název skupiny bude sestaven z názvu předmětu a informacemi o rozvržení.",
+ "app.groupsTeacher.createParentGroupLabel": "Rodičovská skupina",
+ "app.groupsTeacher.createParentGroupLabelExplanation": "Zobrazují se pouze skupiny, které jsou umístěny zároveň pod skupinou předmětu `{course}` a skupinou semestru `{term}`.",
+ "app.groupsTeacher.lastRefreshInfo": "Seznam předmětů, které vyučujete, byl naposledy stažen ze SISu",
+ "app.groupsTeacher.noActiveTerms": "V tuto chvíli nejsou učitelům dostupné žádné semestry.",
+ "app.groupsTeacher.notTeacher": "Tato stránka je dostupná pouze učitelům.",
+ "app.groupsTeacher.scheduledAt": "Rozvrženo na",
+ "app.groupsTeacher.selectGroupForBinding": "Vyberte skupinu pro svázání",
+ "app.groupsTeacher.selectParentGroupForCreating": "Vyberte rodičovskou skupinu pro nově vytvářenou skupinu",
+ "app.groupsTeacher.selectedEvent": "Vybraná událost",
+ "app.groupsTeacher.title": "Vytvořit skupiny pro předměty ze SISu",
+ "app.groupsTreeView.addAttribute": "Přidat",
+ "app.groupsTreeView.adminPopover.title": "Administrátoři skupiny",
+ "app.groupsTreeView.adminsCount": "{count} {count, plural, one {administrátor} =2 {administrátoři} =3 {administrátoři} =4 {administrátoři} other {administrátorů}}",
+ "app.groupsTreeView.empty": "Nejsou dostupné žádné skupiny.",
+ "app.groupsTreeView.examTooltip": "Skupina pro zkoušky",
+ "app.groupsTreeView.organizationalTooltip": "Skupina je organizační (nemá žádné studenty ani zadané úlohy)",
+ "app.groupsTreeView.removeAttributeConfirm": "Jste si jisti, že chcete odstranit tento atribut?",
"app.header.languageSwitching.translationTitle": "Jazyková verze",
- "app.headerNotification.copiedToClippboard": "Zkopírováno do schránky.",
+ "app.headerNotification.copiedToClipboard": "Zkopírováno do schránky.",
"app.homepage.about": "Toto rozšíření ReCodExu má na starost datovou integraci mezi ReCodExem a Studijním Informačním Systémem (SIS) Karlovy Univerzity. Prosíme vyberte si jednu ze stránek níže.",
"app.homepage.backToReCodExDescription": "A odlásit z relace SIS-CodExu.",
+ "app.homepage.groupsStudentPage": "Připojte se ke skupinám, které odpovídají vašim zapsaným předmětům v SISu.",
+ "app.homepage.groupsSuperadminPage": "Správa skupin a jejich atributů na nejvyšší úrovni (dostupné pouze hlavním administrátorům).",
+ "app.homepage.groupsTeacherPage": "Vytvořte nebo přiřaďte skupiny pro vaše předměty ze SISu.",
"app.homepage.processingToken": "Probíhá zpracování přihlašovacího tokenu...",
"app.homepage.processingTokenFailed": "Autentizační proces selhal.",
"app.homepage.returnToReCodEx": "Návrat do ReCodExu...",
"app.homepage.termsPage": "Správa semestrů a jejich souvisejících dat (kdy jsou aktivní pro studenty a učitele).",
"app.homepage.title": "Rozšíření SIS-CodEx",
"app.homepage.userPage": "Stránka s osobními údaji umožnujě synchronizovat uživatelský profil (jméno, tituly, email) s daty ze SISu.",
- "app.homepage.workInProgress": "Další funkce se připravují...",
- "app.homepage.workInProgressDescription": "Další funkce se připravují, zejména funkce týkající se práce se skupinami. Tyto funkce jsou aktuálně integrovány přímo v ReCodExu.",
"app.leaveGroup.confirm": "Opravdu chcete opustit tuto skupinu?",
"app.localizedTexts.externalLink": "Popis je umístěn mimo ReCodEx",
"app.localizedTexts.noText": "Pro danou lokalizaci není vyplněn ani text ani externí odkaz na zadaní. Tato úloha ještě není řádně dospecifikována.",
@@ -71,15 +153,6 @@
"app.localizedTexts.validation.noLocalizedText": "Prosíme povolte alespoň jednu záložku s lokalizovanými texty.",
"app.navigation.dashboard": "Přehled",
"app.navigation.edit": "Editovat",
- "app.navigation.exercise": "Úloha",
- "app.navigation.exerciseAssignments": "Zadané úlohy",
- "app.navigation.exerciseLimits": "Limity",
- "app.navigation.exerciseReferenceSolutions": "Referenční řešení",
- "app.navigation.exerciseTests": "Testy",
- "app.navigation.groupAssignments": "Úlohy ve skupině",
- "app.navigation.groupExams": "Zkouškové termíny",
- "app.navigation.groupInfo": "Info skupiny",
- "app.navigation.groupStudents": "Studenti ve skupině",
"app.navigation.user": "Uživatel",
"app.notifications.hideAll": "Pouze nové notifikace",
"app.notifications.showAll": "Zobrazit {count, plural, one {jednu starou notifikaci} two {dvě staré notifikace} other {# starých notifikací}}",
@@ -109,6 +182,9 @@
"app.roles.supervisorStudents": "Vedoucí-studenti",
"app.roles.supervisors": "Vedoucí",
"app.roles.supervisorsEmpowered": "Zplnomocnění vedoucí",
+ "app.sidebar.menu.groupsStudent": "Připojit ke skupinám",
+ "app.sidebar.menu.groupsSuperadmin": "Správa skupin",
+ "app.sidebar.menu.groupsTeacher": "Vytvořit skupiny",
"app.sidebar.menu.terms": "Semestry",
"app.sidebar.menu.user": "Osobní údaje",
"app.submissionStatus.accepted": "Toto řešení bylo označeno jako akceptované vedoucím skupiny.",
@@ -191,8 +267,9 @@
"generic.lastUpdatedAt": "aktualizováno",
"generic.loading": "Načítání...",
"generic.noRecordsInTable": "V tabulce nejsou žádné záznamy.",
+ "generic.operationFailed": "Operace selhala",
"generic.refresh": "Občerstvit",
"generic.reset": "Resetovat",
"generic.save": "Uložit",
"generic.search": "Vyhledat"
-}
+}
\ No newline at end of file
diff --git a/src/locales/en.json b/src/locales/en.json
index 1c5f01f..8f60b9a 100644
--- a/src/locales/en.json
+++ b/src/locales/en.json
@@ -1,4 +1,18 @@
{
+ "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.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",
+ "app.addAttributeForm.other.explanation": "Creating custom attributes is intended to simplify preparations for future features. Avoid creating attributes unless you are absolutely certain what you are doing.",
+ "app.addAttributeForm.term": "Semester",
+ "app.addAttributeForm.term.explanation": "Associating term (semester) identifier enables bindings and group creations for SIS events of that term in the whole sub-tree.",
+ "app.addAttributeForm.validate.course": "Course identifier can contain only uppercase letters and digits and must have adequate length.",
+ "app.addAttributeForm.validate.duplicate": "The attribute [{key}: {value}] is already associated with this group.",
+ "app.addAttributeForm.validate.group": "The identifier can contain only letters and digits and must be 8-16 characters long.",
+ "app.addAttributeForm.validate.key": "The key can contain only letters, digits, dash, and underscore.",
+ "app.addAttributeForm.validate.term": "Semester must be in the format YYYY-T, where YYYY is the year and T is the term number (1-2).",
+ "app.addAttributeForm.value": "Value",
"app.apiErrorCodes.400": "Bad request",
"app.apiErrorCodes.400-001": "The user has multiple e-mail addresses and multiple matching accounts already exist. Accounts cannot be associated due to ambiguity.",
"app.apiErrorCodes.400-003": "Uploaded file name contains invalid characters.",
@@ -42,6 +56,31 @@
"app.comments.warnings.isPublic": "This will be a public comment visible to everyone who can see this thread.",
"app.confirm.no": "No",
"app.confirm.yes": "Yes",
+ "app.coursesGroupsList.bind": "Bind Existing Group",
+ "app.coursesGroupsList.create": "Create New Group",
+ "app.coursesGroupsList.firstWeekEven": "even weeks",
+ "app.coursesGroupsList.firstWeekOdd": "odd weeks",
+ "app.coursesGroupsList.group": "ReCodEx group",
+ "app.coursesGroupsList.hideCourses": "Hide Courses Without Groups",
+ "app.coursesGroupsList.infoCoursesWithoutGroups": "You are enrolled for {count} {count, plural, one {course} other {courses}} which do not have any groups yet.",
+ "app.coursesGroupsList.join": "Join",
+ "app.coursesGroupsList.labs": "Labs",
+ "app.coursesGroupsList.lecture": "Lecture",
+ "app.coursesGroupsList.noCourses": "There are currently no courses available for this term.",
+ "app.coursesGroupsList.notScheduled": "not scheduled",
+ "app.coursesGroupsList.recodexGroupAssignments": "Group Assignments",
+ "app.coursesGroupsList.recodexGroupEdit": "Edit Group",
+ "app.coursesGroupsList.recodexGroupStudents": "Group Students",
+ "app.coursesGroupsList.showAllCourses": "Show All Courses",
+ "app.coursesGroupsList.unbind": "Unbind Group",
+ "app.coursesGroupsList.unbindConfirm": "Do you really want to unbind the course from this group? The group will remain unchanged, but the students will no longer be able to join it via SIS-CodEx extension.",
+ "app.dayOfWeek.friday": "Fri",
+ "app.dayOfWeek.monday": "Mon",
+ "app.dayOfWeek.saturday": "Sat",
+ "app.dayOfWeek.sunday": "Sun",
+ "app.dayOfWeek.thursday": "Thu",
+ "app.dayOfWeek.tuesday": "Tue",
+ "app.dayOfWeek.wednesday": "Wed",
"app.deadlineValidation.deadlineInFarFuture": "The deadline is too far in the future. At present, the furthest possible deadline is {deadlineFutureLimit, date} {deadlineFutureLimit, time, short}.",
"app.deadlineValidation.emptyDeadline": "Please fill the date and time of the deadline.",
"app.deadlineValidation.invalidDateTime": "Invalid date or time format.",
@@ -49,21 +88,64 @@
"app.deleteButton.confirm": "Are you sure you want to delete this item? The operation cannot be undone.",
"app.footer.copyright": "Copyright © 2016-{year} ReCodEx SIS extension. All rights reserved.",
"app.footer.version": "Version {version} (changelog)",
+ "app.groupMembershipIcon.admin": "You are an administrator of this group",
+ "app.groupMembershipIcon.observer": "You are an observer of this group",
+ "app.groupMembershipIcon.student": "You are a student of this group",
+ "app.groupMembershipIcon.supervisor": "You are a supervisor of this group",
+ "app.groups.coursesRefetched": "The courses were just re-downloaded from SIS.",
"app.groups.joinGroupButton": "Join group",
"app.groups.leaveGroupButton": "Leave group",
+ "app.groups.refreshButton": "Reload from SIS",
"app.groups.removeFromGroup": "Remove from group",
+ "app.groups.term.summer": "Summer Term",
+ "app.groups.term.winter": "Winter Term",
+ "app.groupsStudent.lastRefreshInfo": "The list of enrolled courses was last downloaded from SIS",
+ "app.groupsStudent.noActiveTerms": "There are currently no terms available for students.",
+ "app.groupsStudent.notStudent": "This page is available only to students.",
+ "app.groupsStudent.title": "Joining Groups as Student",
+ "app.groupsSupervisor.addAttributeModal.existingAttributes": "Existing attributes",
+ "app.groupsSupervisor.addAttributeModal.title": "Add Attribute to Group",
+ "app.groupsSupervisor.currentlyManagedGroups": "Groups",
+ "app.groupsSupervisor.notSuperadmin": "This page is available to ReCodEx administrators only.",
+ "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.",
+ "app.groupsTeacher.bindTargetGroupLabel": "Target group",
+ "app.groupsTeacher.bindTargetGroupLabelExplanation": "Additionally, the group must not be organizational and you need to have administrator or supervisor access rights.",
+ "app.groupsTeacher.confirmButtonBind": "Bind with Group",
+ "app.groupsTeacher.confirmButtonCreate": "Create Group",
+ "app.groupsTeacher.courseName": "Course",
+ "app.groupsTeacher.createGroupInfo": "A new group will be created as a subgroup of the selected parent group. The newly created group will be automatically bound to the selected event and you will become its administrator. The name of the group will be assembled automatically from the course name and the scheduling information.",
+ "app.groupsTeacher.createParentGroupLabel": "Parent group",
+ "app.groupsTeacher.createParentGroupLabelExplanation": "Only groups that are located in the hierarchy under both `{course}` course group and `{term}` term group are listed.",
+ "app.groupsTeacher.lastRefreshInfo": "The list of courses taught by you was last downloaded from SIS",
+ "app.groupsTeacher.noActiveTerms": "There are currently no terms available for teachers.",
+ "app.groupsTeacher.notTeacher": "This page is available only to teachers.",
+ "app.groupsTeacher.scheduledAt": "Scheduled at",
+ "app.groupsTeacher.selectGroupForBinding": "Select Group for Binding",
+ "app.groupsTeacher.selectParentGroupForCreating": "Select Parent Group for a New Group",
+ "app.groupsTeacher.selectedEvent": "Selected event",
+ "app.groupsTeacher.title": "Create Groups for SIS Courses",
+ "app.groupsTreeView.addAttribute": "Add",
+ "app.groupsTreeView.adminPopover.title": "Group administrators",
+ "app.groupsTreeView.adminsCount": "{count} {count, plural, one {admin} other {admins}}",
+ "app.groupsTreeView.empty": "No groups available",
+ "app.groupsTreeView.examTooltip": "Exam group",
+ "app.groupsTreeView.organizationalTooltip": "The group is organizational (it does not have any students or assignments)",
+ "app.groupsTreeView.removeAttributeConfirm": "Are you sure you want to remove this attribute?",
"app.header.languageSwitching.translationTitle": "Translation",
- "app.headerNotification.copiedToClippboard": "Copied to clippboard.",
+ "app.headerNotification.copiedToClipboard": "Copied to clipboard.",
"app.homepage.about": "This ReCodEx extension handles data integration and exchange between ReCodEx and Charles University Student Information System (SIS). Please choose one of the pages below.",
"app.homepage.backToReCodExDescription": "And logout from SIS-CodEx session.",
+ "app.homepage.groupsStudentPage": "Join groups that correspond to your enrolled courses in SIS.",
+ "app.homepage.groupsSuperadminPage": "Management of groups and their attributes at highest level (available to superadmins only).",
+ "app.homepage.groupsTeacherPage": "Create or bind groups for your courses in SIS.",
"app.homepage.processingToken": "Processing authentication token...",
"app.homepage.processingTokenFailed": "Authentication process failed.",
"app.homepage.returnToReCodEx": "Return to ReCodEx...",
"app.homepage.termsPage": "Management of terms and their related dates (when they are active for students and teachers).",
"app.homepage.title": "SiS-CodEx Extension",
"app.homepage.userPage": "The personal data integration page allows updating ReCodEx user profile (name, titles, email) using data from SIS.",
- "app.homepage.workInProgress": "More features comming...",
- "app.homepage.workInProgressDescription": "More features are being prepared, most notably the group integration which is currently embedded directly in ReCodEx.",
"app.leaveGroup.confirm": "Are you sure you want to leave this group?",
"app.localizedTexts.externalLink": "The description is located beyond the realms of ReCodEx",
"app.localizedTexts.noText": "There is no text nor link for given localization. The exercise is not fully specified yet.",
@@ -71,15 +153,6 @@
"app.localizedTexts.validation.noLocalizedText": "Please enable at least one tab of localized texts.",
"app.navigation.dashboard": "Dashboard",
"app.navigation.edit": "Edit",
- "app.navigation.exercise": "Exercise",
- "app.navigation.exerciseAssignments": "Assignments",
- "app.navigation.exerciseLimits": "Limits",
- "app.navigation.exerciseReferenceSolutions": "Reference solutions",
- "app.navigation.exerciseTests": "Tests",
- "app.navigation.groupAssignments": "Group Assignments",
- "app.navigation.groupExams": "Exam Terms",
- "app.navigation.groupInfo": "Group Info",
- "app.navigation.groupStudents": "Group Students",
"app.navigation.user": "User",
"app.notifications.hideAll": "Only new notifications",
"app.notifications.showAll": "Show {count, plural, one {old notification} two {two old notifications} other {all # notifications}}",
@@ -94,11 +167,11 @@
"app.page.loadingDescription": "Please wait while we are getting things ready.",
"app.removeFromGroup.confirm": "Are you sure you want to remove the user from this group?",
"app.resourceRenderer.loadingFailed": "Loading failed.",
- "app.roles.description.empoweredSupervisor": "A more priviledged 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 priviledged user who can see only groups he/she is member of and solve assignments inside these groups.",
+ "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.",
"app.roles.description.superadmin": "Omnipotent and omniscient user who can do anything in the instances to which he/she belongs to. Similar to root in linux or Q in Startrek.",
"app.roles.description.supervisor": "Supervisor of a group is basically a teacher who manages own groups and assigns exercises to students in these groups. Supervisor is also capable of creating new exercises.",
- "app.roles.description.supervisorStudent": "A hybrid role, which combines supervisor and student. This role is almost as priviledged as regular supervisor, but the user is also expected to be a student. The role typically covers students nearing the completion of their study programme or graduate students who help T.A. first-year courses whilst attending advanced courses.",
+ "app.roles.description.supervisorStudent": "A hybrid role, which combines supervisor and student. This role is almost as privileged as regular supervisor, but the user is also expected to be a student. The role typically covers students nearing the completion of their study programme or graduate students who help T.A. first-year courses whilst attending advanced courses.",
"app.roles.empoweredSupervisor": "Empowered Supervisor",
"app.roles.student": "Student",
"app.roles.students": "Students",
@@ -109,6 +182,9 @@
"app.roles.supervisorStudents": "Supervisor-students",
"app.roles.supervisors": "Supervisors",
"app.roles.supervisorsEmpowered": "Empowered Supervisors",
+ "app.sidebar.menu.groupsStudent": "Join Groups",
+ "app.sidebar.menu.groupsSuperadmin": "Group Management",
+ "app.sidebar.menu.groupsTeacher": "Create Groups",
"app.sidebar.menu.terms": "Terms",
"app.sidebar.menu.user": "Personal Data",
"app.submissionStatus.accepted": "This solution was marked by one of the supervisors as accepted.",
@@ -174,7 +250,7 @@
"app.user.sisUserFailedCallout": "SIS data (re)loading failed.",
"app.user.sisUserLoadedCallout": "The SIS user data were successfully (re)loaded.",
"app.user.syncButton": "Update ReCodEx profile with data from SIS",
- "app.user.syncButtonConfirmEmail": "You are about to update your e-mail address so you will be required to verify it afterwards in ReCodEx. The e-mail address is also used as your local login if you already have local account (yout local password will not be changed).",
+ "app.user.syncButtonConfirmEmail": "You are about to update your e-mail address so you will be required to verify it afterwards in ReCodEx. The e-mail address is also used as your local login if you already have local account (your local password will not be changed).",
"app.user.title": "Personal Data",
"app.user.userSyncCanceledCallout": "User sync operation was canceled, because the ReCodEx profile data were outdated and needed to be reloaded. Please, re-start the operation if it is still desired.",
"app.user.userSyncFailedCallout": "User sync operation failed. Reload the page and try again later.",
@@ -191,6 +267,7 @@
"generic.lastUpdatedAt": "updated",
"generic.loading": "Loading...",
"generic.noRecordsInTable": "There are no records in the table.",
+ "generic.operationFailed": "The operation has failed",
"generic.refresh": "Refresh",
"generic.reset": "Reset",
"generic.save": "Save",
diff --git a/src/pages/GroupsStudent/GroupsStudent.js b/src/pages/GroupsStudent/GroupsStudent.js
new file mode 100644
index 0000000..bfe2d76
--- /dev/null
+++ b/src/pages/GroupsStudent/GroupsStudent.js
@@ -0,0 +1,224 @@
+import React, { Component } from 'react';
+import PropTypes from 'prop-types';
+import ImmutablePropTypes from 'react-immutable-proptypes';
+import { connect } from 'react-redux';
+import { FormattedMessage } from 'react-intl';
+import { lruMemoize } from 'reselect';
+
+import Page from '../../components/layout/Page';
+import Box from '../../components/widgets/Box';
+import CoursesGroupsList from '../../components/Groups/CoursesGroupsList';
+import Button from '../../components/widgets/TheButton';
+import { DownloadIcon, JoinGroupIcon, LoadingIcon, RefreshIcon, TermIcon } from '../../components/icons';
+import ResourceRenderer from '../../components/helpers/ResourceRenderer';
+
+import { fetchStudentCourses } from '../../redux/modules/courses.js';
+import { fetchStudentGroups, joinGroup } from '../../redux/modules/groups.js';
+import { fetchUser, fetchUserIfNeeded } from '../../redux/modules/users.js';
+import { fetchAllTerms } from '../../redux/modules/terms.js';
+import { loggedInUserIdSelector } from '../../redux/selectors/auth.js';
+import {
+ studentSisEventsSelector,
+ getStudentSisEventsRefetchedSelector,
+ allStudentSisEventsReadySelector,
+} from '../../redux/selectors/courses.js';
+import { getGroups } from '../../redux/selectors/groups.js';
+import { termsSelector } from '../../redux/selectors/terms.js';
+import { loggedInUserSelector } from '../../redux/selectors/users.js';
+
+import { isStudentRole } from '../../components/helpers/usersRoles.js';
+import Callout from '../../components/widgets/Callout/Callout.js';
+import DateTime from '../../components/widgets/DateTime/DateTime.js';
+
+import { isReady } from '../../redux/helpers/resourceManager';
+
+const DEFAULT_EXPIRATION = 7; // days
+
+const getSortedTerms = lruMemoize(terms => {
+ const now = Math.floor(Date.now() / 1000);
+ return terms
+ .filter(term => term.studentsFrom <= now && term.studentsUntil >= now)
+ .sort((b, a) => a.year * 10 + a.term - (b.year * 10 + b.term));
+});
+
+const getAllSisIds = results => {
+ const sisIds = new Set();
+ results.forEach(({ value }) => {
+ value?.student?.forEach(sisEvent => {
+ if (sisEvent?.sisId) {
+ sisIds.add(sisEvent.sisId);
+ }
+ });
+ });
+ return Array.from(sisIds);
+};
+
+class GroupsStudent extends Component {
+ componentDidMount() {
+ this.props.loadAsync(this.props.loggedInUserId);
+ }
+
+ componentDidUpdate(prevProps) {
+ if (prevProps.loggedInUserId !== this.props.loggedInUserId) {
+ this.props.loadAsync(this.props.loggedInUserId);
+ }
+ }
+
+ static loadAsync = ({ userId }, dispatch, expiration = DEFAULT_EXPIRATION) =>
+ Promise.all([
+ dispatch(fetchUserIfNeeded(userId, { allowReload: true })),
+ dispatch(fetchAllTerms()).then(({ value }) => {
+ const now = Math.floor(Date.now() / 1000);
+ return Promise.all(
+ value
+ .filter(term => term.studentsFrom <= now && term.studentsUntil >= now)
+ .map(term =>
+ dispatch(fetchStudentCourses(term.year, term.term, expiration)).catch(
+ () => dispatch(fetchStudentCourses(term.year, term.term)) // fallback (no expiration = from cache only)
+ )
+ )
+ ).then(values =>
+ Promise.all([
+ dispatch(fetchStudentGroups(getAllSisIds(values))),
+ dispatch(fetchUser(userId, { allowReload: true })),
+ ])
+ );
+ }),
+ ]);
+
+ render() {
+ const { loggedInUser, terms, sisEventsSelector, refetchedSelector, allReady, groups, joinGroup, loadAsync } =
+ this.props;
+ const userReady = isReady(loggedInUser);
+
+ return (
+ }
+ title={}>
+ {user =>
+ isStudentRole(user.role) ? (
+
+ {terms => (
+ <>
+
+
+
+
+
+
+
+ .
+
+
+ {getSortedTerms(terms).length > 0 ? (
+ getSortedTerms(terms).map((term, idx) => (
+
+
+
+ {term.year}-{term.term}
+ {' '}
+
+ (
+ {term.term === 1 && (
+
+ )}
+ {term.term === 2 && (
+
+ )}
+ )
+
+ {refetchedSelector(term.year, term.term) && (
+
+ }
+ tooltipId={`fresh-${term.year}-${term.term}`}
+ tooltipPlacement="bottom"
+ />
+ )}
+ >
+ }
+ unlimitedHeight
+ collapsable
+ isOpen={idx === 0}>
+
+
+ ))
+ ) : (
+
+
+
+ )}
+ >
+ )}
+
+ ) : (
+
+
+
+ )
+ }
+
+ );
+ }
+}
+
+GroupsStudent.propTypes = {
+ loggedInUserId: PropTypes.string,
+ loggedInUser: ImmutablePropTypes.map,
+ terms: ImmutablePropTypes.list,
+ sisEventsSelector: PropTypes.func,
+ refetchedSelector: PropTypes.func,
+ allReady: PropTypes.bool,
+ groups: ImmutablePropTypes.map,
+ loadAsync: PropTypes.func.isRequired,
+ joinGroup: PropTypes.func.isRequired,
+};
+
+export default connect(
+ state => ({
+ loggedInUserId: loggedInUserIdSelector(state),
+ loggedInUser: loggedInUserSelector(state),
+ terms: termsSelector(state),
+ sisEventsSelector: studentSisEventsSelector(state),
+ refetchedSelector: getStudentSisEventsRefetchedSelector(state),
+ allReady: allStudentSisEventsReadySelector(state),
+ groups: getGroups(state),
+ }),
+ dispatch => ({
+ loadAsync: (userId, expiration = DEFAULT_EXPIRATION) => GroupsStudent.loadAsync({ userId }, dispatch, expiration),
+ joinGroup: (groupId, reloadGroupsOfCourses) =>
+ dispatch(joinGroup(groupId)).then(() => dispatch(fetchStudentGroups(reloadGroupsOfCourses))),
+ })
+)(GroupsStudent);
diff --git a/src/pages/GroupsStudent/index.js b/src/pages/GroupsStudent/index.js
new file mode 100644
index 0000000..e0e626e
--- /dev/null
+++ b/src/pages/GroupsStudent/index.js
@@ -0,0 +1,2 @@
+import GroupsStudent from './GroupsStudent.js';
+export default GroupsStudent;
diff --git a/src/pages/GroupsSuperadmin/GroupsSuperadmin.js b/src/pages/GroupsSuperadmin/GroupsSuperadmin.js
new file mode 100644
index 0000000..78f3a23
--- /dev/null
+++ b/src/pages/GroupsSuperadmin/GroupsSuperadmin.js
@@ -0,0 +1,202 @@
+import React, { Component } from 'react';
+import PropTypes from 'prop-types';
+import ImmutablePropTypes from 'react-immutable-proptypes';
+import { connect } from 'react-redux';
+import { Modal, Badge } from 'react-bootstrap';
+import { FormattedMessage } from 'react-intl';
+import { FORM_ERROR } from 'final-form';
+
+import Page from '../../components/layout/Page';
+import Box from '../../components/widgets/Box';
+import GroupsTreeView from '../../components/Groups/GroupsTreeView';
+import AddAttributeForm from '../../components/forms/AddAttributeForm';
+import { GroupIcon, ManagementIcon } from '../../components/icons';
+
+import { fetchAllGroups, addGroupAttribute, removeGroupAttribute } from '../../redux/modules/groups.js';
+import { fetchUserIfNeeded } from '../../redux/modules/users.js';
+import { addNotification } from '../../redux/modules/notifications.js';
+import { loggedInUserIdSelector } from '../../redux/selectors/auth.js';
+import { getGroups } from '../../redux/selectors/groups.js';
+import { termsSelector } from '../../redux/selectors/terms.js';
+import { loggedInUserSelector } from '../../redux/selectors/users.js';
+
+import { isSuperadminRole } from '../../components/helpers/usersRoles.js';
+import Callout from '../../components/widgets/Callout/Callout.js';
+
+const DEFAULT_EXPIRATION = 7; // days
+
+const ADD_FORM_INITIAL_VALUES = {
+ mode: 'course',
+ course: '',
+ term: '',
+ group: '',
+ key: '',
+ value: '',
+};
+
+class GroupsSuperadmin extends Component {
+ state = {
+ modalGroup: null,
+ modalPending: false,
+ modalError: null,
+ };
+
+ openModal = modalGroup => this.setState({ modalGroup, modalPending: false, modalError: null });
+
+ closeModal = () =>
+ this.setState({
+ modalGroup: null,
+ modalPending: false,
+ modalError: null,
+ });
+
+ addAttributeFormSubmit = async values => {
+ if (this.state.modalGroup) {
+ const key = values.mode === 'other' ? values.key.trim() : values.mode;
+ const value = values.mode === 'other' ? values.value.trim() : values[values.mode].trim();
+ try {
+ await this.props.addAttribute(this.state.modalGroup.id, key, value);
+ this.closeModal();
+ return undefined; // no error
+ } catch (err) {
+ return { [FORM_ERROR]: err?.message || err.toString() };
+ }
+ } else {
+ return Promise.resolve();
+ }
+ };
+
+ componentDidMount() {
+ this.props.loadAsync(this.props.loggedInUserId);
+ }
+
+ componentDidUpdate(prevProps) {
+ if (prevProps.loggedInUserId !== this.props.loggedInUserId) {
+ this.props.loadAsync(this.props.loggedInUserId);
+ }
+ }
+
+ static loadAsync = ({ userId }, dispatch, expiration = DEFAULT_EXPIRATION) =>
+ Promise.all([dispatch(fetchUserIfNeeded(userId, { allowReload: true })), dispatch(fetchAllGroups())]);
+
+ render() {
+ const { loggedInUser, groups, removeAttribute } = this.props;
+
+ return (
+ }
+ title={
+
+ }>
+ {(user, groups) =>
+ isSuperadminRole(user.role) ? (
+ <>
+ }>
+
+
+
+
+
+
+
+
+
+
+
+
+
+ {this.state.modalGroup?.fullName}
+
+
+ {this.state.modalGroup?.attributes && Object.keys(this.state.modalGroup?.attributes).length > 0 && (
+
+
+
+ :
+
+
+ {Object.keys(this.state.modalGroup?.attributes || {}).map(key =>
+ this.state.modalGroup.attributes[key].map(value => (
+
+ {key}: {value}
+
+ ))
+ )}
+
+ )}
+
+
+
+ {this.state.modalError && (
+
+
+ :
+
+ {this.state.modalError}
+
+ )}
+
+
+
+
+ >
+ ) : (
+
+
+
+ )
+ }
+
+ );
+ }
+}
+
+GroupsSuperadmin.propTypes = {
+ loggedInUserId: PropTypes.string,
+ loggedInUser: ImmutablePropTypes.map,
+ terms: ImmutablePropTypes.list,
+ groups: ImmutablePropTypes.map,
+ loadAsync: PropTypes.func.isRequired,
+ addAttribute: PropTypes.func.isRequired,
+ removeAttribute: PropTypes.func.isRequired,
+};
+
+export default connect(
+ state => ({
+ loggedInUserId: loggedInUserIdSelector(state),
+ loggedInUser: loggedInUserSelector(state),
+ terms: termsSelector(state),
+ groups: getGroups(state),
+ }),
+ dispatch => ({
+ loadAsync: (userId, expiration = DEFAULT_EXPIRATION) =>
+ GroupsSuperadmin.loadAsync({ userId }, dispatch, expiration),
+ addAttribute: (groupId, key, value) => dispatch(addGroupAttribute(groupId, key, value)),
+ removeAttribute: (groupId, key, value) =>
+ dispatch(removeGroupAttribute(groupId, key, value)).catch(err =>
+ dispatch(addNotification(err?.message || err.toString(), false))
+ ),
+ })
+)(GroupsSuperadmin);
diff --git a/src/pages/GroupsSuperadmin/index.js b/src/pages/GroupsSuperadmin/index.js
new file mode 100644
index 0000000..6098383
--- /dev/null
+++ b/src/pages/GroupsSuperadmin/index.js
@@ -0,0 +1,2 @@
+import GroupsSuperadmin from './GroupsSuperadmin.js';
+export default GroupsSuperadmin;
diff --git a/src/pages/GroupsTeacher/GroupsTeacher.js b/src/pages/GroupsTeacher/GroupsTeacher.js
new file mode 100644
index 0000000..24aa508
--- /dev/null
+++ b/src/pages/GroupsTeacher/GroupsTeacher.js
@@ -0,0 +1,509 @@
+import React, { Component } from 'react';
+import PropTypes from 'prop-types';
+import ImmutablePropTypes from 'react-immutable-proptypes';
+import { connect } from 'react-redux';
+import { Modal, FormGroup, FormSelect, FormLabel, InputGroup, Badge, Table } from 'react-bootstrap';
+import { FormattedMessage } from 'react-intl';
+import { lruMemoize } from 'reselect';
+
+import Page from '../../components/layout/Page';
+import Box from '../../components/widgets/Box';
+import CoursesGroupsList from '../../components/Groups/CoursesGroupsList';
+import Button, { TheButtonGroup } from '../../components/widgets/TheButton';
+import {
+ CloseIcon,
+ DownloadIcon,
+ GroupFocusIcon,
+ InfoIcon,
+ LoadingIcon,
+ RefreshIcon,
+ SuccessIcon,
+ TermIcon,
+} from '../../components/icons';
+import ResourceRenderer from '../../components/helpers/ResourceRenderer';
+
+import { fetchTeacherCourses } from '../../redux/modules/courses.js';
+import { fetchTeacherGroups, bindGroup, unbindGroup, createGroup } from '../../redux/modules/groups.js';
+import { fetchUser, fetchUserIfNeeded } from '../../redux/modules/users.js';
+import { fetchAllTerms } from '../../redux/modules/terms.js';
+import { loggedInUserIdSelector } from '../../redux/selectors/auth.js';
+import {
+ teacherSisEventsSelector,
+ getTeacherSisEventsRefetchedSelector,
+ allTeacherSisEventsReadySelector,
+} from '../../redux/selectors/courses.js';
+import { getGroups } from '../../redux/selectors/groups.js';
+import { termsSelector } from '../../redux/selectors/terms.js';
+import { loggedInUserSelector } from '../../redux/selectors/users.js';
+
+import { isSupervisorRole } from '../../components/helpers/usersRoles.js';
+import Callout from '../../components/widgets/Callout/Callout.js';
+import DateTime from '../../components/widgets/DateTime/DateTime.js';
+import InsetPanel from '../../components/widgets/InsetPanel';
+import DayOfWeek from '../../components/widgets/DayOfWeek';
+import Explanation from '../../components/widgets/Explanation';
+import { minutesToTimeStr } from '../../components/helpers/stringFormatters.js';
+
+import { isReady } from '../../redux/helpers/resourceManager';
+
+const DEFAULT_EXPIRATION = 7; // days
+
+const getSortedTerms = lruMemoize(terms => {
+ const now = Math.floor(Date.now() / 1000);
+ return terms
+ .filter(term => term.teachersFrom <= now && term.teachersUntil >= now)
+ .sort((b, a) => a.year * 10 + a.term - (b.year * 10 + b.term));
+});
+
+const getAllSisIds = results => {
+ const eventIds = new Set();
+ const courseIds = new Set();
+ results.forEach(({ value }) => {
+ [...(value?.teacher || []), ...(value?.guarantor || [])].forEach(sisEvent => {
+ if (sisEvent?.sisId) {
+ eventIds.add(sisEvent.sisId);
+ }
+ if (sisEvent?.course?.code) {
+ courseIds.add(sisEvent.course.code);
+ }
+ });
+ });
+ return [Array.from(eventIds), Array.from(courseIds)];
+};
+
+const getGroupAdmins = group => {
+ const res = Object.values(group.admins)
+ .map(admin => admin.lastName)
+ .join(', ');
+ return res ? ` [${res}]` : '';
+};
+
+class GroupsTeacher extends Component {
+ state = {
+ bindEvent: null,
+ createEvent: null,
+ modalPending: false,
+ modalError: null,
+ selectGroups: null,
+ selectedGroupId: '',
+ };
+
+ startBind = (bindEvent, selectGroups) => this.setState({ bindEvent, selectGroups });
+
+ startCreate = (createEvent, selectGroups) => this.setState({ createEvent, selectGroups });
+
+ closeModal = () =>
+ this.setState({
+ bindEvent: null,
+ createEvent: null,
+ modalPending: false,
+ modalError: null,
+ selectGroups: null,
+ selectedGroupId: '',
+ });
+
+ handleGroupChange = ev => this.setState({ selectedGroupId: ev.target.value });
+
+ completeModalOperation = () => {
+ const { loggedInUserId, bind, create, loadAsync } = this.props;
+
+ this.setState({ modalPending: true });
+ if (this.state.bindEvent !== null) {
+ bind(this.state.selectedGroupId, this.state.bindEvent).then(
+ () => loadAsync(loggedInUserId).then(this.closeModal),
+ error => this.setState({ modalPending: false, modalError: error?.message || 'unknown error' })
+ );
+ } else if (this.state.createEvent !== null) {
+ create(this.state.selectedGroupId, this.state.createEvent)
+ .then(
+ () => loadAsync(loggedInUserId),
+ error => this.setState({ modalPending: false, modalError: error?.message || 'unknown error' })
+ )
+ .then(this.closeModal);
+ } else {
+ this.closeModal();
+ }
+ };
+
+ unbindAndReload = (groupId, eventId) => {
+ const { loggedInUserId, unbind, loadAsync } = this.props;
+ unbind(groupId, eventId).then(
+ () => loadAsync(loggedInUserId),
+ () => loadAsync(loggedInUserId)
+ );
+ };
+
+ componentDidMount() {
+ this.props.loadAsync(this.props.loggedInUserId);
+ }
+
+ componentDidUpdate(prevProps) {
+ if (prevProps.loggedInUserId !== this.props.loggedInUserId) {
+ this.props.loadAsync(this.props.loggedInUserId);
+ }
+ }
+
+ static loadAsync = ({ userId }, dispatch, expiration = DEFAULT_EXPIRATION) =>
+ Promise.all([
+ dispatch(fetchUserIfNeeded(userId, { allowReload: true })),
+ dispatch(fetchAllTerms()).then(({ value }) => {
+ const now = Math.floor(Date.now() / 1000);
+ return Promise.all(
+ value
+ .filter(term => term.teachersFrom <= now && term.teachersUntil >= now)
+ .map(term =>
+ dispatch(fetchTeacherCourses(term.year, term.term, expiration)).catch(
+ () => dispatch(fetchTeacherCourses(term.year, term.term)) // fallback (no expiration = from cache only)
+ )
+ )
+ ).then(values => {
+ const [eventIds, courseIds] = getAllSisIds(values);
+ return Promise.all([
+ eventIds.length + courseIds.length > 0
+ ? dispatch(fetchTeacherGroups(eventIds, courseIds))
+ : Promise.resolve(),
+ dispatch(fetchUser(userId, { allowReload: true })),
+ ]);
+ });
+ }),
+ ]);
+
+ render() {
+ const { loggedInUser, terms, sisEventsSelector, refetchedSelector, allReady, groups, loadAsync } = this.props;
+ const userReady = isReady(loggedInUser);
+
+ return (
+ }
+ title={}>
+ {user =>
+ isSupervisorRole(user.role) ? (
+
+ {terms => (
+ <>
+
+
+
+
+
+
+
+ .
+
+
+ {getSortedTerms(terms).length > 0 ? (
+ getSortedTerms(terms).map((term, idx) => (
+
+
+
+ {term.year}-{term.term}
+ {' '}
+
+ (
+ {term.term === 1 && (
+
+ )}
+ {term.term === 2 && (
+
+ )}
+ )
+
+ {refetchedSelector(term.year, term.term) && (
+
+ }
+ tooltipId={`fresh-${term.year}-${term.term}`}
+ tooltipPlacement="bottom"
+ />
+ )}
+ >
+ }
+ unlimitedHeight
+ collapsable
+ isOpen={idx === 0}>
+
+
+ ))
+ ) : (
+
+
+
+ )}
+
+
+
+
+ {this.state.bindEvent !== null && (
+
+ )}
+ {this.state.createEvent !== null && (
+
+ )}
+
+
+
+
+
+
+ {this.state.createEvent !== null ? (
+
+ ) : (
+
+ )}
+
+
+
+
+
+
+ |
+
+ :
+ |
+
+ {(this.state.bindEvent || this.state.createEvent)?.sisId}
+ |
+
+
+ |
+ :
+ |
+
+ {(this.state.bindEvent || this.state.createEvent)?.fullName}
+ |
+
+
+ |
+ :
+ |
+
+
+
+ {minutesToTimeStr((this.state.bindEvent || this.state.createEvent)?.time)}
+ {(this.state.bindEvent || this.state.createEvent)?.fortnight && (
+ <>
+ (
+ {(this.state.bindEvent || this.state.createEvent)?.firstWeek % 2 === 1 ? (
+
+ ) : (
+
+ )}
+ )
+ >
+ )}
+ {(this.state.bindEvent || this.state.createEvent)?.room}
+ |
+
+
+
+
+
+
+
+ {this.state.createEvent !== null ? (
+
+ ) : (
+
+ )}
+ :
+
+ {' '}
+ {this.state.bindEvent !== null && (
+
+ )}
+
+
+
+
+
+ {this.state.selectGroups?.map(group => (
+
+ ))}
+
+
+
+
+
+
+
+
+
+
+ {this.state.modalError && (
+
+
+ :
+
+ {this.state.modalError}
+
+ )}
+
+
+
+
+
+
+
+
+
+
+
+ >
+ )}
+
+ ) : (
+
+
+
+ )
+ }
+
+ );
+ }
+}
+
+GroupsTeacher.propTypes = {
+ loggedInUserId: PropTypes.string,
+ loggedInUser: ImmutablePropTypes.map,
+ terms: ImmutablePropTypes.list,
+ sisEventsSelector: PropTypes.func,
+ refetchedSelector: PropTypes.func,
+ allReady: PropTypes.bool,
+ groups: ImmutablePropTypes.map,
+ loadAsync: PropTypes.func.isRequired,
+ bind: PropTypes.func.isRequired,
+ unbind: PropTypes.func.isRequired,
+ create: PropTypes.func.isRequired,
+};
+
+export default connect(
+ state => ({
+ loggedInUserId: loggedInUserIdSelector(state),
+ loggedInUser: loggedInUserSelector(state),
+ terms: termsSelector(state),
+ sisEventsSelector: teacherSisEventsSelector(state),
+ refetchedSelector: getTeacherSisEventsRefetchedSelector(state),
+ allReady: allTeacherSisEventsReadySelector(state),
+ groups: getGroups(state),
+ }),
+ dispatch => ({
+ loadAsync: (userId, expiration = DEFAULT_EXPIRATION) => GroupsTeacher.loadAsync({ userId }, dispatch, expiration),
+ bind: (groupId, event) => dispatch(bindGroup(groupId, event)),
+ unbind: (groupId, event) => dispatch(unbindGroup(groupId, event)),
+ create: (parentGroupId, event) => dispatch(createGroup(parentGroupId, event)),
+ })
+)(GroupsTeacher);
diff --git a/src/pages/GroupsTeacher/index.js b/src/pages/GroupsTeacher/index.js
new file mode 100644
index 0000000..85f66dd
--- /dev/null
+++ b/src/pages/GroupsTeacher/index.js
@@ -0,0 +1,2 @@
+import GroupsTeacher from './GroupsTeacher.js';
+export default GroupsTeacher;
diff --git a/src/pages/Home/Home.js b/src/pages/Home/Home.js
index 011efff..b6b8056 100644
--- a/src/pages/Home/Home.js
+++ b/src/pages/Home/Home.js
@@ -8,9 +8,12 @@ import { Link } from 'react-router-dom';
import Page from '../../components/layout/Page';
import Icon, {
+ GroupFocusIcon,
HomeIcon,
+ JoinGroupIcon,
LinkIcon,
LoadingIcon,
+ ManagementIcon,
ReturnIcon,
UserProfileIcon,
TermIcon,
@@ -26,7 +29,7 @@ import { loggedInUserIdSelector } from '../../redux/selectors/auth.js';
import { getReturnUrl, setReturnUrl } from '../../helpers/localStorage.js';
import { knownLocalesNames } from '../../helpers/localizedData.js';
-import { isSuperadminRole } from '../../components/helpers/usersRoles.js';
+import { isStudentRole, isSupervisorRole, isSuperadminRole } from '../../components/helpers/usersRoles.js';
import withLinks from '../../helpers/withLinks.js';
class Home extends Component {
@@ -82,7 +85,7 @@ class Home extends Component {
const {
loggedInUser,
params: { token = null },
- links: { USER_URI, TERMS_URI },
+ links: { USER_URI, TERMS_URI, GROUPS_STUDENT_URI, GROUPS_TEACHER_URI, GROUPS_SUPERADMIN_URI },
} = this.props;
return (
@@ -150,50 +153,108 @@ class Home extends Component {
- {isSuperadminRole(user.role) && (
+ {isStudentRole(user.role) && (
-
+
-
-
+
+
)}
-
-
-
-
-
-
-
-
-
-
+ {isSupervisorRole(user.role) && (
+
+
+
+
+
+
+
+
+
+
+
+
+
-
-
-
-
-
+
+
+
+
+
+ )}
+
+ {isSuperadminRole(user.role) && (
+ <>
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ >
+ )}
diff --git a/src/pages/User/User.js b/src/pages/User/User.js
index 3f55d85..7cc3522 100644
--- a/src/pages/User/User.js
+++ b/src/pages/User/User.js
@@ -240,7 +240,7 @@ class User extends Component {
diffIndex.email ? (
) : null
}
diff --git a/src/pages/routes.js b/src/pages/routes.js
index 3c8b0d9..4dfe742 100644
--- a/src/pages/routes.js
+++ b/src/pages/routes.js
@@ -3,6 +3,9 @@ import { matchPath, Routes, Route, Navigate } from 'react-router-dom';
/* container components */
import App from '../containers/App';
+import GroupsStudent from './GroupsStudent';
+import GroupsTeacher from './GroupsTeacher';
+import GroupsSuperadmin from './GroupsSuperadmin';
import Home from './Home';
import Terms from './Terms';
import User from './User';
@@ -69,6 +72,9 @@ const routesDescriptors = [
r('login/:token', Home, 'LOGIN_URI'),
r('app/user', User, 'USER_URI', true),
r('app/terms', Terms, 'TERMS_URI', true),
+ r('app/groups-student', GroupsStudent, 'GROUPS_STUDENT_URI', true),
+ r('app/groups-teacher', GroupsTeacher, 'GROUPS_TEACHER_URI', true),
+ r('app/groups-superadmin', GroupsSuperadmin, 'GROUPS_SUPERADMIN_URI', true),
];
/*
diff --git a/src/redux/helpers/api/tools.js b/src/redux/helpers/api/tools.js
index a100d8d..8330fe9 100644
--- a/src/redux/helpers/api/tools.js
+++ b/src/redux/helpers/api/tools.js
@@ -47,8 +47,15 @@ const generateQuery = query => {
return flatten.join('&');
};
-export const assembleEndpoint = (endpoint, query = {}) =>
- endpoint + maybeQuestionMark(endpoint, query) + generateQuery(query);
+export const assembleEndpoint = (endpoint, query = {}) => {
+ const filteredQuery = {};
+ Object.keys(query)
+ .filter(key => query[key] !== undefined && query[key] !== null)
+ .forEach(key => {
+ filteredQuery[key] = query[key];
+ });
+ return endpoint + maybeQuestionMark(endpoint, filteredQuery) + generateQuery(filteredQuery);
+};
export const flattenBody = body => {
const flattened = flatten(body, { delimiter: ':' });
diff --git a/src/redux/modules/courses.js b/src/redux/modules/courses.js
new file mode 100644
index 0000000..8e46d5d
--- /dev/null
+++ b/src/redux/modules/courses.js
@@ -0,0 +1,73 @@
+import { handleActions } from 'redux-actions';
+import { fromJS } from 'immutable';
+
+import { createRecord, resourceStatus, createActionsWithPostfixes } from '../helpers/resourceManager';
+import { createApiAction } from '../middleware/apiMiddleware.js';
+
+export const additionalActionTypes = {
+ ...createActionsWithPostfixes('FETCH', 'siscodex/courses'),
+};
+
+/**
+ * Actions
+ */
+
+const _fetchCourses = (year, term, affiliation, expiration = undefined) =>
+ createApiAction({
+ type: additionalActionTypes.FETCH,
+ endpoint: '/courses',
+ method: 'POST',
+ meta: { year, term, affiliation },
+ body: { year, term, affiliation, expiration },
+ });
+
+export const fetchStudentCourses = (year, term, expiration = undefined) =>
+ _fetchCourses(year, term, 'student', expiration);
+
+export const fetchTeacherCourses = (year, term, expiration = undefined) =>
+ _fetchCourses(year, term, 'teacher', expiration);
+
+const getPayload = (payload, affiliation) => {
+ if (!payload || typeof payload !== 'object') {
+ return null;
+ }
+
+ if (affiliation === 'teacher') {
+ // special case, we need to merge in guarantor
+ return [...(payload.teacher || []), ...(payload.guarantor || [])];
+ }
+ return payload ? payload[affiliation] : null;
+};
+
+/**
+ * Reducer
+ */
+const reducer = handleActions(
+ {
+ [additionalActionTypes.FETCH_PENDING]: (state, { meta: { year, term, affiliation } }) =>
+ state.hasIn([affiliation, `${year}-${term}`])
+ ? state.setIn([affiliation, `${year}-${term}`], createRecord())
+ : state
+ .setIn([affiliation, `${year}-${term}`, 'state'], resourceStatus.RELOADING)
+ .setIn([affiliation, `${year}-${term}`, 'refetched'], null),
+
+ [additionalActionTypes.FETCH_FULFILLED]: (state, { meta: { year, term, affiliation }, payload }) =>
+ state
+ .setIn(
+ [affiliation, `${year}-${term}`],
+ createRecord({ state: resourceStatus.FULFILLED, data: getPayload(payload, affiliation) })
+ )
+ .setIn([affiliation, `${year}-${term}`, 'refetched'], payload.refetched || false),
+
+ [additionalActionTypes.FETCH_REJECTED]: (state, { meta: { year, term, affiliation }, payload }) =>
+ state
+ .setIn([affiliation, `${year}-${term}`, 'state'], resourceStatus.FAILED)
+ .setIn([affiliation, `${year}-${term}`, 'error'], payload),
+ },
+ fromJS({
+ student: {},
+ teacher: {},
+ })
+);
+
+export default reducer;
diff --git a/src/redux/modules/groups.js b/src/redux/modules/groups.js
new file mode 100644
index 0000000..f92994e
--- /dev/null
+++ b/src/redux/modules/groups.js
@@ -0,0 +1,170 @@
+import { handleActions } from 'redux-actions';
+
+import { createRecord, resourceStatus, createActionsWithPostfixes } from '../helpers/resourceManager';
+import { createApiAction } from '../middleware/apiMiddleware.js';
+import { fromJS } from 'immutable';
+
+export const additionalActionTypes = {
+ ...createActionsWithPostfixes('FETCH', 'siscodex/groups'),
+ ...createActionsWithPostfixes('BIND', 'siscodex/groups'),
+ ...createActionsWithPostfixes('UNBIND', 'siscodex/groups'),
+ ...createActionsWithPostfixes('CREATE', 'siscodex/groups'),
+ ...createActionsWithPostfixes('JOIN', 'siscodex/groups'),
+ ...createActionsWithPostfixes('ADD_ATTRIBUTE', 'siscodex/groups'),
+ ...createActionsWithPostfixes('REMOVE_ATTRIBUTE', 'siscodex/groups'),
+};
+
+/**
+ * Actions
+ */
+
+const _fetchGroups = (affiliation, eventIds = undefined, courseIds = undefined) =>
+ createApiAction({
+ type: additionalActionTypes.FETCH,
+ endpoint: `/groups${affiliation ? '/' : ''}${affiliation || ''}`,
+ method: 'GET',
+ meta: { affiliation },
+ query: { eventIds, courseIds },
+ });
+
+export const fetchAllGroups = () => _fetchGroups(undefined);
+export const fetchStudentGroups = eventIds => _fetchGroups('student', eventIds);
+export const fetchTeacherGroups = (eventIds, courseIds) => _fetchGroups('teacher', eventIds, courseIds);
+
+export const bindGroup = (groupId, event) =>
+ createApiAction({
+ type: additionalActionTypes.BIND,
+ endpoint: `/groups/${groupId}/bind/${event.id}`,
+ method: 'POST',
+ meta: { groupId, eventId: event.id, eventSisId: event.sisId },
+ });
+
+export const unbindGroup = (groupId, event) =>
+ createApiAction({
+ type: additionalActionTypes.UNBIND,
+ endpoint: `/groups/${groupId}/bind/${event.id}`,
+ method: 'DELETE',
+ meta: { groupId, eventId: event.id, eventSisId: event.sisId },
+ });
+
+export const createGroup = (parentId, event) =>
+ createApiAction({
+ type: additionalActionTypes.CREATE,
+ endpoint: `/groups/${parentId}/create/${event.id}`,
+ method: 'POST',
+ meta: { parentId, eventId: event.id, eventSisId: event.sisId },
+ });
+
+export const joinGroup = groupId =>
+ createApiAction({
+ type: additionalActionTypes.JOIN,
+ endpoint: `/groups/${groupId}/join`,
+ method: 'POST',
+ meta: { groupId },
+ });
+
+export const addGroupAttribute = (groupId, key, value) =>
+ createApiAction({
+ type: additionalActionTypes.ADD_ATTRIBUTE,
+ endpoint: `/groups/${groupId}/add-attribute`,
+ method: 'POST',
+ meta: { groupId, key, value },
+ body: { key, value },
+ });
+
+export const removeGroupAttribute = (groupId, key, value) =>
+ createApiAction({
+ type: additionalActionTypes.REMOVE_ATTRIBUTE,
+ endpoint: `/groups/${groupId}/remove-attribute`,
+ method: 'POST',
+ meta: { groupId, key, value },
+ body: { key, value },
+ });
+
+/**
+ * Reducer
+ */
+const fixAttributes = payload => {
+ Object.values(payload).forEach(group => {
+ group.attributes = !group.attributes || Array.isArray(group.attributes) ? {} : group.attributes;
+ });
+ return payload;
+};
+
+const reducer = handleActions(
+ {
+ [additionalActionTypes.FETCH_PENDING]: state => state.set('state', resourceStatus.RELOADING),
+
+ [additionalActionTypes.FETCH_FULFILLED]: (state, { payload }) =>
+ createRecord({ state: resourceStatus.FULFILLED, data: fixAttributes(payload) }),
+
+ [additionalActionTypes.FETCH_REJECTED]: (state, { payload }) =>
+ state.set('state', resourceStatus.FAILED).set('error', payload),
+
+ // group management
+ [additionalActionTypes.BIND_PENDING]: (state, { meta: { groupId, eventSisId } }) =>
+ state
+ .updateIn(['data', groupId, 'attributes', 'group'], groups =>
+ groups ? groups.push(eventSisId) : fromJS([eventSisId])
+ )
+ .setIn(['data', groupId, 'pending'], 'binding'),
+
+ [additionalActionTypes.BIND_FULFILLED]: (state, { meta: { groupId } }) =>
+ state.removeIn(['data', groupId, 'pending']),
+
+ [additionalActionTypes.BIND_REJECTED]: (state, { meta: { groupId, eventSisId }, payload }) =>
+ state
+ .updateIn(['data', groupId, 'attributes', 'group'], groups => groups?.filter(id => id !== eventSisId))
+ .removeIn(['data', groupId, 'pending'])
+ .set('error', payload),
+
+ [additionalActionTypes.UNBIND_PENDING]: (state, { meta: { groupId } }) =>
+ state.setIn(['data', groupId, 'pending'], 'unbinding'),
+
+ [additionalActionTypes.UNBIND_FULFILLED]: (state, { meta: { groupId, eventSisId } }) =>
+ state
+ .updateIn(['data', groupId, 'attributes', 'group'], groups => groups?.filter(id => id !== eventSisId))
+ .removeIn(['data', groupId, 'pending']),
+
+ [additionalActionTypes.UNBIND_REJECTED]: (state, { meta: { groupId }, payload }) =>
+ state.removeIn(['data', groupId, 'pending']).set('error', payload),
+
+ // user joining
+ [additionalActionTypes.JOIN_PENDING]: (state, { meta: { groupId } }) =>
+ state.setIn(['data', groupId, 'membership'], 'student').setIn(['data', groupId, 'pending'], 'joining'),
+
+ [additionalActionTypes.JOIN_FULFILLED]: (state, { meta: { groupId } }) =>
+ state.removeIn(['data', groupId, 'pending']),
+
+ [additionalActionTypes.JOIN_REJECTED]: (state, { meta: { groupId }, payload }) =>
+ state.setIn(['data', groupId, 'membership'], null).removeIn(['data', groupId, 'pending']).set('error', payload),
+
+ // attributes
+ [additionalActionTypes.ADD_ATTRIBUTE_PENDING]: (state, { meta: { groupId, key, value } }) =>
+ state.setIn(['data', groupId, 'pending'], 'adding'),
+
+ [additionalActionTypes.ADD_ATTRIBUTE_FULFILLED]: (state, { meta: { groupId, key, value } }) =>
+ state
+ .updateIn(['data', groupId, 'attributes', key], attributes =>
+ attributes ? attributes.push(value) : fromJS([value])
+ )
+ .removeIn(['data', groupId, 'pending']),
+
+ [additionalActionTypes.ADD_ATTRIBUTE_REJECTED]: (state, { meta: { groupId }, payload }) =>
+ state.removeIn(['data', groupId, 'pending']).set('error', payload),
+
+ [additionalActionTypes.REMOVE_ATTRIBUTE_PENDING]: (state, { meta: { groupId, key, value } }) =>
+ state.setIn(['data', groupId, 'pending'], 'removing'),
+
+ [additionalActionTypes.REMOVE_ATTRIBUTE_FULFILLED]: (state, { meta: { groupId, key, value } }) =>
+ state
+ .updateIn(['data', groupId, 'attributes', key], attributes => attributes?.filter(val => val !== value))
+ .removeIn(['data', groupId, 'pending']),
+
+ [additionalActionTypes.REMOVE_ATTRIBUTE_REJECTED]: (state, { meta: { groupId }, payload }) =>
+ state.removeIn(['data', groupId, 'pending']).set('error', payload),
+ },
+ createRecord()
+);
+
+export default reducer;
diff --git a/src/redux/reducer.js b/src/redux/reducer.js
index 7fd63ae..0a7004b 100644
--- a/src/redux/reducer.js
+++ b/src/redux/reducer.js
@@ -2,6 +2,8 @@ import { combineReducers } from 'redux';
import app from './modules/app.js';
import auth, { actionTypes as authActionTypes } from './modules/auth.js';
+import courses from './modules/courses.js';
+import groups from './modules/groups.js';
import notifications from './modules/notifications.js';
import sisUsers from './modules/sisUsers.js';
import terms from './modules/terms.js';
@@ -10,6 +12,8 @@ import users from './modules/users.js';
const createRecodexReducers = (token, lang) => ({
app: app(lang),
auth: auth(token),
+ courses,
+ groups,
notifications,
sisUsers,
terms,
diff --git a/src/redux/selectors/courses.js b/src/redux/selectors/courses.js
new file mode 100644
index 0000000..ba3fcc8
--- /dev/null
+++ b/src/redux/selectors/courses.js
@@ -0,0 +1,38 @@
+import { createSelector } from 'reselect';
+import { isReady } from '../helpers/resourceManager';
+
+const getCourses = state => state.courses;
+const getStudent = courses => courses.get('student');
+const getTeacher = courses => courses.get('teacher');
+// const getParam = (state, param) => param;
+
+const studentsSelector = createSelector(getCourses, getStudent);
+const teachersSelector = createSelector(getCourses, getTeacher);
+
+export const studentSisEventsSelector = createSelector(
+ studentsSelector,
+ events => (year, term) => events.get(`${year}-${term}`)
+);
+
+export const getStudentSisEventsRefetchedSelector = createSelector(
+ studentsSelector,
+ courses => (year, term) => courses.getIn([`${year}-${term}`, 'refetched'], false)
+);
+
+export const allStudentSisEventsReadySelector = createSelector(studentsSelector, courses =>
+ courses.every(record => isReady(record))
+);
+
+export const teacherSisEventsSelector = createSelector(
+ teachersSelector,
+ courses => (year, term) => courses.get(`${year}-${term}`)
+);
+
+export const getTeacherSisEventsRefetchedSelector = createSelector(
+ teachersSelector,
+ courses => (year, term) => courses.getIn([`${year}-${term}`, 'refetched'], false)
+);
+
+export const allTeacherSisEventsReadySelector = createSelector(teachersSelector, courses =>
+ courses.every(record => isReady(record))
+);
diff --git a/src/redux/selectors/groups.js b/src/redux/selectors/groups.js
new file mode 100644
index 0000000..3daf9c1
--- /dev/null
+++ b/src/redux/selectors/groups.js
@@ -0,0 +1,3 @@
+// import { createSelector } from 'reselect';
+
+export const getGroups = state => state.groups;
diff --git a/src/redux/selectors/terms.js b/src/redux/selectors/terms.js
index bc987e0..6a5c2e4 100644
--- a/src/redux/selectors/terms.js
+++ b/src/redux/selectors/terms.js
@@ -1,10 +1,5 @@
import { createSelector } from 'reselect';
-// import { isReady, getJsData } from '../helpers/resourceManager';
-// import { isStudentRole, isSupervisorRole } from '../../components/helpers/usersRoles.js';
-
-// const getParam = (state, id) => id;
-
const getTerms = state => state.terms;
const getResources = terms => terms.get('resources');