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