Skip to content

Commit

Permalink
Adding participating students list (for exam term) with unlock button…
Browse files Browse the repository at this point in the history
…s so the teacher can release them.
  • Loading branch information
krulis-martin committed Apr 11, 2024
1 parent 87acb2f commit e31a49d
Show file tree
Hide file tree
Showing 17 changed files with 286 additions and 27 deletions.
43 changes: 26 additions & 17 deletions src/components/Groups/GroupExamsTable/GroupExamsTable.js
Expand Up @@ -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';
Expand All @@ -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 ? (
<Table className="mb-0">
<Table className="mb-0" hover>
<thead>
<tr>
<th />
Expand All @@ -28,12 +29,12 @@ const GroupExamsTable = ({ exams = null, selected = null, setSelected = null })
<th>
<FormattedMessage id="app.groupExamsTable.lockType" defaultMessage="Lock type" />
</th>
{setSelected && <th />}
{linkFactory && <th />}
</tr>
</thead>
<tbody>
{sortExams(exams).map((exam, idx) => (
<tr key={exam.id} className={selected === exam.id ? 'table-primary' : ''}>
<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 />
Expand All @@ -50,16 +51,21 @@ const GroupExamsTable = ({ exams = null, selected = null, setSelected = null })
)}
</em>
</td>
{setSelected && (
<td>
<Button
size="xs"
variant="primary"
disabled={selected === exam.id}
onClick={() => setSelected(exam.id)}>
<VisibleIcon visible gapRight />
<FormattedMessage id="app.groupExamsTable.selectButton" defaultMessage="Detail" />
</Button>
{linkFactory && (
<td className="text-right">
<Link to={linkFactory(exam.id) || ''}>
<Button
size="xs"
variant={selected === String(exam.id) ? 'primary-outline' : 'primary'}
disabled={!linkFactory(exam.id)}>
<VisibleIcon visible={selected !== String(exam.id)} gapRight />
{selected === String(exam.id) ? (
<FormattedMessage id="app.groupExamsTable.unselectButton" defaultMessage="Unselect" />
) : (
<FormattedMessage id="app.groupExamsTable.selectButton" defaultMessage="Detail" />
)}
</Button>
</Link>
</td>
)}
</tr>
Expand All @@ -69,15 +75,18 @@ const GroupExamsTable = ({ exams = null, selected = null, setSelected = null })
) : (
<div className="text-center text-muted p-2">
<em>
<FormattedMessage id="app.groupExams.noPreviousExams" defaultMessage="There are no previous exams recorded." />
<FormattedMessage
id="app.groupExamsTable.noPreviousExams"
defaultMessage="There are no previous exams recorded."
/>
</em>
</div>
);

GroupExamsTable.propTypes = {
exams: PropTypes.array,
selected: PropTypes.string,
setSelected: PropTypes.func,
linkFactory: PropTypes.func,
};

export default GroupExamsTable;
58 changes: 58 additions & 0 deletions 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 ? (
<Table className="m-0" hover>
<tbody>
{sortStudents(lockedStudents).map(student => (
<tr key={student.id}>
<td>
<UsersName
{...student}
currentUserId={currentUser.id}
showEmail="full"
showExternalIdentifiers
listItem
/>
</td>
<td className="text-right">
<ExamUnlockButtonContainer groupId={groupId} userId={student.id} size="xs" />
</td>
</tr>
))}
</tbody>
</Table>
) : (
<div className="text-center text-muted p-4">
<em>
<FormattedMessage
id="app.lockedStudentsTable.noLockedStudents"
defaultMessage="There are no locked students yet."
/>
</em>
</div>
);

LockedStudentsTable.propTypes = {
groupId: PropTypes.string.isRequired,
lockedStudents: PropTypes.array,
currentUser: PropTypes.shape({
id: PropTypes.string.isRequired,
}),
intl: PropTypes.object.isRequired,
};

export default injectIntl(LockedStudentsTable);
2 changes: 2 additions & 0 deletions src/components/Groups/LockedStudentsTable/index.js
@@ -0,0 +1,2 @@
import LockedStudentsTable from './LockedStudentsTable';
export default LockedStudentsTable;
24 changes: 24 additions & 0 deletions 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 }) => (
<Button
{...props}
variant={disabled ? 'secondary' : 'warning'}
onClick={unlockUserForExam}
disabled={pending || disabled}>
{pending ? <LoadingIcon gapRight /> : <UnlockIcon gapRight />}
<FormattedMessage id="app.groupExams.unlockStudentButton" defaultMessage="Unlock" />
</Button>
);

ExamUnlockButton.propTypes = {
pending: PropTypes.bool.isRequired,
disabled: PropTypes.bool,
unlockUserForExam: PropTypes.func.isRequired,
};

export default ExamUnlockButton;
2 changes: 2 additions & 0 deletions src/components/buttons/ExamUnlockButton/index.js
@@ -0,0 +1,2 @@
import ExamUnlockButton from './ExamUnlockButton';
export default ExamUnlockButton;
15 changes: 13 additions & 2 deletions src/components/layout/Navigation/Navigation.js
Expand Up @@ -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) ? (
<strong className={className}>
{icon}
{caption}
Expand All @@ -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,
Expand Down
1 change: 1 addition & 0 deletions src/components/layout/Navigation/linkCreators.js
Expand Up @@ -49,6 +49,7 @@ export const createGroupLinks = (
caption: <FormattedMessage id="app.navigation.groupExams" defaultMessage="Exam Terms" />,
link: GROUP_EXAMS_URI_FACTORY(groupId),
icon: <GroupExamsIcon gapRight />,
match: (link, pathname) => pathname.startsWith(link),
},
];

Expand Down
@@ -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
}) => (
<ExamUnlockButton
{...props}
pending={pending}
unlockUserForExam={unlockStudentHandlingErrors(unlockStudentFromExam, addNotification, formatMessage)}
/>
);

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));
2 changes: 2 additions & 0 deletions src/containers/ExamUnlockButtonContainer/index.js
@@ -0,0 +1,2 @@
import ExamUnlockButtonContainer from './ExamUnlockButtonContainer';
export default ExamUnlockButtonContainer;
6 changes: 5 additions & 1 deletion src/locales/cs.json
Expand Up @@ -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.",
Expand Down Expand Up @@ -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.",
Expand Down
6 changes: 5 additions & 1 deletion src/locales/en.json
Expand Up @@ -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.",
Expand Down Expand Up @@ -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.",
Expand Down
5 changes: 3 additions & 2 deletions src/locales/whitelist_cs.json
Expand Up @@ -163,5 +163,6 @@
"app.systemMessagesList.text",
"generic.detail",
"generic.email",
"generic.role"
]
"generic.role",
"app.groupExamsTable.selectButton"
]

0 comments on commit e31a49d

Please sign in to comment.