Skip to content

Commit

Permalink
Implementing loading and visualization of group-exam locks.
Browse files Browse the repository at this point in the history
  • Loading branch information
krulis-martin committed May 22, 2024
1 parent 416675e commit 52199d6
Show file tree
Hide file tree
Showing 10 changed files with 131 additions and 11 deletions.
8 changes: 4 additions & 4 deletions src/components/Groups/GroupExamsTable/GroupExamsTable.js
Original file line number Diff line number Diff line change
Expand Up @@ -37,10 +37,10 @@ const GroupExamsTable = ({ exams = null, selected = null, linkFactory = null })
<tr key={exam.id} className={selected === String(exam.id) ? 'table-primary' : ''}>
<td className="text-bold">#{idx + 1}</td>
<td>
<DateTime unixts={exam.begin} showRelative showSeconds />
<DateTime unixts={exam.begin} showSeconds />
</td>
<td>
<DateTime unixts={exam.end} showRelative showSeconds />
<DateTime unixts={exam.end} showSeconds />
</td>
<td>
<em>
Expand All @@ -56,9 +56,9 @@ const GroupExamsTable = ({ exams = null, selected = null, linkFactory = null })
<Link to={linkFactory(exam.id) || ''}>
<Button
size="xs"
variant={selected === String(exam.id) ? 'primary-outline' : 'primary'}
variant={selected === String(exam.id) ? 'secondary' : 'primary'}
disabled={!linkFactory(exam.id)}>
<VisibleIcon visible={selected !== String(exam.id)} gapRight />
<VisibleIcon visible={selected !== String(exam.id)} className="text-light" gapRight />
{selected === String(exam.id) ? (
<FormattedMessage id="app.groupExamsTable.unselectButton" defaultMessage="Unselect" />
) : (
Expand Down
56 changes: 56 additions & 0 deletions src/components/Groups/LocksTable/LocksTable.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,56 @@
import React from 'react';
import PropTypes from 'prop-types';
import { FormattedMessage } from 'react-intl';
import { Table } from 'react-bootstrap';
import { defaultMemoize } from 'reselect';

import UsersNameContainer from '../../../containers/UsersNameContainer';
import DateTime from '../../widgets/DateTime';
import { LockIcon, UnlockIcon } from '../../icons';

const sortLocks = defaultMemoize(locks => [...locks].sort(({ createdAt: c1 }, { createdAt: c2 }) => c1 - c2));

const LocksTable = ({ locks }) =>
locks.length > 0 ? (
<Table className="m-0" hover>
<tbody>
{sortLocks(locks).map(lock => (
<tr key={lock.id}>
<td>
<UsersNameContainer userId={lock.studentId} showEmail="icon" showExternalIdentifiers />
</td>
<td>
<LockIcon className="text-muted" gapRight />
<DateTime unixts={lock.createdAt} showSeconds />
</td>
<td>
{lock.unlockedAt && (
<>
<UnlockIcon className="text-warning" gapRight />
<DateTime unixts={lock.unlockedAt} showSeconds />
</>
)}
</td>
<td className="shrink-col text-nowrap">
<code>{lock.remoteAddr}</code>
</td>
</tr>
))}
</tbody>
</Table>
) : (
<div className="text-center text-muted p-4">
<em>
<FormattedMessage
id="app.locksTable.noLockedStudents"
defaultMessage="There are no lock records for the selected exam."
/>
</em>
</div>
);

LocksTable.propTypes = {
locks: PropTypes.array.isRequired,
};

export default LocksTable;
2 changes: 2 additions & 0 deletions src/components/Groups/LocksTable/index.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,2 @@
import LocksTable from './LocksTable';
export default LocksTable;
2 changes: 1 addition & 1 deletion src/components/icons/index.js
Original file line number Diff line number Diff line change
Expand Up @@ -160,7 +160,7 @@ export const VisibleIcon = ({ visible = true, ...props }) =>
visible ? (
<Icon {...props} icon={['far', 'eye']} />
) : (
<Icon {...props} icon={['far', 'eye-slash']} className="text-muted" />
<Icon className="text-muted" {...props} icon={['far', 'eye-slash']} />
);
export const WarningIcon = props => <Icon {...props} icon="exclamation-triangle" />;
export const WorkingIcon = props => <Icon {...props} spin icon="cog" />;
Expand Down
2 changes: 2 additions & 0 deletions src/locales/cs.json
Original file line number Diff line number Diff line change
Expand Up @@ -1096,6 +1096,7 @@
"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.locksBoxTitle": "Zaznamenané události zamykání studentů",
"app.groupExams.noExam": "V tuto chvíli není naplánovaná žádná zkouška",
"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ě.",
Expand Down Expand Up @@ -1219,6 +1220,7 @@
"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.locksTable.noLockedStudents": "Pro vybranou úlohu neexistují žádné záznamy o uzamčení studentů ke zkoušce.",
"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.",
Expand Down
2 changes: 2 additions & 0 deletions src/locales/en.json
Original file line number Diff line number Diff line change
Expand Up @@ -1096,6 +1096,7 @@
"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.locksBoxTitle": "Recorded student locking events",
"app.groupExams.noExam": "There is currently no exam scheduled",
"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.",
Expand Down Expand Up @@ -1219,6 +1220,7 @@
"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.locksTable.noLockedStudents": "There are no lock records for the selected exam.",
"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.",
Expand Down
41 changes: 35 additions & 6 deletions src/pages/GroupExams/GroupExams.js
Original file line number Diff line number Diff line change
Expand Up @@ -13,18 +13,22 @@ 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 LocksTable from '../../components/Groups/LocksTable';
import { GroupExamsIcon } from '../../components/icons';

import { fetchGroup, fetchGroupIfNeeded, setExamPeriod, removeExamPeriod } from '../../redux/modules/groups';
import { fetchGroupExamLocksIfNeeded } from '../../redux/modules/groupExamLocks';
import { fetchByIds } from '../../redux/modules/users';
import { addNotification } from '../../redux/modules/notifications';
import { groupSelector, groupDataAccessorSelector, groupTypePendingChange } from '../../redux/selectors/groups';
import { groupExamLocksSelector } from '../../redux/selectors/groupExamLocks';
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 { hasPermissions, safeGet } from '../../helpers/common';
import ResourceRenderer from '../../components/helpers/ResourceRenderer';

const isExam = ({ privateData: { examBegin, examEnd } }) => {
const now = Date.now() / 1000;
Expand All @@ -43,12 +47,18 @@ class GroupExams extends Component {

componentDidMount() {
this.props.loadAsync();
if (this.props.params.examId) {
this.props.loadGroupExamLocks();
}
}

componentDidUpdate(prevProps) {
if (this.props.params.groupId !== prevProps.params.groupId) {
this.props.loadAsync();
}
if (this.props.params.examId !== prevProps.params.examId && this.props.params.examId) {
this.props.loadGroupExamLocks();
}
}

linkFactory = id => {
Expand All @@ -66,6 +76,7 @@ class GroupExams extends Component {
params: { examId = null },
group,
lockedStudents,
groupExamLocks,
currentUser,
groupsAccessor,
examBeginImmediately,
Expand Down Expand Up @@ -119,7 +130,17 @@ class GroupExams extends Component {
<Col xs={12}>
<Box
title={
<FormattedMessage id="app.groupExams.studentsBoxTitle" defaultMessage="Participating students" />
isExam(group) ? (
<FormattedMessage
id="app.groupExams.studentsBoxTitle"
defaultMessage="Participating students"
/>
) : (
<FormattedMessage
id="app.groupExams.locksBoxTitle"
defaultMessage="Recorded student locking events"
/>
)
}
noPadding
unlimitedHeight>
Expand All @@ -130,7 +151,11 @@ class GroupExams extends Component {
currentUser={currentUser}
/>
) : (
examId
groupExamLocks && (
<ResourceRenderer resource={groupExamLocks} bulkyLoading>
{locks => <LocksTable locks={locks} />}
</ResourceRenderer>
)
)}
</Box>
</Col>
Expand All @@ -145,11 +170,9 @@ class GroupExams extends Component {

GroupExams.propTypes = {
links: PropTypes.object.isRequired,
loadAsync: PropTypes.func.isRequired,
reload: PropTypes.func.isRequired,
params: PropTypes.shape({
groupId: PropTypes.string.isRequired,
examId: PropTypes.number,
examId: PropTypes.string,
}).isRequired,
group: ImmutablePropTypes.map,
currentUser: ImmutablePropTypes.map,
Expand All @@ -159,6 +182,10 @@ GroupExams.propTypes = {
examBeginImmediately: PropTypes.bool,
examEndRelative: PropTypes.bool,
examPendingChange: PropTypes.bool,
groupExamLocks: ImmutablePropTypes.map,
loadAsync: PropTypes.func.isRequired,
loadGroupExamLocks: PropTypes.func.isRequired,
reload: PropTypes.func.isRequired,
setExamPeriod: PropTypes.func.isRequired,
removeExamPeriod: PropTypes.func.isRequired,
addNotification: PropTypes.func.isRequired,
Expand All @@ -168,7 +195,7 @@ const examFormSelector = formValueSelector('exam');

export default withLinks(
connect(
(state, { params: { groupId } }) => ({
(state, { params: { groupId, examId } }) => ({
group: groupSelector(state, groupId),
groupsAccessor: groupDataAccessorSelector(state),
lockedStudents: lockedStudentsOfGroupSelector(state, groupId),
Expand All @@ -178,9 +205,11 @@ export default withLinks(
examBeginImmediately: examFormSelector(state, 'beginImmediately'),
examEndRelative: examFormSelector(state, 'endRelative'),
examPendingChange: groupTypePendingChange(state, groupId),
groupExamLocks: examId ? groupExamLocksSelector(state, groupId, examId) : null,
}),
(dispatch, { params: { groupId, examId } }) => ({
loadAsync: () => GroupExams.loadAsync({ groupId, examId }, dispatch),
loadGroupExamLocks: () => dispatch(fetchGroupExamLocksIfNeeded(groupId, examId)),
reload: () => dispatch(fetchGroup(groupId)),
addNotification: (...args) => dispatch(addNotification(...args)),
setExamPeriod: (begin, end, strict) => dispatch(setExamPeriod(groupId, begin, end, strict)),
Expand Down
18 changes: 18 additions & 0 deletions src/redux/modules/groupExamLocks.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,18 @@
import { handleActions } from 'redux-actions';
import factory, { initialState } from '../helpers/resourceManager';

/**
* Create actions & reducer
*/

const resourceName = 'groupExamLocks';
const { actions, reduceActions } = factory({
resourceName,
apiEndpointFactory: id => `/groups/${id}`, // its a hack, id is expected to be <groupId>/exam/<examId>
});

export const fetchGroupExamLocksIfNeeded = (groupId, examId) => actions.fetchOneIfNeeded(`${groupId}/exam/${examId}`);

const reducer = handleActions(Object.assign({}, reduceActions, {}), initialState);

export default reducer;
2 changes: 2 additions & 0 deletions src/redux/reducer.js
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,7 @@ import exercisesAuthors from './modules/exercisesAuthors';
import files from './modules/files';
import filesContent from './modules/filesContent';
import groups from './modules/groups';
import groupExamLocks from './modules/groupExamLocks';
import groupExercises from './modules/groupExercises';
import groupInvitations from './modules/groupInvitations';
import groupResults from './modules/groupResults';
Expand Down Expand Up @@ -81,6 +82,7 @@ const createRecodexReducers = (token, instanceId, lang) => ({
files,
filesContent,
groups,
groupExamLocks,
groupExercises,
groupInvitations,
groupResults,
Expand Down
9 changes: 9 additions & 0 deletions src/redux/selectors/groupExamLocks.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,9 @@
import { createSelector } from 'reselect';

const getGroupExamLocks = state => state.groupExamLocks;
const getGroupExamLocksResources = state => getGroupExamLocks(state).get('resources');
const getParams = (_, groupId, examId) => `${groupId}/exam/${examId}`;

export const groupExamLocksSelector = createSelector([getGroupExamLocksResources, getParams], (locks, id) =>
locks.get(id)
);

0 comments on commit 52199d6

Please sign in to comment.