diff --git a/recodex-web.spec b/recodex-web.spec index 7a2231bb8..00f7ef372 100644 --- a/recodex-web.spec +++ b/recodex-web.spec @@ -1,8 +1,8 @@ %define name recodex-web %define short_name web-app %define version 2.10.0 -%define unmangled_version 137ce75c61aec51b5c721de71173ae6f67266283 -%define release 2 +%define unmangled_version 68e609c525256e3def51c63e6e449849c8794ddf +%define release 3 Summary: ReCodEx web-app component Name: %{name} diff --git a/src/components/Groups/GroupExamPending/GroupExamPending.js b/src/components/Groups/GroupExamPending/GroupExamPending.js index dcc043da0..44e9f044d 100644 --- a/src/components/Groups/GroupExamPending/GroupExamPending.js +++ b/src/components/Groups/GroupExamPending/GroupExamPending.js @@ -1,20 +1,14 @@ import React, { Component } from 'react'; import PropTypes from 'prop-types'; -import { FormattedMessage, injectIntl } from 'react-intl'; -import { Modal } from 'react-bootstrap'; - -import ExamForm, { - prepareInitValues as prepareExamInitValues, - transformSubmittedData as transformExamData, -} from '../../forms/ExamForm'; -import Button, { TheButtonGroup } from '../../widgets/TheButton'; +import { FormattedMessage } from 'react-intl'; + +import ExamLockButtonContainer from '../../../containers/ExamLockButtonContainer'; import Callout from '../../widgets/Callout'; -import Icon, { BanIcon, ClockIcon, EditIcon, GroupExamsIcon, LoadingIcon } from '../../icons'; +import { InfoIcon, GroupExamsIcon } from '../../icons'; import DateTime from '../../widgets/DateTime'; import Explanation from '../../widgets/Explanation'; -import { getErrorMessage } from '../../../locales/apiErrorMessages'; -import { hasPermissions } from '../../../helpers/common'; +import { isStudentRole } from '../../helpers/usersRoles'; const REFRESH_INTERVAL = 1; // [s] @@ -54,47 +48,157 @@ class GroupExamPending extends Component { render() { const { + id, privateData: { examBegin, examEnd, examLockStrict }, - currentUser, + currentUser: { + privateData: { groupLock, isGroupLockStrict, ipLock, role }, + }, } = this.props; + const isStudent = isStudentRole(role); - //console.log(currentUser); return ( this.state.isExam && ( - - ) : this.state.hasExam ? ( - - ) : null - }> - TODO - {this.state.hasExam && ( - + }> +

+ {isStudent && groupLock ? ( + + ) : ( + + )} +

