Book a Demo!
CoCalc Logo Icon
StoreFeaturesDocsShareSupportNewsAboutPoliciesSign UpSign In
sagemathinc
GitHub Repository: sagemathinc/cocalc
Path: blob/master/src/packages/frontend/course/common/student-assignment-info.tsx
5950 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 { Button, Col, Input, Row, Space, Spin } from "antd";
7
import type { TooltipPlacement } from "antd/lib/tooltip";
8
import { ReactNode, useRef, useState } from "react";
9
import { FormattedMessage, useIntl } from "react-intl";
10
11
import { useActions } from "@cocalc/frontend/app-framework";
12
import { Icon, Markdown, Tip } from "@cocalc/frontend/components";
13
import ShowError from "@cocalc/frontend/components/error";
14
import { COPY_TIMEOUT_MS } from "@cocalc/frontend/course/consts";
15
import { MarkdownInput } from "@cocalc/frontend/editors/markdown-input";
16
import { labels } from "@cocalc/frontend/i18n";
17
import { NotebookScores } from "@cocalc/frontend/jupyter/nbgrader/autograde";
18
import { webapp_client } from "@cocalc/frontend/webapp-client";
19
import { BigTime } from ".";
20
import { CourseActions } from "../actions";
21
import { NbgraderScores } from "../nbgrader/scores";
22
import {
23
AssignmentRecord,
24
LastCopyInfo,
25
NBgraderRunInfo,
26
StudentRecord,
27
} from "../store";
28
import { AssignmentCopyType } from "../types";
29
import { useButtonSize } from "../util";
30
import {
31
GRADE_FLEX,
32
STEP_NAMES,
33
Steps,
34
STEPS_INTL,
35
STEPS_INTL_ACTIVE,
36
} from "./consts";
37
38
interface StudentAssignmentInfoProps {
39
name: string;
40
title: ReactNode;
41
student: StudentRecord;
42
assignment: AssignmentRecord;
43
grade?: string;
44
comments?: string;
45
info: {
46
assignment_id: string;
47
student_id: string;
48
peer_assignment: boolean;
49
peer_collect: boolean;
50
last_assignment?: LastCopyInfo;
51
last_collect?: LastCopyInfo;
52
last_peer_assignment?: LastCopyInfo;
53
last_peer_collect?: LastCopyInfo;
54
last_return_graded?: LastCopyInfo;
55
};
56
nbgrader_scores?: { [ipynb: string]: NotebookScores | string };
57
nbgrader_score_ids?: { [ipynb: string]: string[] };
58
is_editing: boolean;
59
nbgrader_run_info?: NBgraderRunInfo;
60
}
61
62
interface RenderLastProps {
63
step: Steps;
64
type: AssignmentCopyType;
65
data?: any;
66
enable_copy?: boolean;
67
copy_tip?: string;
68
open_tip?: string;
69
omit_errors?: boolean;
70
}
71
72
const RECOPY_INIT: Record<Steps, false> = {
73
Assign: false,
74
Collect: false,
75
"Peer Assign": false,
76
Return: false,
77
"Peer Collect": false,
78
} as const;
79
80
function useRecopy(): [
81
typeof RECOPY_INIT,
82
(key: Steps, value: boolean) => void,
83
] {
84
const [recopy, set_recopy] = useState<typeof RECOPY_INIT>(RECOPY_INIT);
85
function set(key: Steps, value: boolean) {
86
set_recopy({ ...recopy, [key]: value });
87
}
88
return [recopy, set];
89
}
90
91
export function StudentAssignmentInfo({
92
name,
93
title,
94
student,
95
assignment,
96
grade = "",
97
comments = "",
98
info,
99
nbgrader_scores,
100
nbgrader_score_ids,
101
is_editing,
102
nbgrader_run_info,
103
}: StudentAssignmentInfoProps) {
104
const intl = useIntl();
105
const clicked_nbgrader = useRef<Date | undefined>(undefined);
106
const actions = useActions<CourseActions>({ name });
107
const size = useButtonSize();
108
const [recopy, set_recopy] = useRecopy();
109
110
function open(
111
type: AssignmentCopyType,
112
assignment_id: string,
113
student_id: string,
114
) {
115
return actions.assignments.open_assignment(type, assignment_id, student_id);
116
}
117
118
function copy(
119
type: AssignmentCopyType,
120
assignment_id: string,
121
student_id: string,
122
) {
123
return actions.assignments.copy_assignment(type, assignment_id, student_id);
124
}
125
126
function stop(
127
type: AssignmentCopyType,
128
assignment_id: string,
129
student_id: string,
130
) {
131
actions.assignments.stop_copying_assignment(
132
assignment_id,
133
student_id,
134
type,
135
);
136
}
137
138
function set_edited_feedback() {
139
actions.assignments.update_edited_feedback(
140
assignment.get("assignment_id"),
141
student.get("student_id"),
142
);
143
}
144
145
function stop_editing() {
146
actions.assignments.clear_edited_feedback(
147
assignment.get("assignment_id"),
148
student.get("student_id"),
149
);
150
}
151
152
function render_grade() {
153
if (is_editing) {
154
return (
155
<Space align="start" style={{ margin: "5px 0" }}>
156
<Input
157
placeholder="Grade..."
158
value={grade ?? ""}
159
onChange={(e) =>
160
actions.assignments.set_grade(
161
assignment.get("assignment_id"),
162
student.get("student_id"),
163
e.target.value,
164
)
165
}
166
onPressEnter={() => stop_editing()}
167
size={size}
168
style={{ maxWidth: 180 }}
169
autoFocus
170
/>
171
<Button type="primary" size={size} onClick={() => stop_editing()}>
172
Done
173
</Button>
174
</Space>
175
);
176
} else {
177
const hasGrade = !!(grade ?? "").trim();
178
const gradeText = intl.formatMessage(
179
{
180
id: "course.student-assignment-info.grade.label",
181
defaultMessage: `{show, select, true {Grade: {grade}} other {Enter grade...}}`,
182
description: "Grade of an assignment in an online course",
183
},
184
{ grade, show: hasGrade },
185
);
186
187
if (hasGrade) {
188
return (
189
<Space align="center">
190
<span>{gradeText}</span>
191
<Button
192
icon={<Icon name="pencil" />}
193
onClick={() => set_edited_feedback()}
194
disabled={is_editing}
195
size={size}
196
aria-label="Edit grade"
197
title="Edit grade"
198
/>
199
</Space>
200
);
201
} else {
202
return (
203
<Button
204
key="edit"
205
icon={<Icon name="pencil" />}
206
onClick={() => set_edited_feedback()}
207
disabled={is_editing}
208
size={size}
209
>
210
{gradeText}
211
</Button>
212
);
213
}
214
}
215
}
216
217
function render_comments() {
218
if (!is_editing) {
219
if (!comments?.trim()) return;
220
return (
221
<div style={{ width: "100%", paddingRight: "5px" }}>
222
<Markdown
223
value={comments}
224
style={{
225
width: "100%",
226
maxHeight: "4em",
227
overflowY: "auto",
228
padding: "5px",
229
border: "1px solid lightgray",
230
cursor: "pointer",
231
display: "inline-block",
232
}}
233
onClick={() => set_edited_feedback()}
234
/>
235
</div>
236
);
237
} else {
238
return (
239
<MarkdownInput
240
placeholder="Optional markdown comments..."
241
value={comments || ""}
242
onBlur={(comment) => {
243
actions.assignments.set_comment(
244
assignment.get("assignment_id"),
245
student.get("student_id"),
246
comment,
247
);
248
}}
249
onShiftEnter={() => stop_editing()}
250
height="7em"
251
hideHelp
252
/>
253
);
254
}
255
}
256
257
function render_nbgrader_scores() {
258
const scores = nbgrader_scores ?? {};
259
const hasScores = Object.keys(scores).length > 0;
260
return (
261
<div>
262
<NbgraderScores
263
show_all={is_editing}
264
set_show_all={() => set_edited_feedback()}
265
nbgrader_scores={scores}
266
nbgrader_score_ids={nbgrader_score_ids}
267
name={name}
268
student_id={student.get("student_id")}
269
assignment_id={assignment.get("assignment_id")}
270
run_button={render_run_nbgrader(hasScores ? "redo" : "first")}
271
buttonSize={size}
272
/>
273
</div>
274
);
275
}
276
277
function render_run_nbgrader(mode: "first" | "redo") {
278
let running = false;
279
if (nbgrader_run_info != null) {
280
const t = nbgrader_run_info.get(
281
assignment.get("assignment_id") + "-" + student.get("student_id"),
282
);
283
if (t && webapp_client.server_time() - t <= 1000 * 60 * 10) {
284
// Time starting is set and it's also within the last few minutes.
285
// This "few minutes" is just in case -- we probably shouldn't need
286
// that at all ever, but it could make cocalc state usable in case of
287
// weird issues, I guess). User could also just close and re-open
288
// the course file, which resets this state completely.
289
running = true;
290
}
291
}
292
293
const isFirst = mode === "first";
294
const iconName = isFirst ? "caret-right" : "redo";
295
const tipTitle = isFirst ? "Run nbgrader" : "Run nbgrader again";
296
const tipText = isFirst
297
? "Run nbgrader on this student's collected submission."
298
: "Re-run nbgrader for this student's collected submission.";
299
300
return (
301
<Tip title={tipTitle} tip={tipText}>
302
<Button
303
key="nbgrader"
304
icon={<Icon name={iconName} />}
305
disabled={running}
306
loading={running}
307
size={size}
308
aria-label={tipTitle}
309
onClick={() => {
310
if (
311
clicked_nbgrader.current != null &&
312
webapp_client.server_time() -
313
clicked_nbgrader.current.valueOf() <=
314
3000
315
) {
316
// avoid firing nbgrader twice on rapid double-clicks
317
return;
318
}
319
320
clicked_nbgrader.current = new Date();
321
actions.assignments.run_nbgrader_for_one_student(
322
assignment.get("assignment_id"),
323
student.get("student_id"),
324
);
325
}}
326
/>
327
</Tip>
328
);
329
}
330
331
function render_nbgrader() {
332
if (!assignment.get("nbgrader")) return;
333
334
return render_nbgrader_scores();
335
}
336
337
function render_last_time(time: string | number | Date) {
338
return (
339
<Space key="time" wrap>
340
<BigTime date={time} />
341
</Space>
342
);
343
}
344
345
function render_recopy_confirm(
346
step: Steps,
347
copy: Function,
348
copy_tip: string,
349
placement,
350
) {
351
if (recopy[step]) {
352
const v: React.JSX.Element[] = [];
353
v.push(
354
<Tip
355
key="copy_cancel"
356
title={intl.formatMessage(labels.cancel)}
357
tip={intl.formatMessage(labels.cancel)}
358
>
359
<Button size={size} onClick={() => set_recopy(step, false)}>
360
{intl.formatMessage(labels.cancel)}
361
</Button>
362
</Tip>,
363
);
364
v.push(
365
<Tip
366
key="recopy_confirm"
367
title={step}
368
placement={placement}
369
tip={copy_tip}
370
>
371
<Button
372
danger
373
size={size}
374
onClick={() => {
375
set_recopy(step, false);
376
copy();
377
}}
378
>
379
<FormattedMessage
380
id="course.student-assignment-info.recopy_confirm.label"
381
defaultMessage={`Yes, {activity} again`}
382
description={"Confirm an activity, like 'assign', 'collect', ..."}
383
values={{ activity: step_intl(step, false).toLowerCase() }}
384
/>
385
</Button>
386
</Tip>,
387
);
388
if (step.toLowerCase() === "assign") {
389
// inline-block because buttons above are float:left
390
v.push(
391
<div key="what-happens">
392
<a
393
target="_blank"
394
rel="noopener noreferrer"
395
href="https://doc.cocalc.com/teaching-tips_and_tricks.html#how-exactly-are-assignments-copied-to-students"
396
>
397
{intl.formatMessage({
398
id: "course.student-assignment-info.recopy.what_happens",
399
defaultMessage: "What happens when I assign again?",
400
description:
401
"Asking the question, what happens if all files are transferred to all students in an online course once again.",
402
})}
403
</a>
404
</div>,
405
);
406
}
407
return v;
408
} else {
409
return [
410
<Tip key="copy" title={step} placement={placement} tip={copy_tip}>
411
<Button
412
size={size}
413
icon={<Icon name="redo" />}
414
onClick={() => set_recopy(step, true)}
415
aria-label={`Redo ${step.toLowerCase()} for this student`}
416
/>
417
</Tip>,
418
];
419
}
420
}
421
422
function render_open(open, tip: string, placement: TooltipPlacement) {
423
return (
424
<Tip key="open" title="Open assignment" tip={tip} placement={placement}>
425
<Button
426
onClick={open}
427
size={size}
428
icon={<Icon name="folder-open" />}
429
aria-label="Open assignment folder"
430
/>
431
</Tip>
432
);
433
}
434
435
function step_intl(step: Steps, active: boolean): string {
436
return intl.formatMessage(active ? STEPS_INTL_ACTIVE : STEPS_INTL, {
437
step: STEP_NAMES.indexOf(step),
438
});
439
}
440
441
function render_copying(step: Steps, stop) {
442
return [
443
<Button key="stop" danger onClick={stop} size={size}>
444
{intl.formatMessage(labels.cancel)}
445
</Button>,
446
<Button key="copy" disabled={true} size={size}>
447
<Spin /> {step_intl(step, true)}
448
</Button>,
449
];
450
}
451
452
function render_copy(
453
step: Steps,
454
copy: () => void,
455
tip: string,
456
placement: TooltipPlacement,
457
) {
458
return (
459
<Tip key="copy" title={step} tip={tip} placement={placement}>
460
<Button
461
onClick={copy}
462
size={size}
463
icon={<Icon name="caret-right" />}
464
aria-label={`${step} this student`}
465
/>
466
</Tip>
467
);
468
}
469
470
function render_error(step: Steps, error) {
471
if (typeof error !== "string") {
472
error = `${error}`;
473
}
474
if (error.includes("[object Object]")) {
475
// already too late to know the actual error -- it got mangled/reported incorrectly
476
error = "";
477
}
478
// We search for two different error messages, since different errors happen in
479
// KuCalc versus other places cocalc runs. It depends on what is doing the copy.
480
if (
481
error.indexOf("No such file or directory") !== -1 ||
482
error.indexOf("ENOENT") != -1
483
) {
484
error = `The student might have renamed or deleted the directory that contained their assignment. Open their project and see what happened. If they renamed it, you could rename it back, then collect the assignment again -- \n${error}`;
485
} else {
486
error = `Try to ${step.toLowerCase()} again -- \n${error}`;
487
}
488
return (
489
<ShowError
490
key="error"
491
error={error}
492
style={{ padding: "4px 4px", overflowWrap: "anywhere" }}
493
/>
494
);
495
}
496
497
function Status({
498
step,
499
type,
500
data = {},
501
enable_copy = false,
502
copy_tip = "",
503
open_tip = "",
504
omit_errors = false,
505
}: RenderLastProps): React.JSX.Element {
506
const do_open = () => open(type, info.assignment_id, info.student_id);
507
const do_copy = () => copy(type, info.assignment_id, info.student_id);
508
const do_stop = () => stop(type, info.assignment_id, info.student_id);
509
const v: React.JSX.Element[] = [];
510
const placement: TooltipPlacement = step === "Return" ? "left" : "right";
511
if (enable_copy) {
512
const now = webapp_client.server_time();
513
const in_progress =
514
data.start != null && now - data.start < COPY_TIMEOUT_MS;
515
if (in_progress) {
516
v.push(...render_copying(step, do_stop));
517
v.push(render_open(do_open, open_tip, placement));
518
} else if (data.time) {
519
v.push(render_open(do_open, open_tip as string, placement));
520
v.push(
521
...render_recopy_confirm(
522
step,
523
do_copy,
524
copy_tip as string,
525
placement,
526
),
527
);
528
} else {
529
v.push(render_copy(step, do_copy, copy_tip as string, placement));
530
}
531
}
532
if (data.time) {
533
v.push(render_last_time(data.time));
534
}
535
if (data.error && !omit_errors) {
536
v.push(render_error(step, data.error));
537
}
538
return <Space wrap>{v}</Space>;
539
}
540
541
let show_grade_col, show_return_graded;
542
const peer_grade: boolean = !!assignment.getIn(["peer_grade", "enabled"]);
543
const skip_grading: boolean = !!assignment.get("skip_grading");
544
const skip_assignment: boolean = !!assignment.get("skip_assignment");
545
const skip_collect: boolean = !!assignment.get("skip_collect");
546
if (peer_grade) {
547
show_grade_col = info.last_peer_collect;
548
show_return_graded = grade || (skip_grading && info.last_peer_collect);
549
} else {
550
show_grade_col = info.last_collect || skip_collect;
551
show_return_graded =
552
grade ||
553
(skip_grading && info.last_collect) ||
554
(skip_grading && skip_collect);
555
}
556
557
function render_assignment_col() {
558
return (
559
<Col flex="1" key="last_assignment">
560
<Status
561
step="Assign"
562
data={info.last_assignment}
563
type="assigned"
564
enable_copy={true}
565
copy_tip={intl.formatMessage({
566
id: "course.student-assignment-info.assignment_col.copy.tooltip",
567
defaultMessage: `Copy the assignment from your project to this student's project so they can do their homework.`,
568
description: "files of a student in an online course",
569
})}
570
open_tip={intl.formatMessage({
571
id: "course.student-assignment-info.assignment_col.open.tooltip",
572
defaultMessage: `Open the student's copy of this assignment directly in their project.
573
You will be able to see them type, chat with them, leave them hints, etc.`,
574
description: "files of a student in an online course",
575
})}
576
omit_errors={skip_assignment}
577
/>
578
</Col>
579
);
580
}
581
582
function render_collect_col() {
583
return (
584
<Col flex="1" key="last_collect">
585
{skip_assignment ||
586
!(info.last_assignment != null
587
? info.last_assignment.error
588
: undefined) ? (
589
<Status
590
step="Collect"
591
data={info.last_collect}
592
type="collected"
593
enable_copy={info.last_assignment != null || skip_assignment}
594
copy_tip={intl.formatMessage({
595
id: "course.student-assignment-info.collect_col.copy.tooltip",
596
defaultMessage:
597
"Copy the assignment from your student's project back to your project so you can grade their work.",
598
description: "files of a student in an online course",
599
})}
600
open_tip={intl.formatMessage({
601
id: "course.student-assignment-info.collect_col.open.tooltip",
602
defaultMessage:
603
"Open the copy of your student's work in your own project, so that you can grade their work.",
604
description: "files of a student in an online course",
605
})}
606
omit_errors={skip_collect}
607
/>
608
) : undefined}
609
</Col>
610
);
611
}
612
613
function render_peer_assign_col() {
614
if (!peer_grade) return;
615
if (!info.peer_assignment || info.last_collect?.error != null) {
616
return <Col flex="1" key="peer_assign" />;
617
}
618
return (
619
<Col flex="1" key="peer_assign">
620
<Status
621
step="Peer Assign"
622
data={info.last_peer_assignment}
623
type={"peer-assigned"}
624
enable_copy={info.last_collect != null}
625
copy_tip={intl.formatMessage({
626
id: "course.student-assignment-info.peer_assign_col.copy.tooltip",
627
defaultMessage:
628
"Copy collected assignments from your project to this student's project so they can grade them.",
629
description: "files of a student in an online course",
630
})}
631
open_tip={intl.formatMessage({
632
id: "course.student-assignment-info.peer_assign_col.open.tooltip",
633
defaultMessage:
634
"Open the student's copies of this assignment directly in their project, so you can see what they are peer grading.",
635
description: "files of a student in an online course",
636
})}
637
/>
638
</Col>
639
);
640
}
641
642
function render_peer_collect_col() {
643
if (!peer_grade) return;
644
if (!info.peer_collect) return <Col flex="1" key="peer_collect" />;
645
return (
646
<Col flex="1" key="peer_collect">
647
<Status
648
step="Peer Collect"
649
data={info.last_peer_collect}
650
type="peer-collected"
651
enable_copy={info.last_peer_assignment != null}
652
copy_tip={intl.formatMessage({
653
id: "course.student-assignment-info.peer_collect_col.copy.tooltip",
654
defaultMessage:
655
"Copy the peer-graded assignments from various student projects back to your project so you can assign their official grade.",
656
description: "files of a student in an online course",
657
})}
658
open_tip={intl.formatMessage({
659
id: "course.student-assignment-info.peer_collect_col.open.tooltip",
660
defaultMessage:
661
"Open your copy of your student's peer grading work in your own project, so that you can grade their work.",
662
663
description: "files of a student in an online course",
664
})}
665
/>
666
</Col>
667
);
668
}
669
670
function render_grade_col() {
671
// {render_enter_grade()}
672
return (
673
<Col flex={GRADE_FLEX} key="grade">
674
{show_grade_col && (
675
<div>
676
{render_grade()}
677
{render_comments()}
678
{render_nbgrader()}
679
</div>
680
)}
681
</Col>
682
);
683
}
684
685
function render_return_graded_col() {
686
return (
687
<Col flex="1" key="return_graded">
688
{show_return_graded ? (
689
<Status
690
step="Return"
691
data={info.last_return_graded}
692
type="graded"
693
enable_copy={info.last_collect != null || skip_collect}
694
copy_tip={intl.formatMessage({
695
id: "course.student-assignment-info.graded_col.copy.tooltip",
696
defaultMessage: `Copy the graded assignment back to your student's project.`,
697
description: "files of a student in an online course",
698
})}
699
open_tip={intl.formatMessage({
700
id: "course.student-assignment-info.graded_col.open.tooltip",
701
defaultMessage: `Open the copy of your student's work that you returned to them.
702
This opens the returned assignment directly in their project.`,
703
description: "the files of a student in an online course",
704
})}
705
/>
706
) : undefined}
707
</Col>
708
);
709
}
710
711
return (
712
<div>
713
<Row
714
style={{
715
borderTop: "1px solid #aaa",
716
paddingTop: "5px",
717
paddingBottom: "5px",
718
}}
719
>
720
<Col md={4} key="title">
721
{title}
722
</Col>
723
<Col md={20} key="rest">
724
<Row gutter={[8, 0]}>
725
{render_assignment_col()}
726
{render_collect_col()}
727
{render_peer_assign_col()}
728
{render_peer_collect_col()}
729
{render_grade_col()}
730
{render_return_graded_col()}
731
</Row>
732
</Col>
733
</Row>
734
</div>
735
);
736
}
737
738