Path: blob/master/src/packages/frontend/course/common/assignment-header.tsx
10799 views
/*1* This file is part of CoCalc: Copyright © 2020 Sagemath, Inc.2* License: MS-RSL – see LICENSE.md for details3*/45import { Alert, Button, Input, Space, Switch } from "antd";6import { ReactElement, ReactNode, useEffect, useState } from "react";7import { DebounceInput } from "react-debounce-input";8import { useIntl } from "react-intl";910import { Loading, Tip } from "@cocalc/frontend/components";11import { webapp_client } from "@cocalc/frontend/webapp-client";1213import { CourseActions } from "../actions";14import { nbgrader_status } from "../nbgrader/util";15import type { AssignmentRecord, NBgraderRunInfo } from "../store";16import type {17AssignmentCopyStep,18AssignmentStep,19AssignmentStatus,20} from "../types";21import { STEPS } from "../util";22import { CopyRunAllAlert } from "./copy-run-all";23import {24filterPlaceholder,25nbgraderMessages,26runAllAriaLabel,27skipStepMessages,28} from "./course-unit-strings";29import { Progress } from "./progress";30import { RunAllPopover } from "./run-all-popover";31import { StudentAssignmentInfoHeader } from "./student-assignment-info-header";3233interface AssignmentHeaderProps {34assignment: AssignmentRecord;35status: AssignmentStatus | null;36numStudents: number;37actions: CourseActions;38studentSearch: string;39setStudentSearch: (value: string) => void;40nbgraderRunInfo?: NBgraderRunInfo;41}4243export function AssignmentHeader({44assignment,45status,46numStudents,47actions,48studentSearch,49setStudentSearch,50nbgraderRunInfo,51}: AssignmentHeaderProps) {52const intl = useIntl();53const [openedRunAll, setOpenedRunAll] = useState<AssignmentStep | null>(null);54const [confirmAllStudents, setConfirmAllStudents] = useState<boolean>(false);55const [confirmSyncGrades, setConfirmSyncGrades] = useState<boolean>(false);5657const assignmentId = assignment.get("assignment_id") ?? "";58const store = actions.get_store();5960useEffect(() => {61setOpenedRunAll(null);62setConfirmAllStudents(false);63setConfirmSyncGrades(false);64}, [assignmentId]);6566if (status == null) {67return <Loading key="loading_more" />;68}6970// Keep a narrowed alias since many nested closures use this value and71// TypeScript does not reliably preserve the non-null guard on `status`.72const assignmentStatus = status;73const peer = !!assignment.getIn(["peer_grade", "enabled"]);7475function renderStepPopover(76step: AssignmentCopyStep,77opts: {78type: "primary" | "default";79content: ReactNode;80onOpen?: () => void;81onClose?: () => void;82},83) {84const open = openedRunAll === step;85const handleOpenChange = (next: boolean) => {86setOpenedRunAll(next ? step : null);87setConfirmAllStudents(false);88setConfirmSyncGrades(false);89if (next) {90opts.onOpen?.();91} else {92opts.onClose?.();93}94};95return (96<RunAllPopover97id={String(step)}98open={open}99onOpenChange={handleOpenChange}100type={opts.type}101content={opts.content}102ariaLabel={runAllAriaLabel(intl, step)}103/>104);105}106107function renderSkipSwitch(108step: "assignment" | "collect" | "grading",109disabled?: boolean,110) {111const { label, title, tip } = skipStepMessages(intl);112const skipped = assignment.get(`skip_${step}` as any);113return (114<Tip title={title} tip={tip}>115<Switch116checked={!!skipped}117onChange={() =>118actions.assignments.set_skip(assignmentId, step, !skipped)119}120unCheckedChildren={label}121checkedChildren={label}122size="small"123disabled={disabled}124/>125</Tip>126);127}128129// Keep this order aligned with header column order; it drives "next recommended step" highlighting.130const orderedSteps: AssignmentStep[] = peer131? [132"assignment",133"collect",134"peer_assignment",135"peer_collect",136"grade",137"return_graded",138]139: ["assignment", "collect", "grade", "return_graded"];140141// For recommendation purposes, a step is "complete" only when it is done142// for all non-deleted students, unless that step was explicitly skipped.143function previousStepsComplete(step: AssignmentStep) {144for (const s of orderedSteps) {145if (s === step) break;146if (s === "grade") {147if (assignment.get("skip_grading")) {148continue;149}150for (const studentId of store.get_student_ids({ deleted: false })) {151if (!store.has_grade(assignmentId, studentId)) {152return false;153}154}155} else {156if (assignment.get(`skip_${s}` as any)) {157continue;158}159if (assignmentStatus[s] !== store.num_students()) {160return false;161}162}163}164return true;165}166167function runAllButtonType(168step: AssignmentStep,169hasNew: boolean,170): "primary" | "default" {171const prevComplete = previousStepsComplete(step);172if (hasNew && prevComplete) {173return "primary";174}175return "default";176}177178function isNbgraderRunning(): boolean {179if (nbgraderRunInfo == null) return false;180const t = nbgraderRunInfo.get(assignmentId);181// Time starting is set and it's also within the last few minutes.182// This "few minutes" is just in case -- we probably shouldn't need183// that at all ever, but it could make cocalc state usable in case of184// weird issues, I guess). User could also just close and re-open185// the course file, which resets this state completely.186return webapp_client.server_time() - (t ?? 0) <= 1000 * 60 * 10;187}188189function renderNbgraderRunAll() {190const nbgrader = nbgrader_status(assignment);191const total = nbgrader.attempted + nbgrader.not_attempted;192const todo = nbgrader.not_attempted + nbgrader.failed;193const running = isNbgraderRunning();194const showRemaining = todo > 0 && !confirmAllStudents && !confirmSyncGrades;195const alertType =196confirmAllStudents || confirmSyncGrades197? "error"198: showRemaining199? "warning"200: "success";201const msg = nbgraderMessages(intl);202const message = (203<Space direction="vertical">204<span>{msg.intro}</span>205{showRemaining && (206<Button207disabled={running}208type="primary"209onClick={() => {210actions.assignments.run_nbgrader_for_all_students(211assignmentId,212true,213);214setOpenedRunAll(null);215}}216>217{msg.remainingButton(todo)}218</Button>219)}220{nbgrader.attempted > 0 && !confirmSyncGrades && (221<Button222danger223disabled={running || confirmAllStudents}224onClick={() => {225setConfirmAllStudents(true);226setConfirmSyncGrades(false);227}}228>229{msg.allButton(total)}230</Button>231)}232{confirmAllStudents && (233<Space direction="vertical">234<div>{msg.confirmAllPrompt(total)}</div>235<Space wrap>236<Button237danger238type="primary"239disabled={running}240onClick={() => {241actions.assignments.run_nbgrader_for_all_students(242assignmentId,243);244setOpenedRunAll(null);245setConfirmAllStudents(false);246}}247>248{msg.confirmAllAction(total)}249</Button>250<Button251onClick={() => setConfirmAllStudents(false)}252disabled={running}253>254{msg.back}255</Button>256</Space>257</Space>258)}259{!confirmAllStudents && (260<Button261disabled={running || confirmSyncGrades}262onClick={() => {263setConfirmSyncGrades(true);264setConfirmAllStudents(false);265}}266>267{msg.syncButton}268</Button>269)}270{confirmSyncGrades && (271<Space direction="vertical">272<div>{msg.syncPrompt}</div>273<Space wrap>274<Button275danger276type="primary"277disabled={running}278onClick={() => {279actions.assignments.set_nbgrader_scores_for_all_students({280assignment_id: assignmentId,281force: true,282commit: true,283});284setOpenedRunAll(null);285}}286>287{msg.syncAction}288</Button>289<Button290onClick={() => setConfirmSyncGrades(false)}291disabled={running}292>293{msg.back}294</Button>295</Space>296</Space>297)}298</Space>299);300return <Alert type={alertType} message={message} />;301}302303function copyAssignment(304step: AssignmentCopyStep,305newOnly: boolean,306overwrite: boolean = false,307) {308switch (step) {309case "assignment":310actions.assignments.copy_assignment_to_all_students(311assignmentId,312newOnly,313overwrite,314);315break;316case "collect":317actions.assignments.copy_assignment_from_all_students(318assignmentId,319newOnly,320);321break;322case "peer_assignment":323actions.assignments.peer_copy_to_all_students(assignmentId, newOnly);324break;325case "peer_collect":326actions.assignments.peer_collect_from_all_students(327assignmentId,328newOnly,329);330break;331case "return_graded":332actions.assignments.return_assignment_to_all_students(333assignmentId,334newOnly,335);336break;337default:338console.log(`BUG -- unknown step: ${step}`);339}340setConfirmAllStudents(false);341setOpenedRunAll(null);342}343344function renderCopyConfirm(step: AssignmentCopyStep) {345return (346<CopyRunAllAlert347id={`copy_confirm_${step}`}348step={step}349status={{350done: assignmentStatus[step],351not_done: assignmentStatus[`not_${step}`],352total: numStudents,353}}354onRun={({ scope, overwrite }) =>355copyAssignment(step, scope === "remaining", overwrite)356}357hasStudentSubdir={assignment.get("has_student_subdir")}358/>359);360}361362function renderGradeRunAll() {363if (!assignment.get("nbgrader") || assignmentStatus.collect === 0) {364return;365}366const nbgrader = nbgrader_status(assignment);367if (nbgrader.attempted + nbgrader.not_attempted === 0) {368return;369}370return (371<RunAllPopover372id="grade"373open={openedRunAll === "grade"}374onOpenChange={(next) => {375setOpenedRunAll(next ? "grade" : null);376setConfirmAllStudents(false);377setConfirmSyncGrades(false);378}}379type={runAllButtonType(380"grade",381nbgrader.not_attempted + nbgrader.failed > 0,382)}383content={renderNbgraderRunAll}384ariaLabel={runAllAriaLabel(intl, "grade")}385/>386);387}388389type StepSection = {390actions: ReactElement<any>[];391progress?: ReactElement<any>;392};393394function renderAssignmentSection(): StepSection {395const actionsForStep: ReactElement<any>[] = [396<span key="run-assignment">397{renderStepPopover("assignment", {398type: runAllButtonType(399"assignment",400assignmentStatus.not_assignment > 0,401),402content: renderCopyConfirm("assignment"),403onOpen: () => {404actions.assignments.update_listing(assignmentId);405},406})}407</span>,408];409if (!peer) {410actionsForStep.push(411<span key="skip-assignment">{renderSkipSwitch("assignment")}</span>,412);413}414return {415actions: actionsForStep,416progress: (417<Progress418key="progress-assignment"419done={assignmentStatus.assignment}420not_done={assignmentStatus.not_assignment}421step="assigned"422skipped={assignment.get("skip_assignment")}423/>424),425};426}427428function renderCollectSection(): StepSection | undefined {429if (assignmentStatus.assignment === 0) {430// no button if nothing ever assigned431return;432}433const actionsForStep: ReactElement<any>[] = [434<span key="run-collect">435{renderStepPopover("collect", {436type: runAllButtonType("collect", assignmentStatus.not_collect > 0),437content: renderCopyConfirm("collect"),438})}439</span>,440];441if (!peer) {442actionsForStep.push(443<span key="skip-collect">{renderSkipSwitch("collect")}</span>,444);445}446return {447actions: actionsForStep,448progress: (449<Progress450key="progress-collect"451done={assignmentStatus.collect}452not_done={assignmentStatus.not_collect}453step="collected"454skipped={assignment.get("skip_collect")}455/>456),457};458}459460function renderPeerAssignmentSection(): StepSection | undefined {461if (assignmentStatus.peer_assignment == null) {462// not peer graded463return;464}465if (assignmentStatus.not_collect + assignmentStatus.not_assignment > 0) {466// collect everything before peer grading467return;468}469if (assignmentStatus.collect === 0) {470// nothing to peer assign471return;472}473return {474actions: [475<span key="run-peer-assignment">476{renderStepPopover("peer_assignment", {477type: runAllButtonType(478"peer_assignment",479assignmentStatus.not_peer_assignment > 0,480),481content: renderCopyConfirm("peer_assignment"),482})}483</span>,484],485progress: (486<Progress487key="progress-peer-assignment"488done={assignmentStatus.peer_assignment}489not_done={assignmentStatus.not_peer_assignment}490step="peer assigned"491/>492),493};494}495496function renderPeerCollectSection(): StepSection | undefined {497// Render the "Peer Collect..." button in the top row, for collecting peer grading from all498// students in the course.499if (assignmentStatus.peer_collect == null) {500return;501}502if (assignmentStatus.peer_assignment === 0) {503// haven't even peer assigned anything -- so nothing to collect504return;505}506if (assignmentStatus.not_peer_assignment > 0) {507// everybody must have received peer assignment, or collecting isn't allowed508return;509}510return {511actions: [512<span key="run-peer-collect">513{renderStepPopover("peer_collect", {514type: runAllButtonType(515"peer_collect",516assignmentStatus.not_peer_collect > 0,517),518content: renderCopyConfirm("peer_collect"),519})}520</span>,521],522progress: (523<Progress524key="progress-peer-collect"525done={assignmentStatus.peer_collect}526not_done={assignmentStatus.not_peer_collect}527step="peer collected"528/>529),530};531}532533function renderReturnGradedSection(): StepSection | undefined {534if (assignmentStatus.collect === 0) {535// No button if nothing collected.536return;537}538if (539assignmentStatus.peer_collect != null &&540assignmentStatus.peer_collect === 0541) {542// Peer grading enabled, but we didn't collect anything yet543return;544}545if (546!assignment.get("skip_grading") &&547assignmentStatus.not_return_graded === 0 &&548assignmentStatus.return_graded === 0549) {550// Nothing unreturned and ungraded yet and also nothing returned yet551return;552}553return {554actions: [555<span key="run-return-graded">556{renderStepPopover("return_graded", {557type: runAllButtonType(558"return_graded",559assignmentStatus.not_return_graded > 0,560),561content: renderCopyConfirm("return_graded"),562})}563</span>,564],565progress: (566<Progress567key="progress-return-graded"568done={assignmentStatus.return_graded}569not_done={assignmentStatus.not_return_graded}570step="returned"571/>572),573};574}575576function renderGradeSection(): StepSection | undefined {577const actionsForStep: ReactElement<any>[] = [];578const gradeAction = renderGradeRunAll();579if (gradeAction) {580actionsForStep.push(<span key="run-grade">{gradeAction}</span>);581}582if (assignmentStatus.collect > 0) {583actionsForStep.push(584<span key="skip-grade">585{renderSkipSwitch("grading", assignmentStatus.collect === 0)}586</span>,587);588}589if (actionsForStep.length === 0) {590return;591}592return { actions: actionsForStep };593}594595function renderStepSection(596state: AssignmentCopyStep,597): StepSection | undefined {598switch (state) {599case "collect":600return renderCollectSection();601case "return_graded":602return renderReturnGradedSection();603case "peer_assignment":604return renderPeerAssignmentSection();605case "peer_collect":606return renderPeerCollectSection();607case "assignment":608return renderAssignmentSection();609}610}611612const actionsMap: Partial<Record<AssignmentStep, ReactElement<any>[]>> = {};613const progressMap: Partial<Record<AssignmentStep, ReactElement<any>>> = {};614615for (const name of STEPS(peer)) {616const section = renderStepSection(name);617if (section == null) {618continue;619}620actionsMap[name] = section.actions;621if (section.progress) {622progressMap[name] = section.progress;623}624}625626const gradeSection = renderGradeSection();627if (gradeSection) {628actionsMap.grade = gradeSection.actions;629}630631return (632<StudentAssignmentInfoHeader633mode="assignment"634peer_grade={peer}635actions={actionsMap}636progress={progressMap}637filter={638<DebounceInput639debounceTimeout={500}640element={Input as any}641placeholder={filterPlaceholder(intl)}642value={studentSearch}643onChange={(e) => setStudentSearch(e.target.value)}644/>645}646/>647);648}649650651