From 12ff691ca31ff49699daf9252337182372168f32 Mon Sep 17 00:00:00 2001 From: Ben Lerner Date: Wed, 29 May 2024 17:48:36 -0400 Subject: [PATCH] Add the ability to print out the table of current PINs, such that they can be cut out and distributed to students. (Also, consistently and properly format nuids to be 9 digits wide, everywhere) --- app/packs/components/common/helpers.ts | 4 + .../components/common/student-dnd/index.tsx | 7 +- .../workflows/proctor/exams/index.scss | 16 ++ .../workflows/proctor/exams/index.tsx | 162 +++++++++++++++--- .../professor/exams/allocate-versions.tsx | 8 +- .../professor/exams/assign-staff/index.tsx | 10 +- .../professor/exams/submissions/index.tsx | 45 ++++- package.json | 1 + yarn.lock | 7 + 9 files changed, 214 insertions(+), 46 deletions(-) diff --git a/app/packs/components/common/helpers.ts b/app/packs/components/common/helpers.ts index f8b84d91a..69ae3fd1d 100644 --- a/app/packs/components/common/helpers.ts +++ b/app/packs/components/common/helpers.ts @@ -59,6 +59,10 @@ export function pluralize(number: number, singular: string, plural: string): str return `${number} ${plural}`; } +export function formatNuid(nuid: number | string): string { + return String(nuid).padStart(9, '0'); +} + export type PointsInfo = { regularPoints: number; extraCreditPoints: number; diff --git a/app/packs/components/common/student-dnd/index.tsx b/app/packs/components/common/student-dnd/index.tsx index e639f06b3..6c3f015b0 100644 --- a/app/packs/components/common/student-dnd/index.tsx +++ b/app/packs/components/common/student-dnd/index.tsx @@ -22,6 +22,7 @@ import { Route, Switch, } from 'react-router-dom'; +import { formatNuid } from '@hourglass/common/helpers'; import { AlertContext } from '@hourglass/common/alerts'; import { TabEditButton } from '@professor/exams/admin'; import { useFragment, graphql, useMutation } from 'react-relay'; @@ -125,7 +126,7 @@ const StudentBadge: React.FC<{ @@ -425,7 +426,7 @@ const Readonly: React.FC = (props) => {

No students

) : sortByName(unassigned).map((s) => (
  • - {s.displayName} + {s.displayName}
  • ))} @@ -441,7 +442,7 @@ const Readonly: React.FC = (props) => {
      {sortByName(r.students).map((s) => (
    • - {s.displayName} + {s.displayName}
    • ))}
    diff --git a/app/packs/components/workflows/proctor/exams/index.scss b/app/packs/components/workflows/proctor/exams/index.scss index 81d8802c8..da6acbfa5 100644 --- a/app/packs/components/workflows/proctor/exams/index.scss +++ b/app/packs/components/workflows/proctor/exams/index.scss @@ -32,3 +32,19 @@ align-items: 'center'; justify-content: 'space-between'; } + +.printTable { + page-break-inside: 'avoid'; + border-collapse: separate; + > tbody > tr > td { + border: 1px solid gray; + border-radius: 0.5em; + padding: 0.5em; + } + table.pinInfo { + tr.nuid { + td:first-child { padding-right: 2em;} + font-size: 80%; + } + } +} \ No newline at end of file diff --git a/app/packs/components/workflows/proctor/exams/index.tsx b/app/packs/components/workflows/proctor/exams/index.tsx index 367b94978..ba84ba91b 100644 --- a/app/packs/components/workflows/proctor/exams/index.tsx +++ b/app/packs/components/workflows/proctor/exams/index.tsx @@ -7,6 +7,7 @@ import React, { Suspense, useEffect, } from 'react'; +import NewWindow from 'react-new-window'; import RegularNavbar, { NavbarBreadcrumbs, NavbarItem } from '@hourglass/common/navbar'; import Select from 'react-select'; import { useParams } from 'react-router-dom'; @@ -22,6 +23,7 @@ import { Media, Alert, Modal, + Card, } from 'react-bootstrap'; import ReadableDate from '@hourglass/common/ReadableDate'; import { @@ -43,13 +45,14 @@ import { ExhaustiveSwitchError, SelectOption, SelectOptions, + formatNuid, useMutationWithDefaults, } from '@hourglass/common/helpers'; import { GiBugleCall } from 'react-icons/gi'; import { DateTime } from 'luxon'; import { IconType } from 'react-icons'; import './index.scss'; -import { BsListCheck } from 'react-icons/bs'; +import { BsListCheck, BsPrinterFill } from 'react-icons/bs'; import { NewMessages, PreviousMessages } from '@hourglass/common/messages'; import DocumentTitle from '@hourglass/common/documentTitle'; import { @@ -77,6 +80,7 @@ export interface Recipient { type: MessageType.Direct | MessageType.Room | MessageType.Version | MessageType.Exam; id: string; name: string; + nuid?: number; } export interface SplitRecipients { @@ -1867,6 +1871,29 @@ const ShowCurrentPins: React.FC<{ enabled, } = props; const [showing, setShowing] = useState(false); + const [pinWindow, togglePINWindow] = useState(false); + const res = useFragment( + graphql` + fragment exams_pins on Exam + @refetchable(queryName: "RegistrationPinRefetchQuery") { + registrations { + id + currentPin + pinValidated + } + } + `, + exam, + ); + const { registrations } = res; + const regsById = new Map(registrations.map((r) => [r.id, r])); + const studentPins = recipients.students.map((s) => ({ + id: s.id, + name: s.name, + nuid: s.nuid, + currentPin: regsById.get(s.id)?.currentPin, + pinValidated: regsById.get(s.id)?.pinValidated, + })); return ( <> + {pinWindow && ( + togglePINWindow(false)} + > + + + )} setShowing(false)} + onHide={() => { + setShowing(false); + togglePINWindow(false); + }} > - + Current PINs for students + @@ -1894,7 +1941,7 @@ const ShowCurrentPins: React.FC<{ @@ -1911,38 +1958,94 @@ const ShowCurrentPins: React.FC<{ ); }; +export const PrintablePinsTable: React.FC<{ + students: Array<{ + name: string, + nuid: number, + pinValidated: boolean, + currentPin: string, + }>, +}> = (props) => { + const { + students, + } = props; + const chunkSize = 4; + const sortedStudents = students.toSorted((s1, s2) => s1.name.localeCompare(s2.name)); + const allInChunks = Array.from( + { length: Math.ceil(sortedStudents.length / chunkSize) }, + (v, i) => sortedStudents.slice(i * chunkSize, i * chunkSize + chunkSize), + ); + + return ( + + + {Array.from( + { length: chunkSize }, + () => + + {allInChunks.map((chunk) => ( + + {chunk.map((reg) => { + let row: React.ReactElement; + if (reg.pinValidated) { + row = Already validated; + } else if (reg.currentPin) { + row = {reg.currentPin}; + } else { + row = none required; + } + return ( + + ); + })} + + ))} + +
    , + )} +
    + {reg.name} + + {reg.nuid && ( + + + + + )} + + + + +
    NUID: + {formatNuid(reg.nuid)} +
    PIN:{row}
    +
    + ); +}; + const ShowCurrentPinsTable: React.FC<{ recipients: SplitRecipients, recipientOptions: RecipientOptions, - exam: exams_pins$key, + students: Array<{ + id: string, + name: string, + nuid: number, + pinValidated: boolean, + currentPin: string, + }>, }> = (props) => { const { recipients, recipientOptions, - exam, + students, } = props; const [filter, setFilter] = useState(undefined); const filterBy = useMemo(() => makeRegistrationFilter(recipients, filter), [filter]); - const res = useFragment( - graphql` - fragment exams_pins on Exam - @refetchable(queryName: "RegistrationPinRefetchQuery") { - registrations { - id - currentPin - pinValidated - } - } - `, - exam, - ); - const { registrations } = res; - const regsById = new Map(registrations.map((r) => [r.id, r])); - let all: Array = []; + let all: typeof students = []; if (filter) { - all = recipients.students.filter((s) => filterBy(s.name, s.id)); + all = students.filter((s) => filterBy(s.name, s.id)); } else { - all = recipients.students; + all = students; } return ( @@ -1972,23 +2075,24 @@ const ShowCurrentPinsTable: React.FC<{ + {all.map((reg) => { - const r = regsById.get(reg.id); let row: React.ReactElement; - if (r?.pinValidated) { + if (reg.pinValidated) { row = ; - } else if (r?.currentPin) { - row = ; + } else if (reg.currentPin) { + row = ; } else { row = ; } return ( + {row} @@ -2047,6 +2151,7 @@ const ProctoringRecipients: React.FC<{ user { id displayName + nuid } currentPin } @@ -2074,6 +2179,7 @@ const ProctoringRecipients: React.FC<{ type: MessageType.Direct, id: reg.id, name: reg.user.displayName, + nuid: reg.user.nuid, }; students.push(r); studentsByRoom[reg.room?.id] = studentsByRoom[reg.room?.id] ?? {}; diff --git a/app/packs/components/workflows/professor/exams/allocate-versions.tsx b/app/packs/components/workflows/professor/exams/allocate-versions.tsx index dd1c52d1f..d9b6e817f 100644 --- a/app/packs/components/workflows/professor/exams/allocate-versions.tsx +++ b/app/packs/components/workflows/professor/exams/allocate-versions.tsx @@ -24,7 +24,7 @@ import { } from 'react-router-dom'; import { AlertContext } from '@hourglass/common/alerts'; import { useFragment, graphql } from 'react-relay'; -import { useMutationWithDefaults } from '@hourglass/common/helpers'; +import { formatNuid, useMutationWithDefaults } from '@hourglass/common/helpers'; import { TabEditButton } from './admin'; import { allocateVersions$key, allocateVersions$data } from './__generated__/allocateVersions.graphql'; @@ -112,7 +112,7 @@ const StudentBadge: React.FC<{ @@ -401,7 +401,7 @@ const Readonly: React.FC = (props) => {

    No students

    ) : sortByName(unassigned).map((s) => (
  • - {s.displayName} + {s.displayName}
  • ))} @@ -417,7 +417,7 @@ const Readonly: React.FC = (props) => {
      {sortByName(v.students).map((s) => (
    • - {s.displayName} + {s.displayName}
    • ))}
    diff --git a/app/packs/components/workflows/professor/exams/assign-staff/index.tsx b/app/packs/components/workflows/professor/exams/assign-staff/index.tsx index 600eec205..ba405726a 100644 --- a/app/packs/components/workflows/professor/exams/assign-staff/index.tsx +++ b/app/packs/components/workflows/professor/exams/assign-staff/index.tsx @@ -24,7 +24,7 @@ import { } from 'react-router-dom'; import { AlertContext } from '@hourglass/common/alerts'; import { useFragment, graphql } from 'react-relay'; -import { useMutationWithDefaults } from '@hourglass/common/helpers'; +import { useMutationWithDefaults, formatNuid } from '@hourglass/common/helpers'; import { TabEditButton } from '@professor/exams/admin'; import { assignStaff$data, assignStaff$key } from './__generated__/assignStaff.graphql'; @@ -115,7 +115,7 @@ const StudentBadge: React.FC<{ @@ -438,7 +438,7 @@ const Readonly: React.FC = (props) => {

    No students

    ) : sortByName(unassigned).map((s) => (
  • - {s.displayName} + {s.displayName}
  • ))} @@ -453,7 +453,7 @@ const Readonly: React.FC = (props) => {
      {sortByName(proctors.map((p) => p.user)).map((s) => (
    • - {s.displayName} + {s.displayName}
    • ))}
    @@ -470,7 +470,7 @@ const Readonly: React.FC = (props) => {
      {r.proctorRegistrations.map((p) => (
    • - {p.user.displayName} + {p.user.displayName}
    • ))}
    diff --git a/app/packs/components/workflows/professor/exams/submissions/index.tsx b/app/packs/components/workflows/professor/exams/submissions/index.tsx index 310c18f7a..f35f51d1b 100644 --- a/app/packs/components/workflows/professor/exams/submissions/index.tsx +++ b/app/packs/components/workflows/professor/exams/submissions/index.tsx @@ -6,6 +6,7 @@ import React, { useEffect, useMemo, } from 'react'; +import NewWindow from 'react-new-window'; import { useParams, Switch, @@ -23,7 +24,8 @@ import { Dropdown, Modal, } from 'react-bootstrap'; -import { BsListCheck } from 'react-icons/bs'; +import { BsListCheck, BsPrinterFill } from 'react-icons/bs'; +import '@proctor/exams/index.scss'; import { graphql, useLazyLoadQuery, @@ -38,9 +40,10 @@ import { NavbarBreadcrumbs, NavbarItem } from '@hourglass/common/navbar'; import ExamViewer from '@proctor/registrations/show'; import ExamTimelineViewer, { SnapshotsState } from '@proctor/registrations/show/Timeline'; import ExamViewerStudent from '@student/registrations/show'; -import { FinalizeDialog, finalizeItemMutation } from '@proctor/exams'; +import { FinalizeDialog, PrintablePinsTable, finalizeItemMutation } from '@proctor/exams'; import { AlertContext } from '@hourglass/common/alerts'; import { scrollToElem } from '@student/exams/show/helpers'; +import { formatNuid } from '@hourglass/common/helpers'; import { examsFinalizeItemMutation } from '@proctor/exams/__generated__/examsFinalizeItemMutation.graphql'; import Icon from '@student/exams/show/components/Icon'; import ErrorBoundary from '@hourglass/common/boundary'; @@ -257,6 +260,7 @@ const ExamSubmissionsQuery: React.FC = () => { id user { displayName + nuid } started over @@ -348,6 +352,7 @@ const ExamSubmissionsQuery: React.FC = () => { }, DateTime.fromMillis(0)); const [showExportAnswers, setShowExportAnswers] = useState(false); const [showExportSnapshots, setShowExportSnapshots] = useState(false); + const [pinWindow, togglePINWindow] = useState(false); const [curReg/* , setCurReg */] = useState(undefined); const [curName/* , setCurName */] = useState(undefined); const items: NavbarItem[] = useMemo(() => [ @@ -507,6 +512,21 @@ const ExamSubmissionsQuery: React.FC = () => {
    NUID Student Current PIN
    Already validated{r.currentPin}{reg.currentPin}none required
    {formatNuid(reg.nuid)} {reg.name}
    )} + {pinWindow && ( + togglePINWindow(false)} + > + ({ + id: r.id, + name: r.user.displayName, + nuid: r.user.nuid, + currentPin: r.currentPin, + pinValidated: r.pinValidated, + }))} + /> + + )}

    {`Not-yet-started submissions (${groups.notStarted.length})`}

    {groups.notStarted.length === 0 ? ( Everyone has started @@ -515,7 +535,19 @@ const ExamSubmissionsQuery: React.FC = () => { Student - {anyPins && (Current PIN)} + {anyPins && ( + + Current PIN + + + )} @@ -534,6 +566,7 @@ const ExamSubmissionsQuery: React.FC = () => { {reg.user.displayName} + {`(${formatNuid(reg.user.nuid)})`} {anyPins && row} @@ -734,7 +767,7 @@ const ExamSubmissionStudentQuery: React.FC = () => { currentScorePercentage, } = registration; const title = `${exam.name} -- Submission for ${user.displayName}`; - const userInfo = `${user.displayName} (${user.nuid})`; + const userInfo = `${user.displayName} (${formatNuid(user.nuid)})`; return (

    @@ -798,7 +831,7 @@ const ExamSubmissionStaffQuery: React.FC = () => { user, exam, } = registration; - const userInfo = `${user.displayName} (${user.nuid})`; + const userInfo = `${user.displayName} (${formatNuid(user.nuid)})`; const titleInfo = published ? userInfo : ''; if (title === undefined) setTitle(`${exam.name} -- Submission for ${titleInfo}`); if (currentAnswers === null && !published) { @@ -883,7 +916,7 @@ const ExamSubmissionAuditStaffQuery: React.FC = () => { user, exam, } = registration; - const userInfo = `${user.displayName} (${user.nuid})`; + const userInfo = `${user.displayName} (${formatNuid(user.nuid)})`; const titleInfo = published ? userInfo : ''; if (title === undefined) setTitle(`${exam.name} -- Submission for ${titleInfo}`); if (snapshots === null && !published) { diff --git a/package.json b/package.json index 045f55583..804f3dc28 100644 --- a/package.json +++ b/package.json @@ -72,6 +72,7 @@ "react-hot-loader": "^4.12.21", "react-icons": "^5.0.0", "react-loading-overlay": "^1.0.1", + "react-new-window": "^1.0.1", "react-redux": "^8", "react-relay": "^14.0.0", "react-router-dom": "^5.2.0", diff --git a/yarn.lock b/yarn.lock index 117a0213f..3d01f431b 100644 --- a/yarn.lock +++ b/yarn.lock @@ -8092,6 +8092,13 @@ react-loading-overlay@^1.0.1: prop-types "^15.6.2" react-transition-group "^2.5.0" +react-new-window@^1.0.1: + version "1.0.1" + resolved "https://registry.yarnpkg.com/react-new-window/-/react-new-window-1.0.1.tgz#38f97ac5a38a14d2ac8263e2427fcbd5f84e1d64" + integrity sha512-pLQlq5NzMxNocXKPIwkXfAxSOZmbpWSbP8QrxCbunpHcx4k249YChkk7l/7P4jjwxq3dI/xwL3nVHLp6h5zM3w== + dependencies: + prop-types "^15.7.2" + react-onclickoutside@^6.13.0: version "6.13.0" resolved "https://registry.yarnpkg.com/react-onclickoutside/-/react-onclickoutside-6.13.0.tgz#e165ea4e5157f3da94f4376a3ab3e22a565f4ffc"