From e31a49dc0e3d86fd2b2e1d4c9965eee63828a761 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Martin=20Kruli=C5=A1?= Date: Thu, 11 Apr 2024 17:45:59 +0200 Subject: [PATCH] Adding participating students list (for exam term) with unlock buttons so the teacher can release them. --- .../Groups/GroupExamsTable/GroupExamsTable.js | 43 +++++++----- .../LockedStudentsTable.js | 58 ++++++++++++++++ .../Groups/LockedStudentsTable/index.js | 2 + .../ExamUnlockButton/ExamUnlockButton.js | 24 +++++++ .../buttons/ExamUnlockButton/index.js | 2 + .../layout/Navigation/Navigation.js | 15 +++- .../layout/Navigation/linkCreators.js | 1 + .../ExamUnlockButtonContainer.js | 56 +++++++++++++++ .../ExamUnlockButtonContainer/index.js | 2 + src/locales/cs.json | 6 +- src/locales/en.json | 6 +- src/locales/whitelist_cs.json | 5 +- src/pages/GroupExams/GroupExams.js | 68 +++++++++++++++++-- src/pages/routes.js | 1 + src/redux/modules/users.js | 3 + src/redux/selectors/groups.js | 5 ++ src/redux/selectors/usersGroups.js | 16 +++++ 17 files changed, 286 insertions(+), 27 deletions(-) create mode 100644 src/components/Groups/LockedStudentsTable/LockedStudentsTable.js create mode 100644 src/components/Groups/LockedStudentsTable/index.js create mode 100644 src/components/buttons/ExamUnlockButton/ExamUnlockButton.js create mode 100644 src/components/buttons/ExamUnlockButton/index.js create mode 100644 src/containers/ExamUnlockButtonContainer/ExamUnlockButtonContainer.js create mode 100644 src/containers/ExamUnlockButtonContainer/index.js diff --git a/src/components/Groups/GroupExamsTable/GroupExamsTable.js b/src/components/Groups/GroupExamsTable/GroupExamsTable.js index 6cb90d22c..1ad53e57c 100644 --- a/src/components/Groups/GroupExamsTable/GroupExamsTable.js +++ b/src/components/Groups/GroupExamsTable/GroupExamsTable.js @@ -2,6 +2,7 @@ import React from 'react'; import PropTypes from 'prop-types'; import { FormattedMessage } from 'react-intl'; import { Table } from 'react-bootstrap'; +import { Link } from 'react-router-dom'; import { defaultMemoize } from 'reselect'; import DateTime from '../../widgets/DateTime'; @@ -10,12 +11,12 @@ import { VisibleIcon } from '../../icons'; const sortExams = defaultMemoize(exams => { const sorted = [...exams]; - return sorted.sort((a, b) => a.end - b.end); + return sorted.sort((a, b) => a.end - b.end || a.begin - b.begin); }); -const GroupExamsTable = ({ exams = null, selected = null, setSelected = null }) => +const GroupExamsTable = ({ exams = null, selected = null, linkFactory = null }) => exams && exams.length > 0 ? ( - +
- {setSelected && {sortExams(exams).map((exam, idx) => ( - + - {setSelected && ( - )} @@ -69,7 +75,10 @@ const GroupExamsTable = ({ exams = null, selected = null, setSelected = null }) ) : (
- +
); @@ -77,7 +86,7 @@ const GroupExamsTable = ({ exams = null, selected = null, setSelected = null }) GroupExamsTable.propTypes = { exams: PropTypes.array, selected: PropTypes.string, - setSelected: PropTypes.func, + linkFactory: PropTypes.func, }; export default GroupExamsTable; diff --git a/src/components/Groups/LockedStudentsTable/LockedStudentsTable.js b/src/components/Groups/LockedStudentsTable/LockedStudentsTable.js new file mode 100644 index 000000000..5494b6f40 --- /dev/null +++ b/src/components/Groups/LockedStudentsTable/LockedStudentsTable.js @@ -0,0 +1,58 @@ +import React from 'react'; +import PropTypes from 'prop-types'; +import { FormattedMessage, injectIntl } from 'react-intl'; +import { Table } from 'react-bootstrap'; +import { defaultMemoize } from 'reselect'; + +import UsersName from '../../Users/UsersName'; +import ExamUnlockButtonContainer from '../../../containers/ExamUnlockButtonContainer'; +import { createUserNameComparator } from '../../helpers/users'; + +const sortStudents = defaultMemoize((lockedStudents, locale) => { + const sorted = [...lockedStudents]; + return sorted.sort(createUserNameComparator(locale)); +}); + +const LockedStudentsTable = ({ groupId, lockedStudents, currentUser, intl: { locale } }) => + lockedStudents && lockedStudents.length > 0 ? ( +
@@ -28,12 +29,12 @@ const GroupExamsTable = ({ exams = null, selected = null, setSelected = null }) } + {linkFactory && }
#{idx + 1} @@ -50,16 +51,21 @@ const GroupExamsTable = ({ exams = null, selected = null, setSelected = null }) )} - + {linkFactory && ( + + + +
+ + {sortStudents(lockedStudents).map(student => ( + + + + + ))} + +
+ + + +
+ ) : ( +
+ + + +
+ ); + +LockedStudentsTable.propTypes = { + groupId: PropTypes.string.isRequired, + lockedStudents: PropTypes.array, + currentUser: PropTypes.shape({ + id: PropTypes.string.isRequired, + }), + intl: PropTypes.object.isRequired, +}; + +export default injectIntl(LockedStudentsTable); diff --git a/src/components/Groups/LockedStudentsTable/index.js b/src/components/Groups/LockedStudentsTable/index.js new file mode 100644 index 000000000..a1e325ff5 --- /dev/null +++ b/src/components/Groups/LockedStudentsTable/index.js @@ -0,0 +1,2 @@ +import LockedStudentsTable from './LockedStudentsTable'; +export default LockedStudentsTable; diff --git a/src/components/buttons/ExamUnlockButton/ExamUnlockButton.js b/src/components/buttons/ExamUnlockButton/ExamUnlockButton.js new file mode 100644 index 000000000..654f8edb3 --- /dev/null +++ b/src/components/buttons/ExamUnlockButton/ExamUnlockButton.js @@ -0,0 +1,24 @@ +import React from 'react'; +import PropTypes from 'prop-types'; +import { FormattedMessage } from 'react-intl'; +import { UnlockIcon, LoadingIcon } from '../../icons'; +import Button from '../../widgets/TheButton'; + +const ExamUnlockButton = ({ pending, disabled = false, unlockUserForExam, ...props }) => ( + +); + +ExamUnlockButton.propTypes = { + pending: PropTypes.bool.isRequired, + disabled: PropTypes.bool, + unlockUserForExam: PropTypes.func.isRequired, +}; + +export default ExamUnlockButton; diff --git a/src/components/buttons/ExamUnlockButton/index.js b/src/components/buttons/ExamUnlockButton/index.js new file mode 100644 index 000000000..619cece2c --- /dev/null +++ b/src/components/buttons/ExamUnlockButton/index.js @@ -0,0 +1,2 @@ +import ExamUnlockButton from './ExamUnlockButton'; +export default ExamUnlockButton; diff --git a/src/components/layout/Navigation/Navigation.js b/src/components/layout/Navigation/Navigation.js index c77a0b793..3bdc882d4 100644 --- a/src/components/layout/Navigation/Navigation.js +++ b/src/components/layout/Navigation/Navigation.js @@ -14,8 +14,18 @@ import { AssignmentIcon, ExerciseIcon, PipelineIcon, ShadowAssignmentIcon } from import styles from './Navigation.less'; -const NavigationLink = ({ link, href, caption, icon = null, location: { pathname, search }, className }) => - link === pathname + search ? ( +const defaultLinkMatch = (link, pathname, search) => link === pathname + search; + +const NavigationLink = ({ + link, + href, + caption, + icon = null, + location: { pathname, search }, + className, + match = defaultLinkMatch, +}) => + match(link, pathname, search) ? ( {icon} {caption} @@ -38,6 +48,7 @@ NavigationLink.propTypes = { caption: PropTypes.oneOfType([PropTypes.string, PropTypes.element]), icon: PropTypes.element, className: PropTypes.string, + match: PropTypes.func, location: PropTypes.shape({ pathname: PropTypes.string.isRequired, search: PropTypes.string.isRequired, diff --git a/src/components/layout/Navigation/linkCreators.js b/src/components/layout/Navigation/linkCreators.js index 13a61f001..cf603f811 100644 --- a/src/components/layout/Navigation/linkCreators.js +++ b/src/components/layout/Navigation/linkCreators.js @@ -49,6 +49,7 @@ export const createGroupLinks = ( caption: , link: GROUP_EXAMS_URI_FACTORY(groupId), icon: , + match: (link, pathname) => pathname.startsWith(link), }, ]; diff --git a/src/containers/ExamUnlockButtonContainer/ExamUnlockButtonContainer.js b/src/containers/ExamUnlockButtonContainer/ExamUnlockButtonContainer.js new file mode 100644 index 000000000..e4d85e6bf --- /dev/null +++ b/src/containers/ExamUnlockButtonContainer/ExamUnlockButtonContainer.js @@ -0,0 +1,56 @@ +import React from 'react'; +import PropTypes from 'prop-types'; +import { connect } from 'react-redux'; +import { injectIntl } from 'react-intl'; +import { defaultMemoize } from 'reselect'; + +import ExamUnlockButton from '../../components/buttons/ExamUnlockButton'; +import { unlockStudentFromExam } from '../../redux/modules/groups'; +import { groupPendingUserUnlock } from '../../redux/selectors/groups'; +import { loggedInUserSelector } from '../../redux/selectors/users'; +import { getErrorMessage } from '../../locales/apiErrorMessages'; +import { addNotification } from '../../redux/modules/notifications'; + +const unlockStudentHandlingErrors = defaultMemoize( + (unlockStudentFromExam, addNotification, formatMessage) => () => + unlockStudentFromExam().catch(err => { + addNotification(getErrorMessage(formatMessage)(err), false); + }) +); + +const ExamUnlockButtonContainer = ({ + groupId, + userId, + pending = null, + unlockStudentFromExam, + addNotification, + intl: { formatMessage }, + ...props +}) => ( + +); + +ExamUnlockButtonContainer.propTypes = { + groupId: PropTypes.string.isRequired, + userId: PropTypes.string.isRequired, + pending: PropTypes.string, + unlockStudentFromExam: PropTypes.func.isRequired, + addNotification: PropTypes.func.isRequired, + intl: PropTypes.object, +}; + +const mapStateToProps = (state, { groupId }) => ({ + pending: groupPendingUserUnlock(state, groupId), + currentUser: loggedInUserSelector(state), +}); + +const mapDispatchToProps = (dispatch, { groupId, userId }) => ({ + unlockStudentFromExam: () => dispatch(unlockStudentFromExam(groupId, userId)), + addNotification: (...args) => dispatch(addNotification(...args)), +}); + +export default connect(mapStateToProps, mapDispatchToProps)(injectIntl(ExamUnlockButtonContainer)); diff --git a/src/containers/ExamUnlockButtonContainer/index.js b/src/containers/ExamUnlockButtonContainer/index.js new file mode 100644 index 000000000..9d20945fd --- /dev/null +++ b/src/containers/ExamUnlockButtonContainer/index.js @@ -0,0 +1,2 @@ +import ExamUnlockButtonContainer from './ExamUnlockButtonContainer'; +export default ExamUnlockButtonContainer; diff --git a/src/locales/cs.json b/src/locales/cs.json index 45be72440..4987e1266 100644 --- a/src/locales/cs.json +++ b/src/locales/cs.json @@ -1084,18 +1084,21 @@ "app.groupExams.lockedStudentInfoStrict": "K ostatním skupinám nemáte přístup dokud jste v uzamčeném režimu.", "app.groupExams.locking": "Typ zámku", "app.groupExams.noExam": "V tuto chvíli není naplánovaná žádná zkouška", - "app.groupExams.noPreviousExams": "Dosud nebyly žádné zkouškové termíny.", "app.groupExams.pending.studentLockedTitle": "Jste uzamčeném režimu pro probíhající zkoušku", "app.groupExams.pending.teacherInfo": "V tuto chvíli jsou zkouškové úlohy viditelné pouze studentům, kteří se uzamkli ve skupině.", "app.groupExams.studentInfo": "Musíte se uzamknout ve skupině, abyste mohl(a) vidět zkouškové úlohy. Po čas uzamčení je komunikace s vámi omezena na vaši aktuální IP adresu.", "app.groupExams.studentInfoRegular": "Navíc budete moct přistupovat k ostatním skupinám pouze v režimu pro čtení dokud budete v uzamčeném režimu.", "app.groupExams.studentInfoStrict": "Navíc nebudete moct přistupovat k ostatním skupinám dokud budete v uzamčeném režimu.", + "app.groupExams.studentsBoxTitle": "Studenti účastnící se zkoušky", "app.groupExams.timeAccuracyWarning": "Lokální hodiny na vašem systému musí být dostatečně seřízené, jinak nemusí tato komponenta fungovat zcela správně.", "app.groupExams.title": "Zkouškové termíny skupiny", + "app.groupExams.unlockStudentButton": "Odemknout", "app.groupExamsTable.begin": "Začala", "app.groupExamsTable.end": "Skončila", "app.groupExamsTable.lockType": "Typ zámku", + "app.groupExamsTable.noPreviousExams": "Dosud nebyly žádné zkouškové termíny.", "app.groupExamsTable.selectButton": "Detail", + "app.groupExamsTable.unselectButton": "Zrušit", "app.groupInfo.title": "Podrobnosti a metadata skupiny", "app.groupInvitationForm.expireAt": "Konec platnosti:", "app.groupInvitationForm.expireAtExplanation": "Odkaz pozvánky bude rozpoznatelný ReCodExem i po uplynutí doby platnosti, ale studenti jej nebudou moci použít.", @@ -1202,6 +1205,7 @@ "app.localizedTexts.noText": "Pro danou lokalizaci není vyplněn ani text ani externí odkaz na zadaní. Tato úloha ještě není řádně dospecifikována.", "app.localizedTexts.studentHintHeading": "Nápověda", "app.localizedTexts.validation.noLocalizedText": "Prosíme povolte alespoň jednu záložku s lokalizovanými texty.", + "app.lockedStudentsTable.noLockedStudents": "Dosud se žádní studenti nezamkli ve skupině.", "app.login.alreadyLoggedIn": "Již jste úspěšně přihlášen(a).", "app.login.cannotRememberPassword": "Zapomněli jste heslo?", "app.login.loginIsRequired": "Cílová stránka je dostupná pouze pro přihlášené uživatele. Nejprve je potřeba se přihlásit.", diff --git a/src/locales/en.json b/src/locales/en.json index 1d02de1b5..1054ef05e 100644 --- a/src/locales/en.json +++ b/src/locales/en.json @@ -1084,18 +1084,21 @@ "app.groupExams.lockedStudentInfoStrict": "You may not access any other groups until the exam lock expires.", "app.groupExams.locking": "Lock type", "app.groupExams.noExam": "There is currently no exam scheduled", - "app.groupExams.noPreviousExams": "There are no previous exams recorded.", "app.groupExams.pending.studentLockedTitle": "You are locked in for an exam", "app.groupExams.pending.teacherInfo": "The exam assignments are currently visible only to students who have lock themselves in the group.", "app.groupExams.studentInfo": "You need to lock yourself in to see the exam assignments. When locked, your actions will be restricted to your current IP address.", "app.groupExams.studentInfoRegular": "Furthermore, you will be able to access other groups in a read-only mode until the exam lock expires.", "app.groupExams.studentInfoStrict": "Furthermore, you will not be able to access other groups until the exam lock expires.", + "app.groupExams.studentsBoxTitle": "Participating students", "app.groupExams.timeAccuracyWarning": "Your local system clock should be sufficiently synchronized or this component may not work properly.", "app.groupExams.title": "Group Exam Terms", + "app.groupExams.unlockStudentButton": "Unlock", "app.groupExamsTable.begin": "Begun at", "app.groupExamsTable.end": "Ended at", "app.groupExamsTable.lockType": "Lock type", + "app.groupExamsTable.noPreviousExams": "There are no previous exams recorded.", "app.groupExamsTable.selectButton": "Detail", + "app.groupExamsTable.unselectButton": "Unselect", "app.groupInfo.title": "Group Details and Metadata", "app.groupInvitationForm.expireAt": "Expire at:", "app.groupInvitationForm.expireAtExplanation": "An invitation link will be still recognized by ReCodEx after the expiration date, but the students will not be allowed to use it.", @@ -1202,6 +1205,7 @@ "app.localizedTexts.noText": "There is no text nor link for given localization. The exercise is not fully specified yet.", "app.localizedTexts.studentHintHeading": "Hint", "app.localizedTexts.validation.noLocalizedText": "Please enable at least one tab of localized texts.", + "app.lockedStudentsTable.noLockedStudents": "There are no locked students yet.", "app.login.alreadyLoggedIn": "You are already logged in.", "app.login.cannotRememberPassword": "You cannot remember what your password was?", "app.login.loginIsRequired": "Target page is available for authorized users only. Please sign in first.", diff --git a/src/locales/whitelist_cs.json b/src/locales/whitelist_cs.json index 0f6512500..2fe335df0 100644 --- a/src/locales/whitelist_cs.json +++ b/src/locales/whitelist_cs.json @@ -163,5 +163,6 @@ "app.systemMessagesList.text", "generic.detail", "generic.email", - "generic.role" -] \ No newline at end of file + "generic.role", + "app.groupExamsTable.selectButton" +] diff --git a/src/pages/GroupExams/GroupExams.js b/src/pages/GroupExams/GroupExams.js index 65c852701..8b98b570c 100644 --- a/src/pages/GroupExams/GroupExams.js +++ b/src/pages/GroupExams/GroupExams.js @@ -11,18 +11,36 @@ import { GroupNavigation } from '../../components/layout/Navigation'; import Box from '../../components/widgets/Box'; import GroupArchivedWarning from '../../components/Groups/GroupArchivedWarning'; import GroupExamsTable from '../../components/Groups/GroupExamsTable'; +import GroupExamStatus from '../../components/Groups/GroupExamStatus'; +import LockedStudentsTable from '../../components/Groups/LockedStudentsTable'; import { GroupExamsIcon } from '../../components/icons'; import { fetchGroup, fetchGroupIfNeeded, setExamPeriod, removeExamPeriod } from '../../redux/modules/groups'; +import { fetchByIds } from '../../redux/modules/users'; import { addNotification } from '../../redux/modules/notifications'; import { groupSelector, groupDataAccessorSelector, groupTypePendingChange } from '../../redux/selectors/groups'; +import { lockedStudentsOfGroupSelector } from '../../redux/selectors/usersGroups'; import { loggedInUserIdSelector } from '../../redux/selectors/auth'; import { isLoggedAsSuperAdmin, loggedInUserSelector } from '../../redux/selectors/users'; import withLinks from '../../helpers/withLinks'; -import GroupExamStatus from '../../components/Groups/GroupExamStatus'; +import { hasPermissions, safeGet } from '../../helpers/common'; + +const isExam = ({ privateData: { examBegin, examEnd } }) => { + const now = Date.now() / 1000; + return examBegin && examEnd && examEnd > now && examBegin <= now; +}; class GroupExams extends Component { + static loadAsync = ({ groupId }, dispatch) => + Promise.all([ + dispatch(fetchGroupIfNeeded(groupId)).then(({ value: group }) => + hasPermissions(group, 'viewStudents') + ? dispatch(fetchByIds(safeGet(group, ['privateData', 'students']) || [])) + : Promise.resolve() + ), + ]); + componentDidMount() { this.props.loadAsync(); } @@ -33,9 +51,21 @@ class GroupExams extends Component { } } + linkFactory = id => { + const { + params: { groupId, examId = null }, + links: { GROUP_EXAMS_URI_FACTORY, GROUP_EXAMS_SPECIFIC_EXAM_URI_FACTORY }, + } = this.props; + return String(id) === String(examId) + ? GROUP_EXAMS_URI_FACTORY(groupId) + : GROUP_EXAMS_SPECIFIC_EXAM_URI_FACTORY(groupId, id); + }; + render() { const { + params: { examId = null }, group, + lockedStudents, currentUser, groupsAccessor, examBeginImmediately, @@ -75,10 +105,37 @@ class GroupExams extends Component { title={} noPadding unlimitedHeight> - + + + {(examId || isExam(group)) && hasPermissions(group, 'viewStudents', 'setExamPeriod') && ( + + + + } + noPadding + unlimitedHeight> + {isExam(group) ? ( + + ) : ( + examId + )} + + + + )} )} @@ -92,9 +149,11 @@ GroupExams.propTypes = { reload: PropTypes.func.isRequired, params: PropTypes.shape({ groupId: PropTypes.string.isRequired, + examId: PropTypes.number, }).isRequired, group: ImmutablePropTypes.map, currentUser: ImmutablePropTypes.map, + lockedStudents: PropTypes.array, groupsAccessor: PropTypes.func.isRequired, isSuperAdmin: PropTypes.bool, examBeginImmediately: PropTypes.bool, @@ -112,6 +171,7 @@ export default withLinks( (state, { params: { groupId } }) => ({ group: groupSelector(state, groupId), groupsAccessor: groupDataAccessorSelector(state), + lockedStudents: lockedStudentsOfGroupSelector(state, groupId), userId: loggedInUserIdSelector(state), currentUser: loggedInUserSelector(state), isSuperAdmin: isLoggedAsSuperAdmin(state), @@ -119,8 +179,8 @@ export default withLinks( examEndRelative: examFormSelector(state, 'endRelative'), examPendingChange: groupTypePendingChange(state, groupId), }), - (dispatch, { params: { groupId } }) => ({ - loadAsync: () => dispatch(fetchGroupIfNeeded(groupId)), + (dispatch, { params: { groupId, examId } }) => ({ + loadAsync: () => GroupExams.loadAsync({ groupId, examId }, dispatch), reload: () => dispatch(fetchGroup(groupId)), addNotification: (...args) => dispatch(addNotification(...args)), setExamPeriod: (begin, end, strict) => dispatch(setExamPeriod(groupId, begin, end, strict)), diff --git a/src/pages/routes.js b/src/pages/routes.js index 5b47d332f..ec2afe84e 100644 --- a/src/pages/routes.js +++ b/src/pages/routes.js @@ -173,6 +173,7 @@ const routesDescriptors = [ r('app/group/:groupId/assignments', GroupAssignments, 'GROUP_ASSIGNMENTS_URI_FACTORY', true), r('app/group/:groupId/students', GroupStudents, 'GROUP_STUDENTS_URI_FACTORY', true), r('app/group/:groupId/exams', GroupExams, 'GROUP_EXAMS_URI_FACTORY', true), + r('app/group/:groupId/exams/:examId', GroupExams, 'GROUP_EXAMS_SPECIFIC_EXAM_URI_FACTORY', true), r('app/group/:groupId/user/:userId', GroupUserSolutions, 'GROUP_USER_SOLUTIONS_URI_FACTORY', true), r('app/instance/:instanceId', Instance, 'INSTANCE_URI_FACTORY', true), r('app/users', Users, 'USERS_URI', true), diff --git a/src/redux/modules/users.js b/src/redux/modules/users.js index 5cf978686..ce2eb9c35 100644 --- a/src/redux/modules/users.js +++ b/src/redux/modules/users.js @@ -253,6 +253,9 @@ const reducer = handleActions( [groupActionTypes.LOCK_STUDENT_EXAM_FULFILLED]: (state, { payload: { user } }) => state.setIn(['resources', user.id], createRecord({ state: resourceStatus.FULFILLED, data: user })), + + [groupActionTypes.UNLOCK_STUDENT_EXAM_FULFILLED]: (state, { payload }) => + state.setIn(['resources', payload.id], createRecord({ state: resourceStatus.FULFILLED, data: payload })), }), initialState ); diff --git a/src/redux/selectors/groups.js b/src/redux/selectors/groups.js index d985a2b3a..7d59265ce 100644 --- a/src/redux/selectors/groups.js +++ b/src/redux/selectors/groups.js @@ -79,6 +79,11 @@ export const groupPendingUserLock = createSelector( (groups, id) => groups && groups.getIn([id, 'pending-user-lock'], null) ); +export const groupPendingUserUnlock = createSelector( + [groupsSelector, getParam], + (groups, id) => groups && groups.getIn([id, 'pending-user-unlock'], null) +); + export const groupArchivedPendingChange = createSelector( [groupsSelector, getParam], (groups, id) => groups && groups.getIn([id, 'pending-archived'], false) diff --git a/src/redux/selectors/usersGroups.js b/src/redux/selectors/usersGroups.js index 9ae9f41a4..84c65a25c 100644 --- a/src/redux/selectors/usersGroups.js +++ b/src/redux/selectors/usersGroups.js @@ -78,6 +78,22 @@ export const studentsOfGroupSelector = createSelector( } ); +export const lockedStudentsOfGroupSelector = createSelector( + [readyUsersDataSelector, getParam, getState], + (users, groupId, state) => { + const studentIds = studentsIdsOfGroup(groupId)(state); + const studentsIndex = new Set(studentIds); + const now = Date.now() / 1000; + return users.filter( + user => + user.privateData && + user.privateData.groupLock === groupId && + (!user.privateData.groupLockExpiration || user.privateData.groupLockExpiration <= now) && + studentsIndex.has(user.id) + ); + } +); + // quite inefficient methods for filtering relevant groups (use carefully, maybe we will replace them in the future) export const studentOfSelector = userId => createSelector(groupsSelector, groups =>