+ + {isStudent ? ( + <> + {groupLock ? ( + <> +
+ + : + + +
+ +
+ {' '} + {isGroupLockStrict ? ( + + ) : ( + + )} +
+ + {ipLock && ( +
+ {ipLock} }} + /> +
+ )} + + ) : ( +
+ + + + + + +
+ + +
+ + : + + +
+ +

+ + {' '} + {examLockStrict ? ( + + ) : ( + + )} +

+
+ )} + +
+
+ +
+ + ) : ( + - - + - - - - @@ -312,6 +316,7 @@ GroupAssignments.propTypes = { userId: PropTypes.string.isRequired, effectiveRole: PropTypes.string, group: ImmutablePropTypes.map, + currentUser: ImmutablePropTypes.map, groupsAccessor: PropTypes.func.isRequired, instance: ImmutablePropTypes.map, students: PropTypes.array, @@ -336,6 +341,7 @@ const mapStateToProps = (state, { params: { groupId } }) => { return { group: groupSelector(state, groupId), + currentUser: loggedInUserSelector(state), groupsAccessor: groupDataAccessorSelector(state), userId, effectiveRole: getLoggedInUserEffectiveRole(state), diff --git a/src/pages/GroupExams/GroupExams.js b/src/pages/GroupExams/GroupExams.js index 70f3d7049..92186dddd 100644 --- a/src/pages/GroupExams/GroupExams.js +++ b/src/pages/GroupExams/GroupExams.js @@ -10,7 +10,6 @@ import Page from '../../components/layout/Page'; import { GroupNavigation } from '../../components/layout/Navigation'; import Box from '../../components/widgets/Box'; import GroupArchivedWarning from '../../components/Groups/GroupArchivedWarning'; -import GroupExamPending from '../../components/Groups/GroupExamPending'; import { GroupExamsIcon } from '../../components/icons'; import { fetchGroup, fetchGroupIfNeeded, setExamPeriod, removeExamPeriod } from '../../redux/modules/groups'; @@ -57,12 +56,11 @@ class GroupExams extends Component { - - } title={} loading={} failed={}> - {data => ( + {(data, currentUser) => (
@@ -133,6 +135,8 @@ class GroupInfo extends Component {
)} + + {!hasPermissions(data, 'viewPublicDetail') && ( @@ -259,6 +263,7 @@ GroupInfo.propTypes = { params: PropTypes.shape({ groupId: PropTypes.string.isRequired }).isRequired, userId: PropTypes.string.isRequired, group: ImmutablePropTypes.map, + currentUser: ImmutablePropTypes.map, groupsAccessor: PropTypes.func.isRequired, instance: ImmutablePropTypes.map, primaryAdmins: PropTypes.array, @@ -290,6 +295,7 @@ const mapStateToProps = (state, { params: { groupId } }) => { return { group: groupSelector(state, groupId), + currentUser: loggedInUserSelector(state), userId, groups: groupsSelector(state), groupsAccessor: groupDataAccessorSelector(state), diff --git a/src/pages/GroupStudents/GroupStudents.js b/src/pages/GroupStudents/GroupStudents.js index c38f68d9f..5e37c9b8c 100644 --- a/src/pages/GroupStudents/GroupStudents.js +++ b/src/pages/GroupStudents/GroupStudents.js @@ -13,6 +13,7 @@ import { LoadingGroupData, FailedGroupLoading } from '../../components/Groups/he import { StudentsIcon, BanIcon } from '../../components/icons'; import ResourceRenderer from '../../components/helpers/ResourceRenderer'; import AddStudent from '../../components/Groups/AddStudent'; +import GroupExamPending from '../../components/Groups/GroupExamPending'; import LeaveJoinGroupButtonContainer from '../../containers/LeaveJoinGroupButtonContainer'; import GroupInvitationsContainer from '../../containers/GroupInvitationsContainer'; @@ -128,12 +129,12 @@ class GroupStudents extends Component { return ( } title={} loading={} failed={}> - {data => { + {(data, loggedUser) => { const canLeaveGroup = !isGroupAdmin && !isGroupSupervisor && @@ -173,6 +174,8 @@ class GroupStudents extends Component { )} + {} + {data.organizational && (
@@ -203,112 +206,99 @@ class GroupStudents extends Component { )} - - {loggedUser => ( - <> - {!data.organizational && hasPermissions(data, 'viewAssignments', 'viewStudents') && ( - - + {!data.organizational && hasPermissions(data, 'viewAssignments', 'viewStudents') && ( + + + + } + unlimitedHeight + noPadding> + + {groupStats => ( + + {assignments => ( + + {shadowAssignments => ( + + {runtimes => ( + + data.archived ? null : ( + + ) + } + /> + )} + + )} + + )} + + )} + + + + + )} + + { + // unfortunatelly, this cannot be covered by permission hints at the moment, since addStudent involes both student and group + !data.organizational && + !data.archived && + (hasPermissions(data, 'inviteStudents') || hasPermissions(data, 'editInvitations')) && ( + + {hasPermissions(data, 'inviteStudents') && ( + - } - unlimitedHeight - noPadding> - - {groupStats => ( - - {assignments => ( - - {shadowAssignments => ( - - {runtimes => ( - - data.archived ? null : ( - - ) - } - /> - )} - - )} - - )} - - )} - + title={} + isOpen> + - - )} + )} - { - // unfortunatelly, this cannot be covered by permission hints at the moment, since addStudent involes both student and group - !data.organizational && - !data.archived && - (hasPermissions(data, 'inviteStudents') || hasPermissions(data, 'editInvitations')) && ( - - {hasPermissions(data, 'inviteStudents') && ( - - - } - isOpen> - - - - )} - - - - } - isOpen> - - - - - ) - } - - )} - + + } + isOpen> + + + + + ) + } ); }} @@ -323,6 +313,7 @@ GroupStudents.propTypes = { loggedUser: ImmutablePropTypes.map, effectiveRole: PropTypes.string, group: ImmutablePropTypes.map, + currentUser: ImmutablePropTypes.map, groupsAccessor: PropTypes.func.isRequired, invitableGroups: ImmutablePropTypes.map, instance: ImmutablePropTypes.map, diff --git a/src/pages/GroupUserSolutions/GroupUserSolutions.js b/src/pages/GroupUserSolutions/GroupUserSolutions.js index a23b79d05..b277df516 100644 --- a/src/pages/GroupUserSolutions/GroupUserSolutions.js +++ b/src/pages/GroupUserSolutions/GroupUserSolutions.js @@ -33,6 +33,7 @@ import FetchManyResourceRenderer from '../../components/helpers/FetchManyResourc import { LocalizedExerciseName } from '../../components/helpers/LocalizedNames'; import EnvironmentsListItem from '../../components/helpers/EnvironmentsList/EnvironmentsListItem'; import GroupArchivedWarning from '../../components/Groups/GroupArchivedWarning/GroupArchivedWarning'; +import GroupExamPending from '../../components/Groups/GroupExamPending'; import Callout from '../../components/widgets/Callout'; import { fetchUserIfNeeded } from '../../redux/modules/users'; @@ -42,6 +43,7 @@ import { fetchRuntimeEnvironments } from '../../redux/modules/runtimeEnvironment import { fetchGroupStudentsSolutions, fetchAssignmentSolversIfNeeded } from '../../redux/modules/solutions'; import { setSolutionReviewState } from '../../redux/modules/solutionReviews'; import { groupSelector, groupsAssignmentsSelector, groupDataAccessorSelector } from '../../redux/selectors/groups'; +import { loggedInUserSelector } from '../../redux/selectors/users'; import { assignmentEnvironmentsSelector, getUserSolutions, @@ -365,6 +367,7 @@ class GroupUserSolutions extends Component { groupId, userId, group, + currentUser, groupsAccessor, assignments, assignmentEnvironmentsSelector, @@ -384,15 +387,17 @@ class GroupUserSolutions extends Component { return ( } title={ }> - {group => ( + {(group, currentUser) => (
+ + endpoint: `/groups/${groupId}/relocate/${newParentId}`, }); +/* + * Exam-related stuff + */ + export const setExamFlag = (groupId, value = true) => createApiAction({ type: additionalActionTypes.SET_EXAM_FLAG, @@ -196,6 +202,22 @@ export const removeExamPeriod = groupId => meta: { groupId }, }); +export const lockStudentForExam = (groupId, userId) => + createApiAction({ + type: additionalActionTypes.LOCK_STUDENT_EXAM, + method: 'POST', + endpoint: `/groups/${groupId}/lock/${userId}`, + meta: { groupId, userId }, + }); + +export const unlockStudentFromExam = (groupId, userId) => + createApiAction({ + type: additionalActionTypes.UNLOCK_STUDENT_EXAM, + method: 'DELETE', + endpoint: `/groups/${groupId}/lock/${userId}`, + meta: { groupId, userId }, + }); + /** * Reducer */ @@ -298,6 +320,12 @@ const reducer = handleActions( [additionalActionTypes.SET_ARCHIVED_REJECTED]: (state, { meta: { groupId } }) => state.deleteIn(['resources', groupId, 'pending-archived']), + [additionalActionTypes.RELOCATE_FULFILLED]: (state, { payload }) => + payload.reduce( + (state, data) => state.setIn(['resources', data.id], createRecord({ state: resourceStatus.FULFILLED, data })), + state + ), + [additionalActionTypes.SET_EXAM_FLAG_PENDING]: (state, { meta: { groupId } }) => state.setIn(['resources', groupId, 'pending-group-type'], true), @@ -333,11 +361,25 @@ const reducer = handleActions( [additionalActionTypes.REMOVE_EXAM_PERIOD_REJECTED]: (state, { meta: { groupId } }) => state.deleteIn(['resources', groupId, 'pending-exam-period']), - [additionalActionTypes.RELOCATE_FULFILLED]: (state, { payload }) => - payload.reduce( - (state, data) => state.setIn(['resources', data.id], createRecord({ state: resourceStatus.FULFILLED, data })), - state - ), + [additionalActionTypes.LOCK_STUDENT_EXAM_PENDING]: (state, { meta: { groupId, userId } }) => + state.setIn(['resources', groupId, 'pending-user-lock'], userId), + + [additionalActionTypes.LOCK_STUDENT_EXAM_FULFILLED]: (state, { payload: { group }, meta: { groupId } }) => + state.deleteIn(['resources', groupId, 'pending-user-lock']).setIn(['resources', groupId, 'data'], fromJS(group)), + + [additionalActionTypes.LOCK_STUDENT_EXAM_REJECTED]: (state, { meta: { groupId } }) => + state.deleteIn(['resources', groupId, 'pending-user-lock']), + + [additionalActionTypes.UNLOCK_STUDENT_EXAM_PENDING]: (state, { meta: { groupId, userId } }) => + state.setIn(['resources', groupId, 'pending-user-unlock'], userId), + + [additionalActionTypes.UNLOCK_STUDENT_EXAM_FULFILLED]: (state, { meta: { groupId } }) => + state.deleteIn(['resources', groupId, 'pending-user-unlock']), + + [additionalActionTypes.UNLOCK_STUDENT_EXAM_REJECTED]: (state, { meta: { groupId } }) => + state.deleteIn(['resources', groupId, 'pending-user-unlock']), + + // external actions [assignmentsActionTypes.UPDATE_FULFILLED]: (state, { payload: { id: assignmentId, groupId } }) => state.updateIn(['resources', groupId, 'data', 'privateData', 'assignments'], assignments => diff --git a/src/redux/modules/users.js b/src/redux/modules/users.js index c156b4bf4..5cf978686 100644 --- a/src/redux/modules/users.js +++ b/src/redux/modules/users.js @@ -14,6 +14,7 @@ import { actionTypes as paginationActionTypes } from './pagination'; import { actionTypes as exercisesAuthorsActionTypes } from './exercisesAuthors'; import { actionTypes as registrationActionTypes } from './registration'; import { actionTypes as authActionTypes } from './authTypes'; +import { additionalActionTypes as groupActionTypes } from './groups'; import { arrayToObject } from '../../helpers/common'; @@ -249,6 +250,9 @@ const reducer = handleActions( user && user.id ? state.setIn(['resources', user.id], createRecord({ state: resourceStatus.FULFILLED, data: user })) : state, + + [groupActionTypes.LOCK_STUDENT_EXAM_FULFILLED]: (state, { payload: { user } }) => + state.setIn(['resources', user.id], createRecord({ state: resourceStatus.FULFILLED, data: user })), }), initialState ); diff --git a/src/redux/selectors/groups.js b/src/redux/selectors/groups.js index dc84181bb..d985a2b3a 100644 --- a/src/redux/selectors/groups.js +++ b/src/redux/selectors/groups.js @@ -74,6 +74,11 @@ export const groupTypePendingChange = createSelector( (groups, id) => groups && groups.getIn([id, 'pending-group-type'], false) ); +export const groupPendingUserLock = createSelector( + [groupsSelector, getParam], + (groups, id) => groups && groups.getIn([id, 'pending-user-lock'], null) +); + export const groupArchivedPendingChange = createSelector( [groupsSelector, getParam], (groups, id) => groups && groups.getIn([id, 'pending-archived'], false)
+ : + +

+ + +

+

+ + +

+
+ : +
+ : + {examLockStrict ? ( @@ -135,15 +239,21 @@ class GroupExamPending extends Component { } GroupExamPending.propTypes = { + id: PropTypes.string.isRequired, privateData: PropTypes.shape({ examBegin: PropTypes.number, examEnd: PropTypes.number, examLockStrict: PropTypes.bool, }).isRequired, archived: PropTypes.bool, - currentUser: PropTypes.object, - //addNotification: PropTypes.func.isRequired, - intl: PropTypes.object, + currentUser: PropTypes.shape({ + privateData: PropTypes.shape({ + ipLock: PropTypes.string, + groupLock: PropTypes.string, + isGroupLockStrict: PropTypes.bool, + role: PropTypes.string, + }).isRequired, + }), }; -export default injectIntl(GroupExamPending); +export default GroupExamPending; diff --git a/src/components/Groups/GroupExamStatus/GroupExamStatus.js b/src/components/Groups/GroupExamStatus/GroupExamStatus.js index ff6ef5b7a..4954788f3 100644 --- a/src/components/Groups/GroupExamStatus/GroupExamStatus.js +++ b/src/components/Groups/GroupExamStatus/GroupExamStatus.js @@ -7,6 +7,7 @@ import ExamForm, { prepareInitValues as prepareExamInitValues, transformSubmittedData as transformExamData, } from '../../forms/ExamForm'; +import ExamLockButtonContainer from '../../../containers/ExamLockButtonContainer'; import Button, { TheButtonGroup } from '../../widgets/TheButton'; import Callout from '../../widgets/Callout'; import Icon, { BanIcon, ClockIcon, EditIcon, GroupExamsIcon, LoadingIcon } from '../../icons'; @@ -14,7 +15,8 @@ import DateTime from '../../widgets/DateTime'; import Explanation from '../../widgets/Explanation'; import { getErrorMessage } from '../../../locales/apiErrorMessages'; -import { hasPermissions } from '../../../helpers/common'; +import { isStudentRole } from '../../helpers/usersRoles'; +import { hasPermissions, shallowCompare } from '../../../helpers/common'; const REFRESH_INTERVAL = 1; // [s] @@ -90,8 +92,10 @@ class GroupExamStatus extends Component { }; periodicRefresh = () => { - this.setState(GroupExamStatus.getDerivedStateFromProps(this.props, this.state)); - // console.log(this.state); + const newState = GroupExamStatus.getDerivedStateFromProps(this.props, this.state); + if (newState.hasExam || !shallowCompare(this.state, newState)) { + this.setState(newState); + } }; componentDidMount() { @@ -111,7 +115,17 @@ class GroupExamStatus extends Component { } render() { - const { group, examBeginImmediately, examEndRelative, pending, removeExamPeriod } = this.props; + const { + group, + examBeginImmediately, + examEndRelative, + pending, + removeExamPeriod, + currentUser: { + privateData: { ipLock, groupLock, isGroupLockStrict, role }, + }, + } = this.props; + const isStudent = isStudentRole(role); return ( <> @@ -141,56 +155,70 @@ class GroupExamStatus extends Component { - - - - - - - + + - + + )} + + + ) : ( + + ) + }> + {group.privateData.examLockStrict ? ( + + ) : ( + + )} + + + + )} + + {isStudent && ipLock && ( + + + + + )}
+ : +
+ : +
- : - - - {group.privateData.examLockStrict ? ( - - ) : ( - - )} - - + + {!isStudent && ( +
+ : + + + {group.privateData.examLockStrict ? ( + ) : ( - - ) - }> - {group.privateData.examLockStrict ? ( - - ) : ( - - )} - -
+ : + + {ipLock} +
)} @@ -271,6 +299,50 @@ class GroupExamStatus extends Component { )} + + {isStudent && this.state.examInProgress && ( + <> +
+ {groupLock ? ( + <> +
+ + . +
+
+ {' '} + {isGroupLockStrict ? ( + + ) : ( + + )} +
+ + ) : ( + + )} + + )} + +
+
+ +
{hasPermissions(group, 'setExamPeriod') && ( @@ -320,12 +392,21 @@ class GroupExamStatus extends Component { GroupExamStatus.propTypes = { group: PropTypes.shape({ + id: PropTypes.string.isRequired, privateData: PropTypes.shape({ examBegin: PropTypes.number, examEnd: PropTypes.number, examLockStrict: PropTypes.bool, }).isRequired, }).isRequired, + currentUser: PropTypes.shape({ + privateData: PropTypes.shape({ + ipLock: PropTypes.string, + groupLock: PropTypes.string, + isGroupLockStrict: PropTypes.bool, + role: PropTypes.string, + }).isRequired, + }).isRequired, examBeginImmediately: PropTypes.bool, examEndRelative: PropTypes.bool, pending: PropTypes.bool, diff --git a/src/components/buttons/ExamLockButton/ExamLockButton.js b/src/components/buttons/ExamLockButton/ExamLockButton.js new file mode 100644 index 000000000..4c2d3fa20 --- /dev/null +++ b/src/components/buttons/ExamLockButton/ExamLockButton.js @@ -0,0 +1,24 @@ +import React from 'react'; +import PropTypes from 'prop-types'; +import { FormattedMessage } from 'react-intl'; +import { UserLockIcon, LoadingIcon } from '../../icons'; +import Button from '../../widgets/TheButton'; + +const ExamLockButton = ({ pending, disabled = false, lockUserForExam, ...props }) => ( + +); + +ExamLockButton.propTypes = { + pending: PropTypes.bool.isRequired, + disabled: PropTypes.bool, + lockUserForExam: PropTypes.func.isRequired, +}; + +export default ExamLockButton; diff --git a/src/components/buttons/ExamLockButton/index.js b/src/components/buttons/ExamLockButton/index.js new file mode 100644 index 000000000..c6dd61a2f --- /dev/null +++ b/src/components/buttons/ExamLockButton/index.js @@ -0,0 +1,2 @@ +import ExamLockButton from './ExamLockButton'; +export default ExamLockButton; diff --git a/src/components/icons/index.js b/src/components/icons/index.js index 0c960ff34..c4f19994d 100644 --- a/src/components/icons/index.js +++ b/src/components/icons/index.js @@ -154,6 +154,7 @@ export const UndoIcon = props => ; export const UnlockIcon = props => ; export const UploadIcon = props => ; export const UserIcon = props => ; +export const UserLockIcon = props => ; export const UserProfileIcon = props => ; export const VisibleIcon = ({ visible = true, ...props }) => visible ? ( diff --git a/src/containers/ExamLockButtonContainer/ExamLockButtonContainer.js b/src/containers/ExamLockButtonContainer/ExamLockButtonContainer.js new file mode 100644 index 000000000..f22f7b049 --- /dev/null +++ b/src/containers/ExamLockButtonContainer/ExamLockButtonContainer.js @@ -0,0 +1,65 @@ +import React from 'react'; +import PropTypes from 'prop-types'; +import ImmutablePropTypes from 'react-immutable-proptypes'; +import { connect } from 'react-redux'; +import { injectIntl } from 'react-intl'; +import { defaultMemoize } from 'reselect'; + +import ExamLockButton from '../../components/buttons/ExamLockButton'; +import { lockStudentForExam } from '../../redux/modules/groups'; +import { groupPendingUserLock } from '../../redux/selectors/groups'; +import { loggedInUserSelector } from '../../redux/selectors/users'; +import ResourceRenderer from '../../components/helpers/ResourceRenderer'; +import { getErrorMessage } from '../../locales/apiErrorMessages'; +import { addNotification } from '../../redux/modules/notifications'; + +import { isStudentRole } from '../../components/helpers/usersRoles'; + +const lockStudentHandlingErrors = defaultMemoize( + (userId, lockStudentForExam, addNotification, formatMessage) => () => + lockStudentForExam(userId).catch(err => { + addNotification(getErrorMessage(formatMessage)(err), false); + }) +); + +const ExamLockButtonContainer = ({ + groupId, + currentUser, + pending = null, + lockStudentForExam, + addNotification, + intl: { formatMessage }, + ...props +}) => ( + + {({ id, privateData: { groupLock, role } }) => ( + + )} + +); + +ExamLockButtonContainer.propTypes = { + groupId: PropTypes.string.isRequired, + currentUser: ImmutablePropTypes.map, + pending: PropTypes.string, + lockStudentForExam: PropTypes.func.isRequired, + addNotification: PropTypes.func.isRequired, + intl: PropTypes.object, +}; + +const mapStateToProps = (state, { groupId }) => ({ + pending: groupPendingUserLock(state, groupId), + currentUser: loggedInUserSelector(state), +}); + +const mapDispatchToProps = (dispatch, { groupId }) => ({ + lockStudentForExam: userId => dispatch(lockStudentForExam(groupId, userId)), + addNotification: (...args) => dispatch(addNotification(...args)), +}); + +export default connect(mapStateToProps, mapDispatchToProps)(injectIntl(ExamLockButtonContainer)); diff --git a/src/containers/ExamLockButtonContainer/index.js b/src/containers/ExamLockButtonContainer/index.js new file mode 100644 index 000000000..c2b5869cf --- /dev/null +++ b/src/containers/ExamLockButtonContainer/index.js @@ -0,0 +1,2 @@ +import ExamLockButtonContainer from './ExamLockButtonContainer'; +export default ExamLockButtonContainer; diff --git a/src/helpers/common.js b/src/helpers/common.js index c22b07c47..004078966 100644 --- a/src/helpers/common.js +++ b/src/helpers/common.js @@ -215,13 +215,44 @@ export const getFirstItemInOrder = (arr, comarator = _defaultComparator) => { return res; }; +/** + * Compare two entities (scalars, arrays, or objects). In case of arrays and objects, + * the items/properties are compared with strict '==='. + * @param {*} a + * @param {*} b + * @returns {boolean} true if the values are equal + */ +export const shallowCompare = (a, b) => { + if (typeof a !== typeof b) { + return false; + } + + if (typeof a !== 'object' || a === null || b === null) { + return a === b; // compare scalars + } + + if (Array.isArray(a) !== Array.isArray(b)) { + return false; + } + + if (Array.isArray(a)) { + // compare arrays + return a.length === b.length ? a.every((val, idx) => val === b[idx]) : false; + } else { + // compare objects + const aKeys = Object.keys(a); + const bKeys = new Set(Object.keys(b)); + return aKeys.length === bKeys.size ? aKeys.every(key => bKeys.has(key) && a[key] === b[key]) : false; + } +}; + /** * Compare two entities (scalars, arrays, or objects). In case of arrays and objects, * the items/properties are compared recursively. * @param {*} a * @param {*} b * @param {boolean} emptyObjectArrayEquals if true, {} and [] are treated as equal - * @returns {boolean} true if the values are matching + * @returns {boolean} true if the values are equal */ export const deepCompare = (a, b, emptyObjectArrayEquals = false) => { if (typeof a !== typeof b) { diff --git a/src/locales/cs.json b/src/locales/cs.json index 0735ca5e3..fe82555b8 100644 --- a/src/locales/cs.json +++ b/src/locales/cs.json @@ -1064,10 +1064,13 @@ "app.groupExams.button.terminate": "Ukončit nyní", "app.groupExams.button.terminate.confirm": "Opravdu si přejete okamžitě ukončit probíhající zkoušku?", "app.groupExams.endAt": "Končí v", + "app.groupExams.endAtLong": "Zkouška končí v", "app.groupExams.examModal.create": "Naplánovat zkouškový termín v této skupině", "app.groupExams.examModal.edit": "Upravit naplánovaný termín v této skupině", "app.groupExams.examPlanned": "Je naplánovaný zkouškový termín", "app.groupExams.inProgress": "Probíhá zkouška, skupina je v zabezpečeném režimu", + "app.groupExams.ipLockInfo": "Vaše akce budou příjmány pouze z IP adresy [{ipLock}] dokud budete v uzamčeném režimu. Kontaktujte vašeho zkoušejícího pokud potřebujete změnit svoje umístění.", + "app.groupExams.ipLocked": "IP zamčena", "app.groupExams.listBoxTitle": "Předchozí zkoušky", "app.groupExams.lockRegular": "běžný", "app.groupExams.lockRegularExplanation": "Studenti, kteří se účastní zkoušky, budou moci číst data z ostatních skupin (a tedy i použít části dříve odevzdaných řešení).", @@ -1075,9 +1078,19 @@ "app.groupExams.lockStrict": "striktní", "app.groupExams.lockStrictExplanation": "Studenti, kteří se účastní zkoušky, nebudou moct přistupovat do jiných skupin ani v režimu pro čtení (jsou tedy odříznuti od jejich dříve odevzdaných řešení).", "app.groupExams.lockStrictTitle": "Striktní zámek", + "app.groupExams.lockStudentButton": "Zamkount se", + "app.groupExams.lockedStudentInfo": "Nyní můžete vidět zkouškové úlohy a odevzdávat u nich řešení.", + "app.groupExams.lockedStudentInfoRegular": "K ostatním skupinám můžete přistupovat pouze v režimu pro čtení dokud jste v uzamčeném režimu.", + "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.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.groupInfo.title": "Podrobnosti a metadata skupiny", "app.groupInvitationForm.expireAt": "Konec platnosti:", diff --git a/src/locales/en.json b/src/locales/en.json index aeb7507ca..93bbee7e4 100644 --- a/src/locales/en.json +++ b/src/locales/en.json @@ -1064,10 +1064,13 @@ "app.groupExams.button.terminate": "Terminate Now", "app.groupExams.button.terminate.confirm": "Do you really wish to terminate the exam immediately?", "app.groupExams.endAt": "Ends at", + "app.groupExams.endAtLong": "Exam ends at", "app.groupExams.examModal.create": "Plan an examination in this group", "app.groupExams.examModal.edit": "Update scheduled examination in this group", "app.groupExams.examPlanned": "There is an exam scheduled", "app.groupExams.inProgress": "Exam in progress, the group is in secured mode", + "app.groupExams.ipLockInfo": "Your actions are restricted to IP address [{ipLock}] until the exam lock expires. Contact your exam supervisor if you require relocation.", + "app.groupExams.ipLocked": "IP locked", "app.groupExams.listBoxTitle": "Previous exams", "app.groupExams.lockRegular": "regular", "app.groupExams.lockRegularExplanation": "Users taking the exam will be able to access other groups in read-only mode (for instance to utilize pieces of previously submitted code).", @@ -1075,9 +1078,19 @@ "app.groupExams.lockStrict": "strict", "app.groupExams.lockStrictExplanation": "Users taking the exam will not be allowed to access any other group, not even for reading (so thet are cut of source codes they submitted before the exam).", "app.groupExams.lockStrictTitle": "Strict lock", + "app.groupExams.lockStudentButton": "Lock In", + "app.groupExams.lockedStudentInfo": "You may now see and submit solutions to exam assignments.", + "app.groupExams.lockedStudentInfoRegular": "You may access other groups in read-only mode until the exam lock expires.", + "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.timeAccuracyWarning": "Your local system clock should be sufficiently synchronized or this component may not work properly.", "app.groupExams.title": "Group Exam Terms", "app.groupInfo.title": "Group Details and Metadata", "app.groupInvitationForm.expireAt": "Expire at:", diff --git a/src/pages/GroupAssignments/GroupAssignments.js b/src/pages/GroupAssignments/GroupAssignments.js index 394f77b6e..6f128bc33 100644 --- a/src/pages/GroupAssignments/GroupAssignments.js +++ b/src/pages/GroupAssignments/GroupAssignments.js @@ -15,6 +15,7 @@ import { AssignmentsIcon, AddIcon, BanIcon } from '../../components/icons'; import AssignmentsTable from '../../components/Assignments/Assignment/AssignmentsTable'; import ShadowAssignmentsTable from '../../components/Assignments/ShadowAssignment/ShadowAssignmentsTable'; import GroupArchivedWarning from '../../components/Groups/GroupArchivedWarning/GroupArchivedWarning'; +import GroupExamPending from '../../components/Groups/GroupExamPending'; import ResourceRenderer from '../../components/helpers/ResourceRenderer'; import LeaveJoinGroupButtonContainer from '../../containers/LeaveJoinGroupButtonContainer'; import ExercisesListContainer from '../../containers/ExercisesListContainer'; @@ -27,7 +28,7 @@ import { create as createExercise } from '../../redux/modules/exercises'; import { fetchRuntimeEnvironments } from '../../redux/modules/runtimeEnvironments'; import { loggedInUserIdSelector } from '../../redux/selectors/auth'; -import { getLoggedInUserEffectiveRole } from '../../redux/selectors/users'; +import { getLoggedInUserEffectiveRole, loggedInUserSelector } from '../../redux/selectors/users'; import { groupSelector, groupDataAccessorSelector, @@ -118,6 +119,7 @@ class GroupAssignments extends Component { render() { const { group, + currentUser, groupsAccessor, students, effectiveRole, @@ -135,12 +137,12 @@ class GroupAssignments extends Component { return ( } title={} loading={} failed={}> - {data => { + {(data, currentUser) => { const canLeaveGroup = !isGroupAdmin && !isGroupSupervisor && @@ -180,6 +182,8 @@ class GroupAssignments extends Component { )} + + {data.organizational && (