From 017e1d08bff28c015a2c5233fe0361b0a2e43b33 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Martin=20Kruli=C5=A1?= Date: Thu, 11 Sep 2025 14:11:52 +0200 Subject: [PATCH 01/15] Fixing bug in terms form validation. --- .../forms/EditTermForm/EditTermForm.js | 17 ++++++++++------- 1 file changed, 10 insertions(+), 7 deletions(-) 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 && From 45c6bb39d06fbfc9f8fc01052e37ccdcfdb8b35c Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Martin=20Kruli=C5=A1?= Date: Thu, 11 Sep 2025 16:27:20 +0200 Subject: [PATCH 02/15] Loading and showing courses (SIS events) for students. --- src/components/icons/index.js | 3 + src/components/layout/Sidebar/Sidebar.js | 17 ++- src/locales/cs.json | 8 + src/locales/en.json | 8 + src/pages/GroupsStudent/GroupsStudent.js | 183 +++++++++++++++++++++++ src/pages/GroupsStudent/index.js | 2 + src/pages/Home/Home.js | 30 +++- src/pages/routes.js | 2 + src/redux/modules/courses.js | 61 ++++++++ src/redux/reducer.js | 2 + src/redux/selectors/courses.js | 24 +++ src/redux/selectors/terms.js | 5 - 12 files changed, 336 insertions(+), 9 deletions(-) create mode 100644 src/pages/GroupsStudent/GroupsStudent.js create mode 100644 src/pages/GroupsStudent/index.js create mode 100644 src/redux/modules/courses.js create mode 100644 src/redux/selectors/courses.js diff --git a/src/components/icons/index.js b/src/components/icons/index.js index dfa9c17..c23f897 100644 --- a/src/components/icons/index.js +++ b/src/components/icons/index.js @@ -68,6 +68,9 @@ 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 => ; diff --git a/src/components/layout/Sidebar/Sidebar.js b/src/components/layout/Sidebar/Sidebar.js index cc3e8d8..033ef39 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 }, +}) => { const user = getUserData(loggedInUser); return ( @@ -69,6 +74,14 @@ const Sidebar = ({ pendingFetchOperations, loggedInUser, currentUrl, links: { HO link={USER_URI} /> + {isStudentRole(user.role) && ( + } + icon="people-group" + currentPath={currentUrl} + link={GROUPS_STUDENT_URI} + /> + )} {isSupervisorRole(user.role) && <>} {isSuperadminRole(user.role) && <>} diff --git a/src/locales/cs.json b/src/locales/cs.json index 30da9e8..a4a3fb8 100644 --- a/src/locales/cs.json +++ b/src/locales/cs.json @@ -52,10 +52,17 @@ "app.groups.joinGroupButton": "Stát se členem", "app.groups.leaveGroupButton": "Opustit skupinu", "app.groups.removeFromGroup": "Odebrat ze skupiny", + "app.groupsStudent.noActiveTerms": "V tuto chvíli nejsou studentům dostupné žádné semestry.", + "app.groupsStudent.noCourses": "V tuto chvíli nejsou pro tento semestr dostupné žádné rozvrhové lístky.", + "app.groupsStudent.notStudent": "Tato stránka je dostupná pouze studentům.", + "app.groupsStudent.term.summer": "Letní semestr", + "app.groupsStudent.term.winter": "Zimní semestr", + "app.groupsStudent.title": "Připojení ke skupinám jako student", "app.header.languageSwitching.translationTitle": "Jazyková verze", "app.headerNotification.copiedToClippboard": "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.processingToken": "Probíhá zpracování přihlašovacího tokenu...", "app.homepage.processingTokenFailed": "Autentizační proces selhal.", "app.homepage.returnToReCodEx": "Návrat do ReCodExu...", @@ -109,6 +116,7 @@ "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.terms": "Semestry", "app.sidebar.menu.user": "Osobní údaje", "app.submissionStatus.accepted": "Toto řešení bylo označeno jako akceptované vedoucím skupiny.", diff --git a/src/locales/en.json b/src/locales/en.json index 1c5f01f..0071e90 100644 --- a/src/locales/en.json +++ b/src/locales/en.json @@ -52,10 +52,17 @@ "app.groups.joinGroupButton": "Join group", "app.groups.leaveGroupButton": "Leave group", "app.groups.removeFromGroup": "Remove from group", + "app.groupsStudent.noActiveTerms": "There are currently no terms available for students.", + "app.groupsStudent.noCourses": "There are currently no courses available for this term.", + "app.groupsStudent.notStudent": "This page is available only to students.", + "app.groupsStudent.term.summer": "Summer Term", + "app.groupsStudent.term.winter": "Winter Term", + "app.groupsStudent.title": "Joining Groups as Student", "app.header.languageSwitching.translationTitle": "Translation", "app.headerNotification.copiedToClippboard": "Copied to clippboard.", "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.processingToken": "Processing authentication token...", "app.homepage.processingTokenFailed": "Authentication process failed.", "app.homepage.returnToReCodEx": "Return to ReCodEx...", @@ -109,6 +116,7 @@ "app.roles.supervisorStudents": "Supervisor-students", "app.roles.supervisors": "Supervisors", "app.roles.supervisorsEmpowered": "Empowered Supervisors", + "app.sidebar.menu.groupsStudent": "Join 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.", diff --git a/src/pages/GroupsStudent/GroupsStudent.js b/src/pages/GroupsStudent/GroupsStudent.js new file mode 100644 index 0000000..731a2e4 --- /dev/null +++ b/src/pages/GroupsStudent/GroupsStudent.js @@ -0,0 +1,183 @@ +import React, { Component } from 'react'; +import PropTypes from 'prop-types'; +import ImmutablePropTypes from 'react-immutable-proptypes'; +import { connect } from 'react-redux'; +import { FormattedMessage, injectIntl } from 'react-intl'; +import { lruMemoize } from 'reselect'; + +import Page from '../../components/layout/Page'; +import Box from '../../components/widgets/Box'; +// import Button, { TheButtonGroup } from '../../components/widgets/TheButton'; +import { JoinGroupIcon, LabsIcon, LectureIcon, TermIcon } from '../../components/icons'; +import ResourceRenderer from '../../components/helpers/ResourceRenderer'; + +import { fetchStudentCourses } from '../../redux/modules/courses.js'; +import { fetchUserIfNeeded } from '../../redux/modules/users.js'; +import { fetchAllTerms } from '../../redux/modules/terms.js'; +import { loggedInUserIdSelector } from '../../redux/selectors/auth.js'; +import { studentCoursesSelector } from '../../redux/selectors/courses.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'; + +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 getCourseName = ({ course }, locale) => { + const key = `caption_${locale}`; + return course?.[key] || course?.caption_en || course?.caption_cs || '???'; +}; + +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) => + Promise.all([ + dispatch(fetchUserIfNeeded(userId)), + 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, DEFAULT_EXPIRATION)).catch( + () => dispatch(fetchStudentCourses(term.year, term.term)) // fallback (no expiration = from cache only) + ) + ) + ); + }), + ]); + + render() { + const { + loggedInUser, + terms, + coursesSelector, + intl: { locale }, + } = this.props; + + 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 && ( + + )} + ) + + + } + collapsable + isOpen={idx === 0}> + + {courses => + courses && courses.length > 0 ? ( + + {courses.map(course => ( + + + + + + + + + + + ))} +
+ {course.type === 'lecture' && } + {course.type === 'labs' && } + {course.dayOfWeek}{course.time}{course.room}{course.sisId}{getCourseName(course, locale)}
+ ) : ( +
+ +
+ ) + } +
+
+ )) + ) : ( + + + + ) + } +
+ ) : ( + + + + ) + } +
+ ); + } +} + +GroupsStudent.propTypes = { + loggedInUserId: PropTypes.string, + loggedInUser: ImmutablePropTypes.map, + terms: ImmutablePropTypes.list, + coursesSelector: PropTypes.func, + loadAsync: PropTypes.func.isRequired, + intl: PropTypes.shape({ locale: PropTypes.string.isRequired }).isRequired, +}; + +export default connect( + state => ({ + loggedInUserId: loggedInUserIdSelector(state), + loggedInUser: loggedInUserSelector(state), + terms: termsSelector(state), + coursesSelector: studentCoursesSelector(state), + }), + dispatch => ({ + loadAsync: userId => GroupsStudent.loadAsync({ userId }, dispatch), + }) +)(injectIntl(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/Home/Home.js b/src/pages/Home/Home.js index 011efff..5701821 100644 --- a/src/pages/Home/Home.js +++ b/src/pages/Home/Home.js @@ -15,6 +15,7 @@ import Icon, { UserProfileIcon, TermIcon, WarningIcon, + JoinGroupIcon, } from '../../components/icons'; import Callout from '../../components/widgets/Callout'; @@ -26,7 +27,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, isSuperadminRole } from '../../components/helpers/usersRoles.js'; import withLinks from '../../helpers/withLinks.js'; class Home extends Component { @@ -82,7 +83,7 @@ class Home extends Component { const { loggedInUser, params: { token = null }, - links: { USER_URI, TERMS_URI }, + links: { USER_URI, TERMS_URI, GROUPS_STUDENT_URI }, } = this.props; return ( @@ -150,6 +151,31 @@ class Home extends Component { + {isStudentRole(user.role) && ( + + +

+ +

+ + +

+ + + + +

+ +

+ +

+ +
+ )} + {isSuperadminRole(user.role) && ( diff --git a/src/pages/routes.js b/src/pages/routes.js index 3c8b0d9..fe86d1c 100644 --- a/src/pages/routes.js +++ b/src/pages/routes.js @@ -3,6 +3,7 @@ import { matchPath, Routes, Route, Navigate } from 'react-router-dom'; /* container components */ import App from '../containers/App'; +import GroupsStudent from './GroupsStudent'; import Home from './Home'; import Terms from './Terms'; import User from './User'; @@ -69,6 +70,7 @@ 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), ]; /* diff --git a/src/redux/modules/courses.js b/src/redux/modules/courses.js new file mode 100644 index 0000000..0eb3acf --- /dev/null +++ b/src/redux/modules/courses.js @@ -0,0 +1,61 @@ +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); + +/** + * 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: 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/reducer.js b/src/redux/reducer.js index 7fd63ae..07ae141 100644 --- a/src/redux/reducer.js +++ b/src/redux/reducer.js @@ -2,6 +2,7 @@ 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 notifications from './modules/notifications.js'; import sisUsers from './modules/sisUsers.js'; import terms from './modules/terms.js'; @@ -10,6 +11,7 @@ import users from './modules/users.js'; const createRecodexReducers = (token, lang) => ({ app: app(lang), auth: auth(token), + courses, notifications, sisUsers, terms, diff --git a/src/redux/selectors/courses.js b/src/redux/selectors/courses.js new file mode 100644 index 0000000..f3c63fe --- /dev/null +++ b/src/redux/selectors/courses.js @@ -0,0 +1,24 @@ +import { createSelector } from 'reselect'; + +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 studentCoursesSelector = createSelector( + studentsSelector, + courses => (year, term) => courses.get(`${year}-${term}`) +); + +export const getStudentCoursesRefetchedSelector = createSelector( + studentsSelector, + courses => (year, term) => courses.getIn([`${year}-${term}`, 'refetched'], false) +); + +export const teacherCoursesSelector = createSelector( + teachersSelector, + courses => (year, term) => courses.get(`${year}-${term}`) +); 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'); From 5c370f5d0497bb2c8d9fe13fe009026425106a3a Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Martin=20Kruli=C5=A1?= Date: Fri, 12 Sep 2025 18:41:59 +0200 Subject: [PATCH 03/15] Displaying list of course groups to students and allowing them to join in. Refresh button added to groups-student page. --- .../CoursesGroupsList/CoursesGroupsList.css | 25 ++ .../CoursesGroupsList/CoursesGroupsList.js | 303 ++++++++++++++++++ .../Groups/CoursesGroupsList/index.js | 2 + src/components/helpers/recodexLinks.js | 12 + src/components/icons/index.js | 11 +- src/components/widgets/DayOfWeek/DayOfWeek.js | 21 ++ src/components/widgets/DayOfWeek/index.js | 2 + src/helpers/localStorage.js | 4 +- src/locales/cs.json | 24 +- src/locales/en.json | 24 +- src/pages/GroupsStudent/GroupsStudent.js | 205 +++++++----- src/redux/modules/groups.js | 61 ++++ src/redux/reducer.js | 2 + src/redux/selectors/courses.js | 5 + src/redux/selectors/groups.js | 3 + 15 files changed, 615 insertions(+), 89 deletions(-) create mode 100644 src/components/Groups/CoursesGroupsList/CoursesGroupsList.css create mode 100644 src/components/Groups/CoursesGroupsList/CoursesGroupsList.js create mode 100644 src/components/Groups/CoursesGroupsList/index.js create mode 100644 src/components/helpers/recodexLinks.js create mode 100644 src/components/widgets/DayOfWeek/DayOfWeek.js create mode 100644 src/components/widgets/DayOfWeek/index.js create mode 100644 src/redux/modules/groups.js create mode 100644 src/redux/selectors/groups.js 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..26f9cbd --- /dev/null +++ b/src/components/Groups/CoursesGroupsList/CoursesGroupsList.js @@ -0,0 +1,303 @@ +import React, { useState } from 'react'; +import PropTypes from 'prop-types'; +import ImmutablePropTypes from 'react-immutable-proptypes'; +import { FormattedMessage, injectIntl } from 'react-intl'; +import { lruMemoize } from 'reselect'; +import { Badge, Button } from 'react-bootstrap'; + +import ResourceRenderer from '../../helpers/ResourceRenderer'; +import DayOfWeek from '../../widgets/DayOfWeek'; +import { AddUserIcon, GroupIcon, LabsIcon, LectureIcon, LinkIcon, LoadingIcon, VisibleIcon } from '../../icons'; + +import './CoursesGroupsList.css'; +import { TheButtonGroup } from '../../widgets/TheButton'; +import { Link } from 'react-router'; +import { + recodexGroupAssignmentsLink, + recodexGroupEditLink, + recodexGroupStudentsLink, +} from '../../helpers/recodexLinks.js'; + +/** + * Convert time in minutes (from midnight) to string H:MM + * @param {Number} minutes + * @returns {String} + */ +const getTimeStr = minutes => + minutes ? `${Math.floor(minutes / 60)}:${(minutes % 60).toString().padStart(2, '0')}` : ''; + +/** + * Retrieve course name in the given locale (with fallbacks). + * @param {Object} course + * @param {String} locale + * @returns {String} + */ +const getCourseName = ({ course }, locale) => { + const key = `caption_${locale}`; + return course?.[key] || course?.caption_en || course?.caption_cs || '???'; +}; + +/** + * Retrieve full (hierarchical) group name in the given locale (with fallbacks). + * @param {String} groupId + * @param {Object} groups + * @param {String} locale + * @returns {String} + */ +const getGroupName = (groupId, groups, locale) => { + let group = groups[groupId]; + const names = []; + while (group && group.parentGroupId) { + const name = group.name?.[locale] || group.name?.en || group.name?.cs || '???'; + names.unshift(name); + group = groups[group.parentGroupId]; + } + return names.join(' > '); +}; + +/** + * Return augmented courses (with localized fullName) sorted by their fullName. + * @param {Array} courses + * @param {String} locale + * @returns {Array} + */ +const getSortedCourses = lruMemoize((courses, locale) => + courses + .map(course => ({ ...course, fullName: getCourseName(course, locale) })) + .sort((a, b) => a.fullName.localeCompare(b.fullName, locale)) +); + +/** + * 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 + */ +const getSisGroups = lruMemoize((groups, locale) => { + const sisGroups = {}; + Object.values(groups).forEach(group => { + const sisIds = group?.attributes?.group || []; + if (Array.isArray(sisIds) && sisIds.length > 0) { + // we make a copy of the group so we can augment it with localized fullName + const groupCopy = { ...group, fullName: getGroupName(group.id, groups, locale) }; + sisIds.forEach(sisId => { + if (!sisGroups[sisId]) { + sisGroups[sisId] = []; + } + sisGroups[sisId].push(groupCopy); + }); + } + }); + + // sort groups for each sisId by their fullName + Object.values(sisGroups).forEach(groupsForSisId => { + groupsForSisId.sort((a, b) => a.fullName.localeCompare(b.fullName, locale)); + }); + + return sisGroups; +}); + +/* + * Component displaying list of courses (with scheduling events) and their groups. + */ +const CoursesGroupsList = ({ courses, groups, allowHiding = false, joinGroup = null, intl: { locale } }) => { + const [showAllState, setShowAll] = useState(false); + const showAll = allowHiding ? showAllState : true; + + return ( + + {groups => { + const sisGroups = getSisGroups(groups || {}, locale); + + return ( + + {courses => { + if (!courses || courses.length === 0) { + return ( +
+ +
+ ); + } + + const sortedCourses = getSortedCourses(courses, locale); + const filteredCourses = sortedCourses.filter(course => sisGroups[course.sisId]?.length > 0); + + return ( + <> + + {(showAll ? sortedCourses : filteredCourses).map(course => ( + + + + + + + + + + + + {sisGroups[course.sisId]?.map(group => ( + + + + + + ))} + + ))} +
+ {course.type === 'lecture' && ( + + } + tooltipId={course.id} + tooltipPlacement="bottom" + /> + )} + {course.type === 'labs' && ( + } + tooltipId={course.id} + tooltipPlacement="bottom" + /> + )} + + + {getTimeStr(course.time)} + {course.fortnight && ( + <> + ( + {course.firstWeek % 2 === 1 ? ( + + ) : ( + + )} + ) + + )} + {course.room}{course.fullName} + {course.sisId} + +
+ + } + tooltipId={group.id} + tooltipPlacement="bottom" + /> + + {group.fullName} + + {group.membership === 'joining' ? ( + + ) : ( + + {joinGroup && group.membership === null && ( + + )} + + {recodexGroupAssignmentsLink(group.id) && group.membership === 'student' && ( + + + + )} + + {recodexGroupStudentsLink(group.id) && + group.membership && + group.membership !== 'student' && ( + + + + )} + + {recodexGroupEditLink(group.id) && group.membership === 'admin' && ( + + + + )} + + )} +
+ + {allowHiding && filteredCourses.length < sortedCourses.length && ( +
+ + + +
+ )} + + ); + }} +
+ ); + }} +
+ ); +}; + +CoursesGroupsList.propTypes = { + courses: ImmutablePropTypes.list, + groups: ImmutablePropTypes.map, + allowHiding: PropTypes.bool, + joinGroup: 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/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/icons/index.js b/src/components/icons/index.js index c23f897..ed88b47 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 }) => ( ); @@ -161,12 +161,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/widgets/DayOfWeek/DayOfWeek.js b/src/components/widgets/DayOfWeek/DayOfWeek.js new file mode 100644 index 0000000..dc24745 --- /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/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 a4a3fb8..22781b7 100644 --- a/src/locales/cs.json +++ b/src/locales/cs.json @@ -42,6 +42,26 @@ "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.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.recodexGroupAssignments": "Zadané úlohy", + "app.coursesGroupsList.recodexGroupEdit": "Upravit skupinu", + "app.coursesGroupsList.recodexGroupStudents": "Studenti skupiny", + "app.coursesGroupsList.showAllCourses": "Zobrazit všechny lístky", + "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.", @@ -52,9 +72,11 @@ "app.groups.joinGroupButton": "Stát se členem", "app.groups.leaveGroupButton": "Opustit skupinu", "app.groups.removeFromGroup": "Odebrat ze skupiny", + "app.groupsStudent.coursesRefetched": "Seznam rozvrhových lístků byl právě znovu načten ze SIS", + "app.groupsStudent.lastRefreshInfo": "Seznam zapsaných rozvrhových lístků byl naposledy stažen ze SIS", "app.groupsStudent.noActiveTerms": "V tuto chvíli nejsou studentům dostupné žádné semestry.", - "app.groupsStudent.noCourses": "V tuto chvíli nejsou pro tento semestr dostupné žádné rozvrhové lístky.", "app.groupsStudent.notStudent": "Tato stránka je dostupná pouze studentům.", + "app.groupsStudent.refreshButton": "Znovu načíst ze SIS", "app.groupsStudent.term.summer": "Letní semestr", "app.groupsStudent.term.winter": "Zimní semestr", "app.groupsStudent.title": "Připojení ke skupinám jako student", diff --git a/src/locales/en.json b/src/locales/en.json index 0071e90..3bdad75 100644 --- a/src/locales/en.json +++ b/src/locales/en.json @@ -42,6 +42,26 @@ "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.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.recodexGroupAssignments": "Group Assignments", + "app.coursesGroupsList.recodexGroupEdit": "Edit Group", + "app.coursesGroupsList.recodexGroupStudents": "Group Students", + "app.coursesGroupsList.showAllCourses": "Show All Courses", + "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.", @@ -52,9 +72,11 @@ "app.groups.joinGroupButton": "Join group", "app.groups.leaveGroupButton": "Leave group", "app.groups.removeFromGroup": "Remove from group", + "app.groupsStudent.coursesRefetched": "The courses were just re-downloaded from SIS.", + "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.noCourses": "There are currently no courses available for this term.", "app.groupsStudent.notStudent": "This page is available only to students.", + "app.groupsStudent.refreshButton": "Reload from SIS", "app.groupsStudent.term.summer": "Summer Term", "app.groupsStudent.term.winter": "Winter Term", "app.groupsStudent.title": "Joining Groups as Student", diff --git a/src/pages/GroupsStudent/GroupsStudent.js b/src/pages/GroupsStudent/GroupsStudent.js index 731a2e4..cc7ebb4 100644 --- a/src/pages/GroupsStudent/GroupsStudent.js +++ b/src/pages/GroupsStudent/GroupsStudent.js @@ -2,25 +2,35 @@ import React, { Component } from 'react'; import PropTypes from 'prop-types'; import ImmutablePropTypes from 'react-immutable-proptypes'; import { connect } from 'react-redux'; -import { FormattedMessage, injectIntl } from 'react-intl'; +import { FormattedMessage } from 'react-intl'; import { lruMemoize } from 'reselect'; import Page from '../../components/layout/Page'; import Box from '../../components/widgets/Box'; -// import Button, { TheButtonGroup } from '../../components/widgets/TheButton'; -import { JoinGroupIcon, LabsIcon, LectureIcon, TermIcon } from '../../components/icons'; +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 { fetchUserIfNeeded } from '../../redux/modules/users.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 { studentCoursesSelector } from '../../redux/selectors/courses.js'; +import { + studentCoursesSelector, + getStudentCoursesRefetchedSelector, + allStudentCoursesReadySelector, +} 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 @@ -31,9 +41,16 @@ const getSortedTerms = lruMemoize(terms => { .sort((b, a) => a.year * 10 + a.term - (b.year * 10 + b.term)); }); -const getCourseName = ({ course }, locale) => { - const key = `caption_${locale}`; - return course?.[key] || course?.caption_en || course?.caption_cs || '???'; +const getAllSisIds = results => { + const sisIds = new Set(); + results.forEach(({ value }) => { + value?.student?.forEach(course => { + if (course?.sisId) { + sisIds.add(course.sisId); + } + }); + }); + return Array.from(sisIds); }; class GroupsStudent extends Component { @@ -47,19 +64,24 @@ class GroupsStudent extends Component { } } - static loadAsync = ({ userId }, dispatch) => + static loadAsync = ({ userId }, dispatch, expiration = DEFAULT_EXPIRATION) => Promise.all([ - dispatch(fetchUserIfNeeded(userId)), + 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, DEFAULT_EXPIRATION)).catch( + 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 })), + ]) ); }), ]); @@ -69,8 +91,13 @@ class GroupsStudent extends Component { loggedInUser, terms, coursesSelector, - intl: { locale }, + coursesRefetchedSelector, + allStudentCoursesReady, + groups, + joinGroup, + loadAsync, } = this.props; + const userReady = isReady(loggedInUser); return ( isStudentRole(user.role) ? ( - {terms => - getSortedTerms(terms).length > 0 ? ( - getSortedTerms(terms).map((term, idx) => ( - - - - {term.year}-{term.term} - {' '} - - ( - {term.term === 1 && ( - - )} - {term.term === 2 && ( - - )} - ) - - - } - collapsable - isOpen={idx === 0}> - - {courses => - courses && courses.length > 0 ? ( - - {courses.map(course => ( - - - - - - - - - - - ))} -
- {course.type === 'lecture' && } - {course.type === 'labs' && } - {course.dayOfWeek}{course.time}{course.room}{course.sisId}{getCourseName(course, locale)}
- ) : ( -
- -
- ) - } -
-
- )) - ) : ( - + {terms => ( + <> + + +    + + + + . - ) - } + + {getSortedTerms(terms).length > 0 ? ( + getSortedTerms(terms).map((term, idx) => ( + + + + {term.year}-{term.term} + {' '} + + ( + {term.term === 1 && ( + + )} + {term.term === 2 && ( + + )} + ) + + {coursesRefetchedSelector(term.year, term.term) && ( + + } + tooltipId={`fresh-${term.year}-${term.term}`} + tooltipPlacement="bottom" + /> + )} + + } + unlimitedHeight + collapsable + isOpen={idx === 0}> + + + )) + ) : ( + + + + )} + + )}
) : ( @@ -166,8 +207,11 @@ GroupsStudent.propTypes = { loggedInUser: ImmutablePropTypes.map, terms: ImmutablePropTypes.list, coursesSelector: PropTypes.func, + coursesRefetchedSelector: PropTypes.func, + allStudentCoursesReady: PropTypes.bool, + groups: ImmutablePropTypes.map, loadAsync: PropTypes.func.isRequired, - intl: PropTypes.shape({ locale: PropTypes.string.isRequired }).isRequired, + joinGroup: PropTypes.func.isRequired, }; export default connect( @@ -176,8 +220,13 @@ export default connect( loggedInUser: loggedInUserSelector(state), terms: termsSelector(state), coursesSelector: studentCoursesSelector(state), + coursesRefetchedSelector: getStudentCoursesRefetchedSelector(state), + allStudentCoursesReady: allStudentCoursesReadySelector(state), + groups: getGroups(state), }), dispatch => ({ - loadAsync: userId => GroupsStudent.loadAsync({ userId }, dispatch), + loadAsync: (userId, expiration = DEFAULT_EXPIRATION) => GroupsStudent.loadAsync({ userId }, dispatch, expiration), + joinGroup: (groupId, reloadGroupsOFCourses) => + dispatch(joinGroup(groupId)).then(() => dispatch(fetchStudentGroups(reloadGroupsOFCourses))), }) -)(injectIntl(GroupsStudent)); +)(GroupsStudent); diff --git a/src/redux/modules/groups.js b/src/redux/modules/groups.js new file mode 100644 index 0000000..7da5254 --- /dev/null +++ b/src/redux/modules/groups.js @@ -0,0 +1,61 @@ +import { handleActions } from 'redux-actions'; + +import { createRecord, resourceStatus, createActionsWithPostfixes } from '../helpers/resourceManager'; +import { createApiAction } from '../middleware/apiMiddleware.js'; + +export const additionalActionTypes = { + ...createActionsWithPostfixes('FETCH', 'siscodex/groups'), + ...createActionsWithPostfixes('JOIN', 'siscodex/groups'), +}; + +/** + * Actions + */ + +const _fetchGroups = (affiliation, eventIds = undefined, courseIds = undefined) => + createApiAction({ + type: additionalActionTypes.FETCH, + endpoint: `/groups/${affiliation}`, + method: 'GET', + meta: { affiliation }, + query: { eventIds, courseIds }, + }); + +export const fetchStudentGroups = eventIds => _fetchGroups('student', eventIds); + +export const fetchTeacherGroups = (eventIds, courseIds) => _fetchGroups('teacher', eventIds, courseIds); + +export const joinGroup = groupId => + createApiAction({ + type: additionalActionTypes.JOIN, + endpoint: `/groups/${groupId}/join`, + method: 'POST', + meta: { groupId }, + }); + +/** + * Reducer + */ +const reducer = handleActions( + { + [additionalActionTypes.FETCH_PENDING]: state => state.set('state', resourceStatus.RELOADING), + + [additionalActionTypes.FETCH_FULFILLED]: (state, { payload }) => + createRecord({ state: resourceStatus.FULFILLED, data: payload }), + + [additionalActionTypes.FETCH_REJECTED]: (state, { payload }) => + state.set('state', resourceStatus.FAILED).set('error', payload), + + [additionalActionTypes.JOIN_PENDING]: (state, { meta: { groupId } }) => + state.setIn(['data', groupId, 'membership'], 'joining'), + + [additionalActionTypes.JOIN_FULFILLED]: (state, { meta: { groupId } }) => + state.setIn(['data', groupId, 'membership'], 'student'), + + [additionalActionTypes.JOIN_REJECTED]: (state, { meta: { groupId }, payload }) => + state.setIn(['data', groupId, 'membership'], null).set('error', payload), + }, + createRecord() +); + +export default reducer; diff --git a/src/redux/reducer.js b/src/redux/reducer.js index 07ae141..0a7004b 100644 --- a/src/redux/reducer.js +++ b/src/redux/reducer.js @@ -3,6 +3,7 @@ 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'; @@ -12,6 +13,7 @@ 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 index f3c63fe..0bc5603 100644 --- a/src/redux/selectors/courses.js +++ b/src/redux/selectors/courses.js @@ -1,4 +1,5 @@ import { createSelector } from 'reselect'; +import { isReady } from '../helpers/resourceManager'; const getCourses = state => state.courses; const getStudent = courses => courses.get('student'); @@ -18,6 +19,10 @@ export const getStudentCoursesRefetchedSelector = createSelector( courses => (year, term) => courses.getIn([`${year}-${term}`, 'refetched'], false) ); +export const allStudentCoursesReadySelector = createSelector(studentsSelector, courses => + courses.every(record => isReady(record)) +); + export const teacherCoursesSelector = createSelector( teachersSelector, courses => (year, term) => courses.get(`${year}-${term}`) 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; From 00e661ebd7468185d713a64f6c3fa5cc47512682 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Martin=20Kruli=C5=A1?= Date: Sun, 14 Sep 2025 22:31:59 +0200 Subject: [PATCH 04/15] Page that presents courses and their groups for teachers was created and proper course/group loading was established. --- .../CoursesGroupsList/CoursesGroupsList.js | 59 +++-- src/components/icons/index.js | 1 + src/components/layout/Sidebar/Sidebar.js | 12 +- src/components/widgets/DayOfWeek/DayOfWeek.js | 2 +- src/locales/cs.json | 19 +- src/locales/en.json | 17 +- src/pages/GroupsStudent/GroupsStudent.js | 14 +- src/pages/GroupsTeacher/GroupsTeacher.js | 230 ++++++++++++++++++ src/pages/GroupsTeacher/index.js | 2 + src/pages/Home/Home.js | 52 ++-- src/pages/routes.js | 2 + src/redux/modules/courses.js | 14 +- src/redux/selectors/courses.js | 9 + 13 files changed, 364 insertions(+), 69 deletions(-) create mode 100644 src/pages/GroupsTeacher/GroupsTeacher.js create mode 100644 src/pages/GroupsTeacher/index.js diff --git a/src/components/Groups/CoursesGroupsList/CoursesGroupsList.js b/src/components/Groups/CoursesGroupsList/CoursesGroupsList.js index 26f9cbd..5583595 100644 --- a/src/components/Groups/CoursesGroupsList/CoursesGroupsList.js +++ b/src/components/Groups/CoursesGroupsList/CoursesGroupsList.js @@ -152,29 +152,44 @@ const CoursesGroupsList = ({ courses, groups, allowHiding = false, joinGroup = n /> )} - - - - {getTimeStr(course.time)} - - {course.fortnight && ( - <> - ( - {course.firstWeek % 2 === 1 ? ( - - ) : ( - + + {course.dayOfWeek === null && course.time === null ? ( + + ( + + ) + + ) : ( + <> + + + + {getTimeStr(course.time)} + + {course.fortnight && ( + <> + ( + {course.firstWeek % 2 === 1 ? ( + + ) : ( + + )} + ) + )} - ) - - )} - + + + )} + {course.room} {course.fullName} diff --git a/src/components/icons/index.js b/src/components/icons/index.js index ed88b47..d2578f1 100644 --- a/src/components/icons/index.js +++ b/src/components/icons/index.js @@ -63,6 +63,7 @@ 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 => ; diff --git a/src/components/layout/Sidebar/Sidebar.js b/src/components/layout/Sidebar/Sidebar.js index 033ef39..dbb9201 100644 --- a/src/components/layout/Sidebar/Sidebar.js +++ b/src/components/layout/Sidebar/Sidebar.js @@ -25,7 +25,7 @@ const Sidebar = ({ pendingFetchOperations, loggedInUser, currentUrl, - links: { HOME_URI, USER_URI, TERMS_URI, GROUPS_STUDENT_URI }, + links: { HOME_URI, USER_URI, TERMS_URI, GROUPS_STUDENT_URI, GROUPS_TEACHER_URI }, }) => { const user = getUserData(loggedInUser); @@ -82,7 +82,15 @@ const Sidebar = ({ link={GROUPS_STUDENT_URI} /> )} - {isSupervisorRole(user.role) && <>} + + {isSupervisorRole(user.role) && ( + } + icon="users-viewfinder" + currentPath={currentUrl} + link={GROUPS_TEACHER_URI} + /> + )} {isSuperadminRole(user.role) && <>} , }; -const DayOfWeek = ({ dow }) => <>{DAYS[dow] || '???'}; +const DayOfWeek = ({ dow }) => <>{DAYS[dow] || ''}; DayOfWeek.propTypes = { dow: PropTypes.number.isRequired, diff --git a/src/locales/cs.json b/src/locales/cs.json index 22781b7..02197fa 100644 --- a/src/locales/cs.json +++ b/src/locales/cs.json @@ -51,6 +51,7 @@ "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", @@ -69,30 +70,33 @@ "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.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.groupsStudent.coursesRefetched": "Seznam rozvrhových lístků byl právě znovu načten ze SIS", - "app.groupsStudent.lastRefreshInfo": "Seznam zapsaných rozvrhových lístků byl naposledy stažen ze SIS", + "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.refreshButton": "Znovu načíst ze SIS", - "app.groupsStudent.term.summer": "Letní semestr", - "app.groupsStudent.term.winter": "Zimní semestr", "app.groupsStudent.title": "Připojení ke skupinám jako student", + "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.title": "Vytvořit skupiny pro předměty ze SISu", "app.header.languageSwitching.translationTitle": "Jazyková verze", "app.headerNotification.copiedToClippboard": "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.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.", @@ -139,6 +143,7 @@ "app.roles.supervisors": "Vedoucí", "app.roles.supervisorsEmpowered": "Zplnomocnění vedoucí", "app.sidebar.menu.groupsStudent": "Připojit ke skupinám", + "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.", diff --git a/src/locales/en.json b/src/locales/en.json index 3bdad75..abc3fbc 100644 --- a/src/locales/en.json +++ b/src/locales/en.json @@ -51,6 +51,7 @@ "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", @@ -69,30 +70,33 @@ "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.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.groupsStudent.coursesRefetched": "The courses were just re-downloaded from SIS.", + "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.refreshButton": "Reload from SIS", - "app.groupsStudent.term.summer": "Summer Term", - "app.groupsStudent.term.winter": "Winter Term", "app.groupsStudent.title": "Joining Groups as Student", + "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.title": "Create Groups for SIS Courses", "app.header.languageSwitching.translationTitle": "Translation", "app.headerNotification.copiedToClippboard": "Copied to clippboard.", "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.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.", @@ -139,6 +143,7 @@ "app.roles.supervisors": "Supervisors", "app.roles.supervisorsEmpowered": "Empowered Supervisors", "app.sidebar.menu.groupsStudent": "Join Groups", + "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.", diff --git a/src/pages/GroupsStudent/GroupsStudent.js b/src/pages/GroupsStudent/GroupsStudent.js index cc7ebb4..e5ddb74 100644 --- a/src/pages/GroupsStudent/GroupsStudent.js +++ b/src/pages/GroupsStudent/GroupsStudent.js @@ -44,9 +44,9 @@ const getSortedTerms = lruMemoize(terms => { const getAllSisIds = results => { const sisIds = new Set(); results.forEach(({ value }) => { - value?.student?.forEach(course => { - if (course?.sisId) { - sisIds.add(course.sisId); + value?.student?.forEach(sisEvent => { + if (sisEvent?.sisId) { + sisIds.add(sisEvent.sisId); } }); }); @@ -117,7 +117,7 @@ class GroupsStudent extends Component { disabled={!userReady || !allStudentCoursesReady} onClick={() => loadAsync(user.id, 0)}> {userReady && allStudentCoursesReady ? : } - + ( {term.term === 1 && ( - + )} {term.term === 2 && ( - + )} ) @@ -156,7 +156,7 @@ class GroupsStudent extends Component { className="text-success" tooltip={ } diff --git a/src/pages/GroupsTeacher/GroupsTeacher.js b/src/pages/GroupsTeacher/GroupsTeacher.js new file mode 100644 index 0000000..b94ebe5 --- /dev/null +++ b/src/pages/GroupsTeacher/GroupsTeacher.js @@ -0,0 +1,230 @@ +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 { fetchTeacherCourses } from '../../redux/modules/courses.js'; +import { fetchTeacherGroups } 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 { + teacherCoursesSelector, + getTeacherCoursesRefetchedSelector, + allTeacherCoursesReadySelector, +} 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 { 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)]; +}; + +class GroupsTeacher 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.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, + coursesSelector, + coursesRefetchedSelector, + allTeacherCoursesReady, + 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 && ( + + )} + ) + + {coursesRefetchedSelector(term.year, term.term) && ( + + } + tooltipId={`fresh-${term.year}-${term.term}`} + tooltipPlacement="bottom" + /> + )} + + } + unlimitedHeight + collapsable + isOpen={idx === 0}> + + + )) + ) : ( + + + + )} + + )} + + ) : ( + + + + ) + } + + ); + } +} + +GroupsTeacher.propTypes = { + loggedInUserId: PropTypes.string, + loggedInUser: ImmutablePropTypes.map, + terms: ImmutablePropTypes.list, + coursesSelector: PropTypes.func, + coursesRefetchedSelector: PropTypes.func, + allTeacherCoursesReady: PropTypes.bool, + groups: ImmutablePropTypes.map, + loadAsync: PropTypes.func.isRequired, +}; + +export default connect( + state => ({ + loggedInUserId: loggedInUserIdSelector(state), + loggedInUser: loggedInUserSelector(state), + terms: termsSelector(state), + coursesSelector: teacherCoursesSelector(state), + coursesRefetchedSelector: getTeacherCoursesRefetchedSelector(state), + allTeacherCoursesReady: allTeacherCoursesReadySelector(state), + groups: getGroups(state), + }), + dispatch => ({ + loadAsync: (userId, expiration = DEFAULT_EXPIRATION) => GroupsTeacher.loadAsync({ userId }, dispatch, expiration), + }) +)(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 5701821..f6c64a6 100644 --- a/src/pages/Home/Home.js +++ b/src/pages/Home/Home.js @@ -8,14 +8,15 @@ import { Link } from 'react-router-dom'; import Page from '../../components/layout/Page'; import Icon, { + GroupFocusIcon, HomeIcon, + JoinGroupIcon, LinkIcon, LoadingIcon, ReturnIcon, UserProfileIcon, TermIcon, WarningIcon, - JoinGroupIcon, } from '../../components/icons'; import Callout from '../../components/widgets/Callout'; @@ -27,7 +28,7 @@ import { loggedInUserIdSelector } from '../../redux/selectors/auth.js'; import { getReturnUrl, setReturnUrl } from '../../helpers/localStorage.js'; import { knownLocalesNames } from '../../helpers/localizedData.js'; -import { isStudentRole, 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 { @@ -83,7 +84,7 @@ class Home extends Component { const { loggedInUser, params: { token = null }, - links: { USER_URI, TERMS_URI, GROUPS_STUDENT_URI }, + links: { USER_URI, TERMS_URI, GROUPS_STUDENT_URI, GROUPS_TEACHER_URI }, } = this.props; return ( @@ -176,6 +177,31 @@ class Home extends Component {
)} + {isSupervisorRole(user.role) && ( + + +

+ +

+ + +

+ + + + +

+ +

+ +

+ +
+ )} + {isSuperadminRole(user.role) && ( @@ -201,26 +227,6 @@ class Home extends Component { )} - - -

- -

- - -

- -

- -

- -

- -
-
diff --git a/src/pages/routes.js b/src/pages/routes.js index fe86d1c..8ba3cb8 100644 --- a/src/pages/routes.js +++ b/src/pages/routes.js @@ -4,6 +4,7 @@ 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 Home from './Home'; import Terms from './Terms'; import User from './User'; @@ -71,6 +72,7 @@ const routesDescriptors = [ 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), ]; /* diff --git a/src/redux/modules/courses.js b/src/redux/modules/courses.js index 0eb3acf..8e46d5d 100644 --- a/src/redux/modules/courses.js +++ b/src/redux/modules/courses.js @@ -27,6 +27,18 @@ export const fetchStudentCourses = (year, term, expiration = undefined) => 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 */ @@ -43,7 +55,7 @@ const reducer = handleActions( state .setIn( [affiliation, `${year}-${term}`], - createRecord({ state: resourceStatus.FULFILLED, data: payload[affiliation] }) + createRecord({ state: resourceStatus.FULFILLED, data: getPayload(payload, affiliation) }) ) .setIn([affiliation, `${year}-${term}`, 'refetched'], payload.refetched || false), diff --git a/src/redux/selectors/courses.js b/src/redux/selectors/courses.js index 0bc5603..3196883 100644 --- a/src/redux/selectors/courses.js +++ b/src/redux/selectors/courses.js @@ -27,3 +27,12 @@ export const teacherCoursesSelector = createSelector( teachersSelector, courses => (year, term) => courses.get(`${year}-${term}`) ); + +export const getTeacherCoursesRefetchedSelector = createSelector( + teachersSelector, + courses => (year, term) => courses.getIn([`${year}-${term}`, 'refetched'], false) +); + +export const allTeacherCoursesReadySelector = createSelector(teachersSelector, courses => + courses.every(record => isReady(record)) +); From 43ad8a32fdffa0649e034bf7f4171cb06993f7b4 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Martin=20Kruli=C5=A1?= Date: Sun, 14 Sep 2025 23:07:09 +0200 Subject: [PATCH 05/15] Adding role icon indicator for course groups. --- .../CoursesGroupsList/CoursesGroupsList.js | 76 ++++++++++++++++++- src/locales/cs.json | 4 + src/locales/en.json | 4 + 3 files changed, 81 insertions(+), 3 deletions(-) diff --git a/src/components/Groups/CoursesGroupsList/CoursesGroupsList.js b/src/components/Groups/CoursesGroupsList/CoursesGroupsList.js index 5583595..e9b37e0 100644 --- a/src/components/Groups/CoursesGroupsList/CoursesGroupsList.js +++ b/src/components/Groups/CoursesGroupsList/CoursesGroupsList.js @@ -7,7 +7,19 @@ import { Badge, Button } from 'react-bootstrap'; import ResourceRenderer from '../../helpers/ResourceRenderer'; import DayOfWeek from '../../widgets/DayOfWeek'; -import { AddUserIcon, GroupIcon, LabsIcon, LectureIcon, LinkIcon, LoadingIcon, VisibleIcon } from '../../icons'; +import { + AddUserIcon, + AdminRoleIcon, + GroupIcon, + LabsIcon, + LectureIcon, + LinkIcon, + LoadingIcon, + ObserverIcon, + SupervisorIcon, + StudentsIcon, + VisibleIcon, +} from '../../icons'; import './CoursesGroupsList.css'; import { TheButtonGroup } from '../../widgets/TheButton'; @@ -200,7 +212,65 @@ const CoursesGroupsList = ({ courses, groups, allowHiding = false, joinGroup = n {sisGroups[course.sisId]?.map(group => ( - + + {group.membership === 'admin' && ( + + } + /> + )} + {group.membership === 'supervisor' && ( + + } + /> + )} + {group.membership === 'observer' && ( + + } + /> + )} + {group.membership === 'student' && ( + + } + /> + )} + {group.membership === 'joining' && } + @@ -209,7 +279,7 @@ const CoursesGroupsList = ({ courses, groups, allowHiding = false, joinGroup = n tooltipPlacement="bottom" /> - + {group.fullName} diff --git a/src/locales/cs.json b/src/locales/cs.json index 02197fa..1235bee 100644 --- a/src/locales/cs.json +++ b/src/locales/cs.json @@ -42,6 +42,7 @@ "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.admin": "Jste administrátor této skupiny", "app.coursesGroupsList.firstWeekEven": "sudé týdny", "app.coursesGroupsList.firstWeekOdd": "liché týdny", "app.coursesGroupsList.group": "Skupina z ReCodExu", @@ -52,10 +53,13 @@ "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.observer": "Jste pozorovatel této skupiny", "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.student": "Jste studentem této skupiny", + "app.coursesGroupsList.supervisor": "Jste vedoucím této skupiny", "app.dayOfWeek.friday": "Pá", "app.dayOfWeek.monday": "Po", "app.dayOfWeek.saturday": "So", diff --git a/src/locales/en.json b/src/locales/en.json index abc3fbc..b6d8a8e 100644 --- a/src/locales/en.json +++ b/src/locales/en.json @@ -42,6 +42,7 @@ "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.admin": "You are an administrator of this group", "app.coursesGroupsList.firstWeekEven": "even weeks", "app.coursesGroupsList.firstWeekOdd": "odd weeks", "app.coursesGroupsList.group": "ReCodEx group", @@ -52,10 +53,13 @@ "app.coursesGroupsList.lecture": "Lecture", "app.coursesGroupsList.noCourses": "There are currently no courses available for this term.", "app.coursesGroupsList.notScheduled": "not scheduled", + "app.coursesGroupsList.observer": "You are an observer of this group", "app.coursesGroupsList.recodexGroupAssignments": "Group Assignments", "app.coursesGroupsList.recodexGroupEdit": "Edit Group", "app.coursesGroupsList.recodexGroupStudents": "Group Students", "app.coursesGroupsList.showAllCourses": "Show All Courses", + "app.coursesGroupsList.student": "You are a student of this group", + "app.coursesGroupsList.supervisor": "You are a supervisor of this group", "app.dayOfWeek.friday": "Fri", "app.dayOfWeek.monday": "Mon", "app.dayOfWeek.saturday": "Sat", From ed525dc02fbcbf9a3e9ac633b79e586e7a469004 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Martin=20Kruli=C5=A1?= Date: Mon, 15 Sep 2025 18:06:40 +0200 Subject: [PATCH 06/15] Preparing courses groups list for new features; clarifications (better distinguishing vs courses/SIS-events). --- .../CoursesGroupsList/CoursesGroupsList.js | 282 +++++++++++++----- src/pages/GroupsStudent/GroupsStudent.js | 14 +- src/pages/GroupsTeacher/GroupsTeacher.js | 18 +- src/redux/selectors/courses.js | 14 +- 4 files changed, 229 insertions(+), 99 deletions(-) diff --git a/src/components/Groups/CoursesGroupsList/CoursesGroupsList.js b/src/components/Groups/CoursesGroupsList/CoursesGroupsList.js index e9b37e0..ea49429 100644 --- a/src/components/Groups/CoursesGroupsList/CoursesGroupsList.js +++ b/src/components/Groups/CoursesGroupsList/CoursesGroupsList.js @@ -3,13 +3,15 @@ import PropTypes from 'prop-types'; import ImmutablePropTypes from 'react-immutable-proptypes'; import { FormattedMessage, injectIntl } from 'react-intl'; import { lruMemoize } from 'reselect'; -import { Badge, Button } from 'react-bootstrap'; +import { Badge } from 'react-bootstrap'; import ResourceRenderer from '../../helpers/ResourceRenderer'; import DayOfWeek from '../../widgets/DayOfWeek'; import { + AddIcon, AddUserIcon, AdminRoleIcon, + BindIcon, GroupIcon, LabsIcon, LectureIcon, @@ -18,11 +20,12 @@ import { ObserverIcon, SupervisorIcon, StudentsIcon, + UnbindIcon, VisibleIcon, } from '../../icons'; import './CoursesGroupsList.css'; -import { TheButtonGroup } from '../../widgets/TheButton'; +import Button, { TheButtonGroup } from '../../widgets/TheButton'; import { Link } from 'react-router'; import { recodexGroupAssignmentsLink, @@ -40,7 +43,7 @@ const getTimeStr = minutes => /** * Retrieve course name in the given locale (with fallbacks). - * @param {Object} course + * @param {Object} sisEvent (with course sub-object) * @param {String} locale * @returns {String} */ @@ -50,35 +53,66 @@ const getCourseName = ({ course }, locale) => { }; /** - * Retrieve full (hierarchical) group name in the given locale (with fallbacks). - * @param {String} groupId - * @param {Object} groups + * Return augmented sisEvents (with localized fullName) sorted by their fullName. + * @param {Array} sisEvents * @param {String} locale - * @returns {String} + * @returns {Array} */ -const getGroupName = (groupId, groups, locale) => { - let group = groups[groupId]; - const names = []; - while (group && group.parentGroupId) { - const name = group.name?.[locale] || group.name?.en || group.name?.cs || '???'; - names.unshift(name); - group = groups[group.parentGroupId]; +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); + + // use the parent to update our values + group.fullName = groups[group.parentGroupId].fullName || ''; + if (group.fullName) { + group.fullName += ' ❭ '; + } + group.fullName += group.name?.[locale] || group.name?.en || group.name?.cs || '???'; + group.isAdmin = groups[group.parentGroupId].isAdmin || group.membership === 'admin'; + } else { + // root group has special treatment + groups[id].fullName = ''; + groups[id].isAdmin = false; + } } - return names.join(' > '); }; /** - * Return augmented courses (with localized fullName) sorted by their fullName. - * @param {Array} courses + * Preprocessing function that returns a sorted array of all groups + * (augmented with localized fullName and isAdmin flag). + * @param {Object} groups * @param {String} locale * @returns {Array} */ -const getSortedCourses = lruMemoize((courses, locale) => - courses - .map(course => ({ ...course, fullName: getCourseName(course, locale) })) - .sort((a, b) => a.fullName.localeCompare(b.fullName, locale)) -); +const getGroups = lruMemoize((groups, locale) => { + // make a copy of groups so we can augment it + const result = {}; + Object.keys(groups).forEach(id => { + result[id] = { ...groups[id] }; + }); + Object.keys(result).forEach(id => { + if (!result[id].fullName) { + augmentGroupObject(result, id, locale); + } + }); + + return Object.values(result).sort((a, b) => a.fullName.localeCompare(b.fullName, locale)); +}); + +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. @@ -88,32 +122,79 @@ const getSortedCourses = lruMemoize((courses, locale) => */ const getSisGroups = lruMemoize((groups, locale) => { const sisGroups = {}; - Object.values(groups).forEach(group => { - const sisIds = group?.attributes?.group || []; - if (Array.isArray(sisIds) && sisIds.length > 0) { - // we make a copy of the group so we can augment it with localized fullName - const groupCopy = { ...group, fullName: getGroupName(group.id, groups, locale) }; - sisIds.forEach(sisId => { - if (!sisGroups[sisId]) { - sisGroups[sisId] = []; - } - sisGroups[sisId].push(groupCopy); - }); - } + 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; +}); - // sort groups for each sisId by their fullName - Object.values(sisGroups).forEach(groupsForSisId => { - groupsForSisId.sort((a, b) => a.fullName.localeCompare(b.fullName, locale)); - }); +/** + * 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}`; - return sisGroups; + 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; +}; + +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) + */ +const getBindingCandidates = lruMemoize((sisEvent, groups, locale) => + getGroups(groups, locale).filter( + group => + !group.organizational && + (group.isAdmin || group.membership === 'supervisor') && + isSuitableForCourse(sisEvent, group, groups) + ) +); + /* - * Component displaying list of courses (with scheduling events) and their groups. + * Component displaying list of sisEvents (with scheduling events) and their groups. */ -const CoursesGroupsList = ({ courses, groups, allowHiding = false, joinGroup = null, intl: { locale } }) => { +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; @@ -123,9 +204,9 @@ const CoursesGroupsList = ({ courses, groups, allowHiding = false, joinGroup = n const sisGroups = getSisGroups(groups || {}, locale); return ( - - {courses => { - if (!courses || courses.length === 0) { + + {sisEvents => { + if (!sisEvents || sisEvents.length === 0) { return (
sisGroups[course.sisId]?.length > 0); + const sortedSisEvents = getSortedSisEvents(sisEvents, locale); + const filteredSisEvents = sortedSisEvents.filter(sisEvent => sisGroups[sisEvent.sisId]?.length > 0); return ( <> - {(showAll ? sortedCourses : filteredCourses).map(course => ( - + className={`coursesGroupsList ${allowHiding && filteredSisEvents.length < sortedSisEvents.length ? 'mb-3' : ''}`}> + {(showAll ? sortedSisEvents : filteredSisEvents).map(sisEvent => ( + - {course.dayOfWeek === null && course.time === null ? ( + {sisEvent.dayOfWeek === null && sisEvent.time === null ? ( - + - + + + - - {sisGroups[course.sisId]?.map(group => ( + {sisGroups[sisEvent.sisId]?.map(group => (
- {course.type === 'lecture' && ( + {sisEvent.type === 'lecture' && ( } - tooltipId={course.id} + tooltipId={sisEvent.id} tooltipPlacement="bottom" /> )} - {course.type === 'labs' && ( + {sisEvent.type === 'labs' && ( } - tooltipId={course.id} + tooltipId={sisEvent.id} tooltipPlacement="bottom" /> )} ( - + {getTimeStr(course.time)}{getTimeStr(sisEvent.time)} - {course.fortnight && ( + {sisEvent.fortnight && ( <> ( - {course.firstWeek % 2 === 1 ? ( + {sisEvent.firstWeek % 2 === 1 ? ( )} - {course.room}{course.fullName}{sisEvent.room}{sisEvent.fullName} - {course.sisId} + {sisEvent.sisId} + + {create && ( + + )} + {bind && ( + + )}
{group.membership === 'admin' && ( @@ -323,16 +427,39 @@ const CoursesGroupsList = ({ courses, groups, allowHiding = false, joinGroup = n )} - {recodexGroupEditLink(group.id) && group.membership === 'admin' && ( - - - + {(group.isAdmin || group.membership === 'supervisor') && ( + <> + {recodexGroupEditLink(group.id) && ( + + + + )} + {unbind && ( + + )} + )} )} @@ -343,12 +470,12 @@ const CoursesGroupsList = ({ courses, groups, allowHiding = false, joinGroup = n ))}
- {allowHiding && filteredCourses.length < sortedCourses.length && ( + {allowHiding && filteredSisEvents.length < sortedSisEvents.length && (
- )} - {bind && ( - - )} + + + {create && ( + + )} + {bind && ( + + )} + @@ -373,7 +377,7 @@ const CoursesGroupsList = ({ } /> )} - {group.membership === 'joining' && } + {group.pending && } - {group.membership === 'joining' ? ( + {group.pending ? ( ) : ( @@ -444,8 +448,8 @@ const CoursesGroupsList = ({ - {coursesRefetchedSelector(term.year, term.term) && ( + {refetchedSelector(term.year, term.term) && ( ({ diff --git a/src/pages/GroupsTeacher/GroupsTeacher.js b/src/pages/GroupsTeacher/GroupsTeacher.js index 558cd34..94f4049 100644 --- a/src/pages/GroupsTeacher/GroupsTeacher.js +++ b/src/pages/GroupsTeacher/GroupsTeacher.js @@ -2,18 +2,27 @@ import React, { Component } from 'react'; import PropTypes from 'prop-types'; import ImmutablePropTypes from 'react-immutable-proptypes'; import { connect } from 'react-redux'; +import { Modal } 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 from '../../components/widgets/TheButton'; -import { DownloadIcon, GroupFocusIcon, LoadingIcon, RefreshIcon, TermIcon } from '../../components/icons'; +import Button, { TheButtonGroup } from '../../components/widgets/TheButton'; +import { + CloseIcon, + DownloadIcon, + GroupFocusIcon, + LoadingIcon, + RefreshIcon, + SuccessIcon, + TermIcon, +} from '../../components/icons'; import ResourceRenderer from '../../components/helpers/ResourceRenderer'; import { fetchTeacherCourses } from '../../redux/modules/courses.js'; -import { fetchTeacherGroups } from '../../redux/modules/groups.js'; +import { fetchTeacherGroups, bindGroup, unbindGroup } 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'; @@ -57,7 +66,38 @@ const getAllSisIds = results => { 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, selectGroups: null, selectedGroupId: '' }; + + startBind = (bindEvent, selectGroups) => this.setState({ bindEvent, selectGroups }); + startCreate = (createEvent, selectGroups) => this.setState({ createEvent, selectGroups }); + closeModal = () => this.setState({ bindEvent: null, createEvent: null, selectGroups: null, selectedGroupId: '' }); + handleGroupChange = ev => this.setState({ selectedGroupId: ev.target.value }); + + completeModalOperation = () => { + const { loggedInUserId, bind, loadAsync } = this.props; + + if (this.state.bindEvent !== null) { + bind(this.state.selectedGroupId, this.state.bindEvent.id) + .then(() => loadAsync(loggedInUserId)) + .then(this.closeModal); + } else { + this.closeModal(); + } + }; + + unbindAndReload = (groupId, eventId) => { + const { loggedInUserId, unbind, loadAsync } = this.props; + unbind(groupId, eventId).then(() => loadAsync(loggedInUserId)); + }; + componentDidMount() { this.props.loadAsync(this.props.loggedInUserId); } @@ -94,15 +134,7 @@ class GroupsTeacher extends Component { ]); render() { - const { - loggedInUser, - terms, - coursesSelector, - coursesRefetchedSelector, - allTeacherCoursesReady, - groups, - loadAsync, - } = this.props; + const { loggedInUser, terms, sisEventsSelector, refetchedSelector, allReady, groups, loadAsync } = this.props; const userReady = isReady(loggedInUser); return ( @@ -120,9 +152,9 @@ class GroupsTeacher extends Component { variant="primary" size="sm" className="float-end" - disabled={!userReady || !allTeacherCoursesReady} + disabled={!userReady || !allReady} onClick={() => loadAsync(user.id, 0)}> - {userReady && allTeacherCoursesReady ? : } + {userReady && allReady ? : } - {coursesRefetchedSelector(term.year, term.term) && ( + {refetchedSelector(term.year, term.term) && ( - + )) ) : ( @@ -186,6 +224,71 @@ class GroupsTeacher extends Component { /> )} + + + + + {this.state.bindEvent !== null && ( + + )} + {this.state.createEvent !== null && ( + + )} + + + + + + + + +
+ + + + +
+
+
)} @@ -207,11 +310,13 @@ GroupsTeacher.propTypes = { loggedInUserId: PropTypes.string, loggedInUser: ImmutablePropTypes.map, terms: ImmutablePropTypes.list, - coursesSelector: PropTypes.func, - coursesRefetchedSelector: PropTypes.func, - allTeacherCoursesReady: PropTypes.bool, + sisEventsSelector: PropTypes.func, + refetchedSelector: PropTypes.func, + allReady: PropTypes.bool, groups: ImmutablePropTypes.map, loadAsync: PropTypes.func.isRequired, + bind: PropTypes.func.isRequired, + unbind: PropTypes.func.isRequired, }; export default connect( @@ -219,12 +324,14 @@ export default connect( loggedInUserId: loggedInUserIdSelector(state), loggedInUser: loggedInUserSelector(state), terms: termsSelector(state), - coursesSelector: teacherSisEventsSelector(state), - coursesRefetchedSelector: getTeacherSisEventsRefetchedSelector(state), - allTeacherCoursesReady: allTeacherSisEventsReadySelector(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, eventId) => dispatch(bindGroup(groupId, eventId)), + unbind: (groupId, eventId) => dispatch(unbindGroup(groupId, eventId)), }) )(GroupsTeacher); diff --git a/src/redux/modules/groups.js b/src/redux/modules/groups.js index 7da5254..01ad3fe 100644 --- a/src/redux/modules/groups.js +++ b/src/redux/modules/groups.js @@ -5,6 +5,8 @@ import { createApiAction } from '../middleware/apiMiddleware.js'; export const additionalActionTypes = { ...createActionsWithPostfixes('FETCH', 'siscodex/groups'), + ...createActionsWithPostfixes('BIND', 'siscodex/groups'), + ...createActionsWithPostfixes('UNBIND', 'siscodex/groups'), ...createActionsWithPostfixes('JOIN', 'siscodex/groups'), }; @@ -25,6 +27,22 @@ export const fetchStudentGroups = eventIds => _fetchGroups('student', eventIds); export const fetchTeacherGroups = (eventIds, courseIds) => _fetchGroups('teacher', eventIds, courseIds); +export const bindGroup = (groupId, eventId) => + createApiAction({ + type: additionalActionTypes.BIND, + endpoint: `/groups/${groupId}/bind/${eventId}`, + method: 'POST', + meta: { groupId, eventId }, + }); + +export const unbindGroup = (groupId, eventId) => + createApiAction({ + type: additionalActionTypes.UNBIND, + endpoint: `/groups/${groupId}/bind/${eventId}`, + method: 'DELETE', + meta: { groupId, eventId }, + }); + export const joinGroup = groupId => createApiAction({ type: additionalActionTypes.JOIN, @@ -46,14 +64,41 @@ const reducer = handleActions( [additionalActionTypes.FETCH_REJECTED]: (state, { payload }) => state.set('state', resourceStatus.FAILED).set('error', payload), + // bindings + [additionalActionTypes.BIND_PENDING]: (state, { meta: { groupId, eventId } }) => + state + .updateIn(['data', groupId, 'attributes', 'group'], groups => groups.push(eventId)) + .setIn(['data', groupId, 'pending'], 'binding'), + + [additionalActionTypes.BIND_FULFILLED]: (state, { meta: { groupId } }) => + state.removeIn(['data', groupId, 'pending']), + + [additionalActionTypes.BIND_REJECTED]: (state, { meta: { groupId, eventId }, payload }) => + state + .updateIn(['data', groupId, 'attributes', 'group'], groups => groups.filter(id => id !== eventId)) + .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, eventId } }) => + state + .updateIn(['data', groupId, 'attributes', 'group'], groups => groups.filter(id => id !== eventId)) + .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'], 'joining'), + state.setIn(['data', groupId, 'membership'], 'student').setIn(['data', groupId, 'pending'], 'joining'), [additionalActionTypes.JOIN_FULFILLED]: (state, { meta: { groupId } }) => - state.setIn(['data', groupId, 'membership'], 'student'), + state.removeIn(['data', groupId, 'pending']), [additionalActionTypes.JOIN_REJECTED]: (state, { meta: { groupId }, payload }) => - state.setIn(['data', groupId, 'membership'], null).set('error', payload), + state.setIn(['data', groupId, 'membership'], null).removeIn(['data', groupId, 'pending']).set('error', payload), }, createRecord() ); From 257e5b3468ed9a8b97a8229a699c971c770f80e2 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Martin=20Kruli=C5=A1?= Date: Wed, 17 Sep 2025 23:57:56 +0200 Subject: [PATCH 08/15] Improving group selection modal. --- .../CoursesGroupsList/CoursesGroupsList.js | 17 +- src/components/helpers/stringFormatters.js | 8 + src/components/helpers/usersRoles.js | 6 +- .../HeaderNotification/HeaderNotification.js | 4 +- src/containers/App/siscodex.css | 4 + src/locales/cs.json | 16 +- src/locales/en.json | 22 ++- src/pages/GroupsTeacher/GroupsTeacher.js | 151 ++++++++++++++++-- 8 files changed, 188 insertions(+), 40 deletions(-) diff --git a/src/components/Groups/CoursesGroupsList/CoursesGroupsList.js b/src/components/Groups/CoursesGroupsList/CoursesGroupsList.js index 5735d51..e0573a8 100644 --- a/src/components/Groups/CoursesGroupsList/CoursesGroupsList.js +++ b/src/components/Groups/CoursesGroupsList/CoursesGroupsList.js @@ -32,14 +32,7 @@ import { recodexGroupEditLink, recodexGroupStudentsLink, } from '../../helpers/recodexLinks.js'; - -/** - * Convert time in minutes (from midnight) to string H:MM - * @param {Number} minutes - * @returns {String} - */ -const getTimeStr = minutes => - minutes ? `${Math.floor(minutes / 60)}:${(minutes % 60).toString().padStart(2, '0')}` : ''; +import { minutesToTimeStr } from '../../helpers/stringFormatters.js'; /** * Retrieve course name in the given locale (with fallbacks). @@ -162,9 +155,9 @@ const isSuitableForCourse = (sisEvent, group, groups) => { return courseCovered && termCovered; }; -const getParentCandidates = lruMemoize((sisEvent, groups, locale) => { - getGroups(groups, locale).filter(group => isSuitableForCourse(sisEvent, group, groups)); -}); +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. @@ -260,7 +253,7 @@ const CoursesGroupsList = ({ - {getTimeStr(sisEvent.time)} + {minutesToTimeStr(sisEvent.time)} {sisEvent.fortnight && ( <> 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/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/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/locales/cs.json b/src/locales/cs.json index 8c69dde..2c33322 100644 --- a/src/locales/cs.json +++ b/src/locales/cs.json @@ -89,16 +89,26 @@ "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.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.selectGroupForBinding": "Vyberte skupinu pro svázání s {bindEvent}", - "app.groupsTeacher.selectParentGroupForCreating": "Vyberte nadřazenou skupinu pro novou skupinu {createEvent}", + "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.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.", diff --git a/src/locales/en.json b/src/locales/en.json index f63276f..97f6a3a 100644 --- a/src/locales/en.json +++ b/src/locales/en.json @@ -89,16 +89,26 @@ "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.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.selectGroupForBinding": "Select Group for Binding with {bindEvent}", - "app.groupsTeacher.selectParentGroupForCreating": "Select Parent Group for New Group of {createEvent}", + "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.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.", @@ -139,11 +149,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", diff --git a/src/pages/GroupsTeacher/GroupsTeacher.js b/src/pages/GroupsTeacher/GroupsTeacher.js index 94f4049..1e5fed1 100644 --- a/src/pages/GroupsTeacher/GroupsTeacher.js +++ b/src/pages/GroupsTeacher/GroupsTeacher.js @@ -2,7 +2,7 @@ import React, { Component } from 'react'; import PropTypes from 'prop-types'; import ImmutablePropTypes from 'react-immutable-proptypes'; import { connect } from 'react-redux'; -import { Modal } from 'react-bootstrap'; +import { Modal, FormGroup, FormSelect, FormLabel, InputGroup, Badge, Table } from 'react-bootstrap'; import { FormattedMessage } from 'react-intl'; import { lruMemoize } from 'reselect'; @@ -14,6 +14,7 @@ import { CloseIcon, DownloadIcon, GroupFocusIcon, + InfoIcon, LoadingIcon, RefreshIcon, SuccessIcon, @@ -38,6 +39,10 @@ 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'; @@ -235,30 +240,148 @@ class GroupsTeacher extends Component { {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 => ( + + ))} + + + + + +

+ +

+
From 093b051c7ec8df058c5488ff4c9e2bb1321c3d69 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Martin=20Kruli=C5=A1?= Date: Fri, 19 Sep 2025 20:51:31 +0200 Subject: [PATCH 09/15] Completing the groups-teacher operations, implementing create group call. --- .../CoursesGroupsList/CoursesGroupsList.js | 2 +- src/locales/cs.json | 1 + src/locales/en.json | 3 +- src/pages/GroupsTeacher/GroupsTeacher.js | 83 +++++++++++++++---- src/redux/modules/groups.js | 38 ++++++--- 5 files changed, 97 insertions(+), 30 deletions(-) diff --git a/src/components/Groups/CoursesGroupsList/CoursesGroupsList.js b/src/components/Groups/CoursesGroupsList/CoursesGroupsList.js index e0573a8..c6744ea 100644 --- a/src/components/Groups/CoursesGroupsList/CoursesGroupsList.js +++ b/src/components/Groups/CoursesGroupsList/CoursesGroupsList.js @@ -441,7 +441,7 @@ const CoursesGroupsList = ({ - @@ -440,6 +491,7 @@ GroupsTeacher.propTypes = { loadAsync: PropTypes.func.isRequired, bind: PropTypes.func.isRequired, unbind: PropTypes.func.isRequired, + create: PropTypes.func.isRequired, }; export default connect( @@ -454,7 +506,8 @@ export default connect( }), dispatch => ({ loadAsync: (userId, expiration = DEFAULT_EXPIRATION) => GroupsTeacher.loadAsync({ userId }, dispatch, expiration), - bind: (groupId, eventId) => dispatch(bindGroup(groupId, eventId)), - unbind: (groupId, eventId) => dispatch(unbindGroup(groupId, eventId)), + 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/redux/modules/groups.js b/src/redux/modules/groups.js index 01ad3fe..a46f33b 100644 --- a/src/redux/modules/groups.js +++ b/src/redux/modules/groups.js @@ -2,11 +2,13 @@ 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'), }; @@ -27,20 +29,28 @@ export const fetchStudentGroups = eventIds => _fetchGroups('student', eventIds); export const fetchTeacherGroups = (eventIds, courseIds) => _fetchGroups('teacher', eventIds, courseIds); -export const bindGroup = (groupId, eventId) => +export const bindGroup = (groupId, event) => createApiAction({ type: additionalActionTypes.BIND, - endpoint: `/groups/${groupId}/bind/${eventId}`, + endpoint: `/groups/${groupId}/bind/${event.id}`, method: 'POST', - meta: { groupId, eventId }, + meta: { groupId, eventId: event.id, eventSisId: event.sisId }, }); -export const unbindGroup = (groupId, eventId) => +export const unbindGroup = (groupId, event) => createApiAction({ type: additionalActionTypes.UNBIND, - endpoint: `/groups/${groupId}/bind/${eventId}`, + endpoint: `/groups/${groupId}/bind/${event.id}`, method: 'DELETE', - meta: { groupId, eventId }, + 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 => @@ -64,27 +74,29 @@ const reducer = handleActions( [additionalActionTypes.FETCH_REJECTED]: (state, { payload }) => state.set('state', resourceStatus.FAILED).set('error', payload), - // bindings - [additionalActionTypes.BIND_PENDING]: (state, { meta: { groupId, eventId } }) => + // group management + [additionalActionTypes.BIND_PENDING]: (state, { meta: { groupId, eventSisId } }) => state - .updateIn(['data', groupId, 'attributes', 'group'], groups => groups.push(eventId)) + .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, eventId }, payload }) => + [additionalActionTypes.BIND_REJECTED]: (state, { meta: { groupId, eventSisId }, payload }) => state - .updateIn(['data', groupId, 'attributes', 'group'], groups => groups.filter(id => id !== eventId)) + .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, eventId } }) => + [additionalActionTypes.UNBIND_FULFILLED]: (state, { meta: { groupId, eventSisId } }) => state - .updateIn(['data', groupId, 'attributes', 'group'], groups => groups.filter(id => id !== eventId)) + .updateIn(['data', groupId, 'attributes', 'group'], groups => groups?.filter(id => id !== eventSisId)) .removeIn(['data', groupId, 'pending']), [additionalActionTypes.UNBIND_REJECTED]: (state, { meta: { groupId }, payload }) => From 86c24f47f4b57b3db7f98c450510facb3c725bd8 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Martin=20Kruli=C5=A1?= Date: Sat, 20 Sep 2025 00:17:12 +0200 Subject: [PATCH 10/15] Fixing router. --- .../Groups/CoursesGroupsList/CoursesGroupsList.js | 13 ++++++------- src/containers/App/App.js | 7 +------ 2 files changed, 7 insertions(+), 13 deletions(-) diff --git a/src/components/Groups/CoursesGroupsList/CoursesGroupsList.js b/src/components/Groups/CoursesGroupsList/CoursesGroupsList.js index c6744ea..a2daa23 100644 --- a/src/components/Groups/CoursesGroupsList/CoursesGroupsList.js +++ b/src/components/Groups/CoursesGroupsList/CoursesGroupsList.js @@ -26,7 +26,6 @@ import { import './CoursesGroupsList.css'; import Button, { TheButtonGroup } from '../../widgets/TheButton'; -import { Link } from 'react-router'; import { recodexGroupAssignmentsLink, recodexGroupEditLink, @@ -399,7 +398,7 @@ const CoursesGroupsList = ({ )} {recodexGroupAssignmentsLink(group.id) && group.membership === 'student' && ( - + - + )} {recodexGroupStudentsLink(group.id) && group.membership && group.membership !== 'student' && ( - + - + )} {(group.isAdmin || group.membership === 'supervisor') && ( <> {recodexGroupEditLink(group.id) && ( - + - + )} {unbind && ( +
+
+ + + + ) : ( + + + + ) + } + + ); + } +} + +GroupsSuperadmin.propTypes = { + loggedInUserId: PropTypes.string, + loggedInUser: ImmutablePropTypes.map, + terms: ImmutablePropTypes.list, + groups: ImmutablePropTypes.map, + loadAsync: 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), + }) +)(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 index d53f599..24aa508 100644 --- a/src/pages/GroupsTeacher/GroupsTeacher.js +++ b/src/pages/GroupsTeacher/GroupsTeacher.js @@ -424,11 +424,7 @@ class GroupsTeacher extends Component { {this.state.modalError && (

- - : + :

{this.state.modalError}
diff --git a/src/pages/Home/Home.js b/src/pages/Home/Home.js index f6c64a6..b6b8056 100644 --- a/src/pages/Home/Home.js +++ b/src/pages/Home/Home.js @@ -13,6 +13,7 @@ import Icon, { JoinGroupIcon, LinkIcon, LoadingIcon, + ManagementIcon, ReturnIcon, UserProfileIcon, TermIcon, @@ -84,7 +85,7 @@ class Home extends Component { const { loggedInUser, params: { token = null }, - links: { USER_URI, TERMS_URI, GROUPS_STUDENT_URI, GROUPS_TEACHER_URI }, + links: { USER_URI, TERMS_URI, GROUPS_STUDENT_URI, GROUPS_TEACHER_URI, GROUPS_SUPERADMIN_URI }, } = this.props; return ( @@ -203,28 +204,56 @@ class Home extends Component { )} {isSuperadminRole(user.role) && ( - - -

- -

- - -

- - - - -

+ <> + + +

+ +

+ + +

+ + + + +

-

- -

- -
+

+ +

+ +
+ + + +

+ +

+ + +

+ + + + +

+ +

+ +

+ +
+ )}
diff --git a/src/pages/routes.js b/src/pages/routes.js index 8ba3cb8..4dfe742 100644 --- a/src/pages/routes.js +++ b/src/pages/routes.js @@ -5,6 +5,7 @@ import { matchPath, Routes, Route, Navigate } from 'react-router-dom'; 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'; @@ -73,6 +74,7 @@ const routesDescriptors = [ 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/groups.js b/src/redux/modules/groups.js index a46f33b..490213b 100644 --- a/src/redux/modules/groups.js +++ b/src/redux/modules/groups.js @@ -19,14 +19,14 @@ export const additionalActionTypes = { const _fetchGroups = (affiliation, eventIds = undefined, courseIds = undefined) => createApiAction({ type: additionalActionTypes.FETCH, - endpoint: `/groups/${affiliation}`, + 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) => From a6ab5be30d650ee426fddabb56fcdd0c22651f90 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Martin=20Kruli=C5=A1?= Date: Wed, 24 Sep 2025 23:41:31 +0200 Subject: [PATCH 12/15] Implementing GroupsTreeView component and utilizing it to display groups on Group management page. --- .../CoursesGroupsList/CoursesGroupsList.js | 205 +----------------- .../GroupMembershipIcon.js | 67 ++++++ .../Groups/GroupMembershipIcon/index.js | 2 + .../Groups/GroupsTreeView/GroupsTreeList.js | 29 +++ .../Groups/GroupsTreeView/GroupsTreeNode.js | 200 +++++++++++++++++ .../Groups/GroupsTreeView/GroupsTreeView.css | 17 ++ .../Groups/GroupsTreeView/GroupsTreeView.js | 38 ++++ src/components/Groups/GroupsTreeView/index.js | 2 + src/components/Groups/helpers.js | 180 +++++++++++++++ src/components/icons/index.js | 2 +- .../layout/Navigation/linkCreators.js | 101 --------- src/locales/cs.json | 25 +-- src/locales/en.json | 33 ++- .../GroupsSuperadmin/GroupsSuperadmin.js | 6 +- src/redux/modules/groups.js | 45 ++++ 15 files changed, 613 insertions(+), 339 deletions(-) create mode 100644 src/components/Groups/GroupMembershipIcon/GroupMembershipIcon.js create mode 100644 src/components/Groups/GroupMembershipIcon/index.js create mode 100644 src/components/Groups/GroupsTreeView/GroupsTreeList.js create mode 100644 src/components/Groups/GroupsTreeView/GroupsTreeNode.js create mode 100644 src/components/Groups/GroupsTreeView/GroupsTreeView.css create mode 100644 src/components/Groups/GroupsTreeView/GroupsTreeView.js create mode 100644 src/components/Groups/GroupsTreeView/index.js create mode 100644 src/components/Groups/helpers.js delete mode 100644 src/components/layout/Navigation/linkCreators.js diff --git a/src/components/Groups/CoursesGroupsList/CoursesGroupsList.js b/src/components/Groups/CoursesGroupsList/CoursesGroupsList.js index a2daa23..4670999 100644 --- a/src/components/Groups/CoursesGroupsList/CoursesGroupsList.js +++ b/src/components/Groups/CoursesGroupsList/CoursesGroupsList.js @@ -2,7 +2,6 @@ import React, { useState } from 'react'; import PropTypes from 'prop-types'; import ImmutablePropTypes from 'react-immutable-proptypes'; import { FormattedMessage, injectIntl } from 'react-intl'; -import { lruMemoize } from 'reselect'; import { Badge } from 'react-bootstrap'; import ResourceRenderer from '../../helpers/ResourceRenderer'; @@ -10,19 +9,16 @@ import DayOfWeek from '../../widgets/DayOfWeek'; import { AddIcon, AddUserIcon, - AdminRoleIcon, BindIcon, GroupIcon, LabsIcon, LectureIcon, LinkIcon, LoadingIcon, - ObserverIcon, - SupervisorIcon, - StudentsIcon, UnbindIcon, VisibleIcon, } from '../../icons'; +import GroupMembershipIcon from '../GroupMembershipIcon'; import './CoursesGroupsList.css'; import Button, { TheButtonGroup } from '../../widgets/TheButton'; @@ -32,147 +28,7 @@ import { recodexGroupStudentsLink, } from '../../helpers/recodexLinks.js'; import { minutesToTimeStr } from '../../helpers/stringFormatters.js'; - -/** - * 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} - */ -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); - - // use the parent to update our values - group.fullName = groups[group.parentGroupId].fullName || ''; - if (group.fullName) { - group.fullName += ' ❭ '; - } - group.fullName += group.name?.[locale] || group.name?.en || group.name?.cs || '???'; - group.isAdmin = groups[group.parentGroupId].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 - * @returns {Array} - */ -const getGroups = lruMemoize((groups, locale) => { - // make a copy of groups so we can augment it - const result = {}; - Object.keys(groups).forEach(id => { - result[id] = { ...groups[id] }; - }); - - Object.keys(result).forEach(id => { - if (!result[id].fullName) { - augmentGroupObject(result, id, locale); - } - }); - - return Object.values(result).sort((a, b) => a.fullName.localeCompare(b.fullName, locale)); -}); - -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 - */ -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; -}; - -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) - */ -const getBindingCandidates = lruMemoize((sisEvent, groups, locale) => - getGroups(groups, locale).filter( - group => - !group.organizational && - (group.isAdmin || group.membership === 'supervisor') && - isSuitableForCourse(sisEvent, group, groups) - ) -); +import { getSisGroups, getSortedSisEvents, getBindingCandidates, getParentCandidates } from '../helpers.js'; /* * Component displaying list of sisEvents (with scheduling events) and their groups. @@ -313,62 +169,7 @@ const CoursesGroupsList = ({ {sisGroups[sisEvent.sisId]?.map(group => ( - {group.membership === 'admin' && ( - - } - /> - )} - {group.membership === 'supervisor' && ( - - } - /> - )} - {group.membership === 'observer' && ( - - } - /> - )} - {group.membership === 'student' && ( - - } - /> - )} + {group.pending && } ( + <> + {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..6719308 --- /dev/null +++ b/src/components/Groups/GroupsTreeView/GroupsTreeNode.js @@ -0,0 +1,200 @@ +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 GroupsTreeList from './GroupsTreeList.js'; +import GroupMembershipIcon from '../GroupMembershipIcon'; +import Icon, { 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 && } + + {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 && removeAttribute(id, key, value)} />} + + )) + )} + + )} + + {addAttribute && ( + + + + )} + + + {!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/icons/index.js b/src/components/icons/index.js index 8b48753..ed5856c 100644 --- a/src/components/icons/index.js +++ b/src/components/icons/index.js @@ -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 => ; @@ -94,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 => ; 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/locales/cs.json b/src/locales/cs.json index 7c80573..0511132 100644 --- a/src/locales/cs.json +++ b/src/locales/cs.json @@ -42,7 +42,6 @@ "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.admin": "Jste administrátor této skupiny", "app.coursesGroupsList.bind": "Svázat existující skupinu", "app.coursesGroupsList.create": "Vytvořit novou skupinu", "app.coursesGroupsList.firstWeekEven": "sudé týdny", @@ -55,13 +54,10 @@ "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.observer": "Jste pozorovatel této skupiny", "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.student": "Jste studentem této skupiny", - "app.coursesGroupsList.supervisor": "Jste vedoucím této skupiny", "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á", @@ -78,6 +74,10 @@ "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", @@ -111,6 +111,12 @@ "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 atribut", + "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.header.languageSwitching.translationTitle": "Jazyková verze", "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.", @@ -131,15 +137,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í}}", @@ -259,4 +256,4 @@ "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 850627e..8d74969 100644 --- a/src/locales/en.json +++ b/src/locales/en.json @@ -42,7 +42,6 @@ "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.admin": "You are an administrator of this group", "app.coursesGroupsList.bind": "Bind Existing Group", "app.coursesGroupsList.create": "Create New Group", "app.coursesGroupsList.firstWeekEven": "even weeks", @@ -55,13 +54,10 @@ "app.coursesGroupsList.lecture": "Lecture", "app.coursesGroupsList.noCourses": "There are currently no courses available for this term.", "app.coursesGroupsList.notScheduled": "not scheduled", - "app.coursesGroupsList.observer": "You are an observer of this group", "app.coursesGroupsList.recodexGroupAssignments": "Group Assignments", "app.coursesGroupsList.recodexGroupEdit": "Edit Group", "app.coursesGroupsList.recodexGroupStudents": "Group Students", "app.coursesGroupsList.showAllCourses": "Show All Courses", - "app.coursesGroupsList.student": "You are a student of this group", - "app.coursesGroupsList.supervisor": "You are a supervisor of this group", "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", @@ -78,6 +74,10 @@ "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", @@ -89,10 +89,10 @@ "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.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.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", @@ -111,6 +111,12 @@ "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 attribute", + "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.header.languageSwitching.translationTitle": "Translation", "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.", @@ -131,15 +137,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}}", @@ -259,4 +256,4 @@ "generic.reset": "Reset", "generic.save": "Save", "generic.search": "Search" -} +} \ No newline at end of file diff --git a/src/pages/GroupsSuperadmin/GroupsSuperadmin.js b/src/pages/GroupsSuperadmin/GroupsSuperadmin.js index fbdf1aa..36ac2cd 100644 --- a/src/pages/GroupsSuperadmin/GroupsSuperadmin.js +++ b/src/pages/GroupsSuperadmin/GroupsSuperadmin.js @@ -7,6 +7,7 @@ import { FormattedMessage } from 'react-intl'; import Page from '../../components/layout/Page'; import Box from '../../components/widgets/Box'; +import GroupsTreeView from '../../components/Groups/GroupsTreeView'; import Button, { TheButtonGroup } from '../../components/widgets/TheButton'; import { CloseIcon, ManagementIcon } from '../../components/icons'; // import ResourceRenderer from '../../components/helpers/ResourceRenderer'; @@ -95,10 +96,9 @@ class GroupsSuperadmin extends Component { isSuperadminRole(user.role) ? ( <> }> - {Object.values(groups).map(group => ( -
    {group.id}
    - ))} +
    diff --git a/src/redux/modules/groups.js b/src/redux/modules/groups.js index 490213b..d23aed9 100644 --- a/src/redux/modules/groups.js +++ b/src/redux/modules/groups.js @@ -10,6 +10,8 @@ export const additionalActionTypes = { ...createActionsWithPostfixes('UNBIND', 'siscodex/groups'), ...createActionsWithPostfixes('CREATE', 'siscodex/groups'), ...createActionsWithPostfixes('JOIN', 'siscodex/groups'), + ...createActionsWithPostfixes('ADD_ATTRIBUTE', 'siscodex/groups'), + ...createActionsWithPostfixes('REMOVE_ATTRIBUTE', 'siscodex/groups'), }; /** @@ -61,6 +63,24 @@ export const joinGroup = groupId => 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 */ @@ -111,6 +131,31 @@ const reducer = handleActions( [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() ); From 9e6939272e357e732c07e170555fb3a321c4d589 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Martin=20Kruli=C5=A1?= Date: Thu, 25 Sep 2025 00:21:33 +0200 Subject: [PATCH 13/15] Attaching operations add/remove attribute to group tree view. --- .../Groups/GroupsTreeView/GroupsTreeNode.js | 44 +++++++++++++------ src/locales/cs.json | 5 ++- src/locales/en.json | 3 +- .../GroupsSuperadmin/GroupsSuperadmin.js | 32 ++++++++++---- 4 files changed, 58 insertions(+), 26 deletions(-) diff --git a/src/components/Groups/GroupsTreeView/GroupsTreeNode.js b/src/components/Groups/GroupsTreeView/GroupsTreeNode.js index 6719308..6b2feae 100644 --- a/src/components/Groups/GroupsTreeView/GroupsTreeNode.js +++ b/src/components/Groups/GroupsTreeView/GroupsTreeNode.js @@ -5,9 +5,10 @@ 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, { CloseIcon, GroupIcon, LectureIcon, LoadingIcon, TermIcon } from '../../icons'; +import Icon, { AddIcon, CloseIcon, GroupIcon, LectureIcon, LoadingIcon, TermIcon } from '../../icons'; import withLinks from '../../../helpers/withLinks.js'; import { EMPTY_OBJ } from '../../../helpers/common.js'; @@ -136,31 +137,46 @@ const GroupsTreeNode = React.memo(({ group, isExpanded = false, addAttribute, re {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 && removeAttribute(id, key, value)} />} + + {removeAttribute && !pending && ( + removeAttribute(id, key, value)} + question={ + + }> + + + )} + {pending && } )) )} )} - - {addAttribute && ( - - - - )} {!leafNode && ( diff --git a/src/locales/cs.json b/src/locales/cs.json index 0511132..eab1146 100644 --- a/src/locales/cs.json +++ b/src/locales/cs.json @@ -111,12 +111,13 @@ "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 atribut", + "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.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.", @@ -256,4 +257,4 @@ "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 8d74969..2ae11a6 100644 --- a/src/locales/en.json +++ b/src/locales/en.json @@ -111,12 +111,13 @@ "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 attribute", + "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.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.", diff --git a/src/pages/GroupsSuperadmin/GroupsSuperadmin.js b/src/pages/GroupsSuperadmin/GroupsSuperadmin.js index 36ac2cd..3426044 100644 --- a/src/pages/GroupsSuperadmin/GroupsSuperadmin.js +++ b/src/pages/GroupsSuperadmin/GroupsSuperadmin.js @@ -9,10 +9,10 @@ import Page from '../../components/layout/Page'; import Box from '../../components/widgets/Box'; import GroupsTreeView from '../../components/Groups/GroupsTreeView'; import Button, { TheButtonGroup } from '../../components/widgets/TheButton'; -import { CloseIcon, ManagementIcon } from '../../components/icons'; +import { CloseIcon, GroupIcon, ManagementIcon } from '../../components/icons'; // import ResourceRenderer from '../../components/helpers/ResourceRenderer'; -import { fetchAllGroups } from '../../redux/modules/groups.js'; +import { fetchAllGroups, addGroupAttribute, removeGroupAttribute } from '../../redux/modules/groups.js'; import { fetchUserIfNeeded } from '../../redux/modules/users.js'; import { fetchAllTerms } from '../../redux/modules/terms.js'; import { loggedInUserIdSelector } from '../../redux/selectors/auth.js'; @@ -43,18 +43,18 @@ const getGroupAdmins = group => { class GroupsSuperadmin extends Component { state = { + modalGroup: null, modalPending: false, modalError: null, - selectGroups: null, - selectedGroupId: '', }; + openModal = modalGroup => this.setState({ modalGroup, modalPending: false, modalError: null }); + closeModal = () => this.setState({ + modalGroup: null, modalPending: false, modalError: null, - selectGroups: null, - selectedGroupId: '', }); // handleGroupChange = ev => this.setState({ selectedGroupId: ev.target.value }); @@ -83,7 +83,7 @@ class GroupsSuperadmin extends Component { ]); render() { - const { loggedInUser, groups } = this.props; + const { loggedInUser, groups, removeAttribute } = this.props; return ( }> - + - + +
    + + {this.state.modalGroup?.fullName} +
    + {this.state.modalError && (

    @@ -154,6 +164,8 @@ GroupsSuperadmin.propTypes = { terms: ImmutablePropTypes.list, groups: ImmutablePropTypes.map, loadAsync: PropTypes.func.isRequired, + addAttribute: PropTypes.func.isRequired, + removeAttribute: PropTypes.func.isRequired, }; export default connect( @@ -166,5 +178,7 @@ export default connect( 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)), }) )(GroupsSuperadmin); From 2f721c15144070ad903902f29671cf2aa30effcb Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Martin=20Kruli=C5=A1?= Date: Thu, 25 Sep 2025 16:59:40 +0200 Subject: [PATCH 14/15] Implementing add-attribute form and its submission. --- .../AddAttributeForm/AddAttributeForm.js | 253 ++++++++++++++++++ .../forms/AddAttributeForm/index.js | 2 + .../forms/fields/StandaloneRadioField.js | 21 ++ src/components/forms/fields/index.js | 1 + src/locales/cs.json | 17 +- src/locales/en.json | 17 +- .../GroupsSuperadmin/GroupsSuperadmin.js | 104 ++++--- src/pages/User/User.js | 2 +- src/redux/modules/groups.js | 9 +- 9 files changed, 379 insertions(+), 47 deletions(-) create mode 100644 src/components/forms/AddAttributeForm/AddAttributeForm.js create mode 100644 src/components/forms/AddAttributeForm/index.js create mode 100644 src/components/forms/fields/StandaloneRadioField.js 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 ( +

    ( + + + + + {({ values: { mode } }) => ( + <> + + + + + + + + + + + + + + + + + + + + + + )} + + +
    + + + + : + + + + + } + /> +
    + + + + : + + + + + } + /> +
    + + + + + : + + + + + } + /> +
    + + + + : + + + + + } + /> + + + : + + } + /> +
    + + + {({ errors: { students } }) => students && {students}} + + + {submitError && {submitError}} + +
    + + + {({ values, valid }) => ( + + )} + + + {onClose && ( + + )} + +
    +
    + )} + /> + ); +}; + +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/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/locales/cs.json b/src/locales/cs.json index eab1146..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.", @@ -89,6 +103,7 @@ "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.", @@ -257,4 +272,4 @@ "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 2ae11a6..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.", @@ -89,6 +103,7 @@ "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.", @@ -235,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.", diff --git a/src/pages/GroupsSuperadmin/GroupsSuperadmin.js b/src/pages/GroupsSuperadmin/GroupsSuperadmin.js index 3426044..78f3a23 100644 --- a/src/pages/GroupsSuperadmin/GroupsSuperadmin.js +++ b/src/pages/GroupsSuperadmin/GroupsSuperadmin.js @@ -2,19 +2,19 @@ import React, { Component } from 'react'; import PropTypes from 'prop-types'; import ImmutablePropTypes from 'react-immutable-proptypes'; import { connect } from 'react-redux'; -import { Modal } from 'react-bootstrap'; +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 Button, { TheButtonGroup } from '../../components/widgets/TheButton'; -import { CloseIcon, GroupIcon, ManagementIcon } from '../../components/icons'; -// import ResourceRenderer from '../../components/helpers/ResourceRenderer'; +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 { fetchAllTerms } from '../../redux/modules/terms.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'; @@ -25,21 +25,14 @@ import Callout from '../../components/widgets/Callout/Callout.js'; 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 getGroupAdmins = group => { - const res = Object.values(group.admins) - .map(admin => admin.lastName) - .join(', '); - return res ? ` [${res}]` : ''; +const ADD_FORM_INITIAL_VALUES = { + mode: 'course', + course: '', + term: '', + group: '', + key: '', + value: '', }; -*/ class GroupsSuperadmin extends Component { state = { @@ -57,12 +50,20 @@ class GroupsSuperadmin extends Component { modalError: null, }); - // handleGroupChange = ev => this.setState({ selectedGroupId: ev.target.value }); - - completeModalOperation = () => { - // const { loggedInUserId, loadAsync } = this.props; - // this.setState({ modalPending: true }); - this.closeModal(); + 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() { @@ -76,11 +77,7 @@ class GroupsSuperadmin extends Component { } static loadAsync = ({ userId }, dispatch, expiration = DEFAULT_EXPIRATION) => - Promise.all([ - dispatch(fetchUserIfNeeded(userId, { allowReload: true })), - dispatch(fetchAllGroups()), - dispatch(fetchAllTerms()), - ]); + Promise.all([dispatch(fetchUserIfNeeded(userId, { allowReload: true })), dispatch(fetchAllGroups())]); render() { const { loggedInUser, groups, removeAttribute } = this.props; @@ -102,7 +99,7 @@ class GroupsSuperadmin extends Component { + {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 && (

    @@ -130,18 +149,14 @@ class GroupsSuperadmin extends Component {

    {this.state.modalError}
    )} -
    - -
    - - - -
    -
    + +
    ) : ( @@ -179,6 +194,9 @@ export default connect( 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)), + removeAttribute: (groupId, key, value) => + dispatch(removeGroupAttribute(groupId, key, value)).catch(err => + dispatch(addNotification(err?.message || err.toString(), false)) + ), }) )(GroupsSuperadmin); 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/redux/modules/groups.js b/src/redux/modules/groups.js index d23aed9..f92994e 100644 --- a/src/redux/modules/groups.js +++ b/src/redux/modules/groups.js @@ -84,12 +84,19 @@ export const removeGroupAttribute = (groupId, 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: payload }), + createRecord({ state: resourceStatus.FULFILLED, data: fixAttributes(payload) }), [additionalActionTypes.FETCH_REJECTED]: (state, { payload }) => state.set('state', resourceStatus.FAILED).set('error', payload), From a6f02dd2a7b0ec0bed06b00ef8a856ce30b2bf67 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Martin=20Kruli=C5=A1?= Date: Fri, 26 Sep 2025 16:03:56 +0200 Subject: [PATCH 15/15] Update src/pages/GroupsStudent/GroupsStudent.js Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com> --- src/pages/GroupsStudent/GroupsStudent.js | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/pages/GroupsStudent/GroupsStudent.js b/src/pages/GroupsStudent/GroupsStudent.js index 4239460..bfe2d76 100644 --- a/src/pages/GroupsStudent/GroupsStudent.js +++ b/src/pages/GroupsStudent/GroupsStudent.js @@ -218,7 +218,7 @@ export default connect( }), dispatch => ({ loadAsync: (userId, expiration = DEFAULT_EXPIRATION) => GroupsStudent.loadAsync({ userId }, dispatch, expiration), - joinGroup: (groupId, reloadGroupsOFCourses) => - dispatch(joinGroup(groupId)).then(() => dispatch(fetchStudentGroups(reloadGroupsOFCourses))), + joinGroup: (groupId, reloadGroupsOfCourses) => + dispatch(joinGroup(groupId)).then(() => dispatch(fetchStudentGroups(reloadGroupsOfCourses))), }) )(GroupsStudent);