Book a Demo!
CoCalc Logo Icon
StoreFeaturesDocsShareSupportNewsAboutPoliciesSign UpSign In
sagemathinc
GitHub Repository: sagemathinc/cocalc
Path: blob/master/src/packages/frontend/course/assignments/assignment.tsx
5837 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 {
7
Alert,
8
Button,
9
Card,
10
Col,
11
ConfigProvider,
12
Divider,
13
Input,
14
Popconfirm,
15
Popover,
16
Row,
17
Switch,
18
Space,
19
} from "antd";
20
import { ReactElement, ReactNode, useEffect, useState } from "react";
21
import { DebounceInput } from "react-debounce-input";
22
import { FormattedMessage, useIntl } from "react-intl";
23
import { AppRedux, useActions } from "@cocalc/frontend/app-framework";
24
import {
25
DateTimePicker,
26
Icon,
27
Loading,
28
Tip,
29
} from "@cocalc/frontend/components";
30
import MultiMarkdownInput from "@cocalc/frontend/editors/markdown-input/multimode";
31
import StaticMarkdown from "@cocalc/frontend/editors/slate/static-markdown";
32
import { labels } from "@cocalc/frontend/i18n";
33
import { capitalize, trunc_middle } from "@cocalc/util/misc";
34
import { CourseActions } from "../actions";
35
import { BigTime, Progress, StudentAssignmentInfoHeader } from "../common";
36
import { nbgrader_status } from "../nbgrader/util";
37
import { webapp_client } from "@cocalc/frontend/webapp-client";
38
import type {
39
AssignmentRecord,
40
CourseStore,
41
IsGradingMap,
42
NBgraderRunInfo,
43
} from "../store";
44
import * as styles from "../styles";
45
import { AssignmentCopyStep, AssignmentStatus } from "../types";
46
import {
47
step_direction,
48
step_ready,
49
step_verb,
50
STEPS,
51
useButtonSize,
52
} from "../util";
53
import { StudentListForAssignment } from "./assignment-student-list";
54
import { ConfigurePeerGrading } from "./configure-peer";
55
import { STUDENT_SUBDIR } from "./consts";
56
import { ComputeServerButton } from "../compute";
57
58
interface AssignmentProps {
59
active_feedback_edits: IsGradingMap;
60
assignment: AssignmentRecord;
61
background?: string;
62
expand_peer_config?: boolean;
63
frame_id?: string;
64
is_expanded?: boolean;
65
name: string;
66
nbgrader_run_info?: NBgraderRunInfo;
67
project_id: string;
68
redux: AppRedux;
69
students: object;
70
user_map: object;
71
}
72
73
export function Assignment({
74
active_feedback_edits,
75
assignment,
76
background,
77
expand_peer_config,
78
frame_id,
79
is_expanded,
80
name,
81
nbgrader_run_info,
82
project_id,
83
redux,
84
students,
85
user_map,
86
}: AssignmentProps) {
87
const intl = useIntl();
88
const size = useButtonSize();
89
const assignmentId = assignment.get("assignment_id");
90
const noteProp = assignment.get("note") ?? "";
91
const [noteValue, setNoteValue] = useState<string>(noteProp);
92
const [noteEditing, setNoteEditing] = useState<boolean>(false);
93
94
useEffect(() => {
95
setNoteValue(noteProp);
96
setNoteEditing(false);
97
}, [assignmentId]);
98
99
useEffect(() => {
100
if (!noteEditing) {
101
setNoteValue(noteProp);
102
}
103
}, [noteProp, noteEditing]);
104
105
useEffect(() => {
106
if (is_peer_graded()) {
107
for (const step of ["assignment", "collect"] as const) {
108
if (assignment.get(`skip_${step}` as any)) {
109
actions.assignments.set_skip(assignmentId, step, false);
110
}
111
}
112
}
113
}, [assignmentId, assignment.getIn(["peer_grade", "enabled"])]);
114
115
useEffect(() => {
116
if (assignment.get("nbgrader") && is_peer_graded()) {
117
actions.assignments.set_peer_grade(assignmentId, { enabled: false });
118
setPeerDisabledForNbgrader(true);
119
}
120
}, [
121
assignmentId,
122
assignment.get("nbgrader"),
123
assignment.getIn(["peer_grade", "enabled"]),
124
]);
125
126
const [
127
copy_assignment_confirm_overwrite,
128
set_copy_assignment_confirm_overwrite,
129
] = useState<boolean>(false);
130
const [peerDisabledForNbgrader, setPeerDisabledForNbgrader] = useState(false);
131
const [
132
copy_assignment_confirm_overwrite_text,
133
set_copy_assignment_confirm_overwrite_text,
134
] = useState<string>("");
135
const [student_search, set_student_search] = useState<string>("");
136
const [opened_run_all, set_opened_run_all] = useState<
137
AssignmentCopyStep | "grade" | null
138
>(null);
139
const [confirm_all_students, set_confirm_all_students] =
140
useState<boolean>(false);
141
const [confirm_sync_grades, set_confirm_sync_grades] =
142
useState<boolean>(false);
143
144
const actions = useActions<CourseActions>({ name });
145
146
function get_store(): CourseStore {
147
return actions.get_store();
148
}
149
150
function is_peer_graded() {
151
return !!assignment.getIn(["peer_grade", "enabled"]);
152
}
153
154
function render_due() {
155
return (
156
<Space align="center">
157
<div>Due:</div>
158
<Tip
159
placement="top"
160
title="Set the due date"
161
tip="Set the due date for the assignment. This changes how the list of assignments is sorted. Note that you must explicitly click a button to collect student assignments when they are due -- they are not automatically collected on the due date. You should also tell students when assignments are due (e.g., at the top of the assignment)."
162
>
163
<DateTimePicker
164
placeholder={"Set Due Date"}
165
value={assignment.get("due_date")}
166
onChange={date_change}
167
/>
168
</Tip>
169
</Space>
170
);
171
}
172
173
function date_change(date): void {
174
actions.assignments.set_due_date(
175
assignment.get("assignment_id"),
176
date != null ? date.toISOString() : undefined,
177
);
178
}
179
180
function render_note() {
181
return (
182
<Space
183
key="note"
184
align="start"
185
style={{
186
display: "grid",
187
gridTemplateColumns: "auto 1fr",
188
}}
189
>
190
<Tip
191
title="Notes about this assignment"
192
tip="Record notes about this assignment here. These notes are only visible to you, not to your students. Put any instructions to students about assignments in a file in the directory that contains the assignment."
193
>
194
<Button
195
icon={<Icon name="pencil" />}
196
type={noteEditing ? "primary" : "default"}
197
onClick={() => {
198
if (noteEditing) {
199
actions.assignments.set_assignment_note(
200
assignmentId,
201
noteValue,
202
);
203
}
204
setNoteEditing(!noteEditing);
205
}}
206
>
207
{noteEditing ? "Done" : "Notes:"}
208
</Button>
209
</Tip>
210
<div style={{ minWidth: 0, width: "100%" }}>
211
{noteEditing ? (
212
<MultiMarkdownInput
213
value={noteValue}
214
onChange={(value: string) => setNoteValue(value)}
215
placeholder="Private notes about this assignment (not visible to students)"
216
height="200px"
217
minimal
218
enableUpload={false}
219
/>
220
) : (
221
<StaticMarkdown value={noteValue ?? ""} />
222
)}
223
</div>
224
</Space>
225
);
226
}
227
228
function render_export_file_use_times() {
229
return (
230
<Tip
231
title="Export when students used files"
232
tip="Export a JSON file containing extensive information about exactly when students have opened or edited files in this assignment. The JSON file will open in a new tab; the access_times (in milliseconds since the UNIX epoch) are when they opened the file and the edit_times are when they actually changed it through CoCalc's web-based editor."
233
>
234
<Button
235
onClick={() =>
236
actions.export.file_use_times(assignment.get("assignment_id"))
237
}
238
>
239
File Use Times
240
</Button>
241
</Tip>
242
);
243
}
244
245
function render_export_assignment() {
246
return (
247
<Tip
248
title="Export collected student files"
249
tip="Export all student work to files in a single directory that are easy to grade or archive outside of CoCalc. Any Jupyter notebooks or Sage worksheets are first converted to PDF (if possible), and all files are renamed with the student as a filename prefix."
250
>
251
<Button
252
onClick={() =>
253
actions.assignments.export_collected(
254
assignment.get("assignment_id"),
255
)
256
}
257
>
258
Export
259
</Button>
260
</Tip>
261
);
262
}
263
264
function render_no_content() {
265
if (assignment.get("deleted")) {
266
// no point
267
return null;
268
}
269
return (
270
<div style={{ margin: "15px auto", maxWidth: "800px", fontSize: "12pt" }}>
271
There are no files in this assignment yet. Please{" "}
272
<a onClick={open_assignment_path}>open the directory</a> for this
273
assignment, then create, upload, or copy any content you want into that
274
directory. {intl.formatMessage(labels.you)} will then be able to send it
275
to all of your students.
276
</div>
277
);
278
}
279
280
function render_more_header(num_files: number) {
281
const status: AssignmentStatus | undefined =
282
get_store().get_assignment_status(assignment.get("assignment_id"));
283
if (status == null) {
284
return <Loading key="loading_more" />;
285
}
286
const v: ReactElement<any>[] = [];
287
const stackSize = size === "small" ? "small" : "middle";
288
289
v.push(
290
<Space
291
key="header-stack"
292
direction="vertical"
293
size={stackSize}
294
style={{ width: "100%" }}
295
>
296
<Row gutter={[8, 4]} align="top" justify="space-between">
297
<Col md={16}>
298
<Space wrap size={[12, 6]}>
299
{render_open_button()}
300
{render_due()}
301
{render_peer_button()}
302
<ComputeServerButton actions={actions} unit={assignment as any} />
303
</Space>
304
</Col>
305
<Col md={8} style={{ marginLeft: "auto" }}>
306
<Space
307
wrap
308
size={[12, 6]}
309
style={{ width: "100%", justifyContent: "flex-end" }}
310
>
311
{render_export_file_use_times()}
312
{render_export_assignment()}
313
{render_delete_button()}
314
</Space>
315
</Col>
316
</Row>
317
318
{peerDisabledForNbgrader ? (
319
<div style={{ marginTop: 8 }}>
320
<Alert
321
type="warning"
322
showIcon
323
closable
324
onClose={() => setPeerDisabledForNbgrader(false)}
325
message="Peer grading was disabled because nbgrader notebooks were detected. Remove nbgrader metadata to re-enable peer grading."
326
/>
327
</div>
328
) : null}
329
330
{expand_peer_config ? (
331
<ConfigurePeerGrading actions={actions} assignment={assignment} />
332
) : null}
333
334
{render_note()}
335
<Divider style={{ borderTopWidth: 3, margin: 0 }} />
336
337
{(() => {
338
const peer = is_peer_graded();
339
340
if (num_files === 0) return null;
341
342
const actions: Partial<
343
Record<AssignmentCopyStep | "grade", ReactElement<any>[]>
344
> = {};
345
const progress: Partial<
346
Record<AssignmentCopyStep | "grade", ReactElement<any>>
347
> = {};
348
349
function add_action(
350
step: AssignmentCopyStep | "grade",
351
element: ReactElement<any>,
352
) {
353
actions[step] = [...(actions[step] ?? []), element];
354
}
355
356
const renderedMap: Partial<Record<AssignmentCopyStep, boolean>> = {};
357
358
for (const name of STEPS(peer)) {
359
const rendered = render_step_run_all(name, status);
360
// squeeze in the skip grading button (don't add it to STEPS!)
361
if (rendered != null) {
362
renderedMap[name] = true;
363
if (Array.isArray(rendered)) {
364
const buttons = rendered.filter(
365
(elem) => elem?.type !== Progress,
366
);
367
const prog = rendered.find(
368
(elem) => elem?.type === Progress,
369
) as ReactElement | undefined;
370
if (buttons.length > 0) {
371
add_action(name, <span key={name}>{buttons}</span>);
372
}
373
if (prog) {
374
progress[name] = prog;
375
}
376
} else {
377
add_action(name, <span key={name}>{rendered}</span>);
378
}
379
}
380
381
if (!peer) {
382
if (rendered && name === "assignment") {
383
add_action(
384
"assignment",
385
<span key="skip-assignment">
386
{render_skip_switch("assignment")}
387
</span>,
388
);
389
} else if (rendered && name === "collect") {
390
add_action(
391
"collect",
392
<span key="skip-collect">
393
{render_skip_switch("collect")}
394
</span>,
395
);
396
}
397
}
398
}
399
400
const gradeAction = render_grade_run_all(status);
401
if (gradeAction) {
402
add_action("grade", gradeAction);
403
}
404
405
if (status.collect > 0 && renderedMap.collect) {
406
add_action(
407
"grade",
408
<span key="skip-grade">
409
{render_skip_switch("grading", status.collect === 0)}
410
</span>,
411
);
412
}
413
414
if (
415
renderedMap.peer_assignment &&
416
status.peer_assignment != null &&
417
progress.peer_assignment == null
418
) {
419
progress["peer_assignment"] = (
420
<Progress
421
key="progress-peer-assign"
422
done={status.peer_assignment}
423
not_done={status.not_peer_assignment}
424
step="peer assigned"
425
/>
426
);
427
}
428
429
if (
430
renderedMap.peer_collect &&
431
status.peer_collect != null &&
432
progress.peer_collect == null
433
) {
434
progress["peer_collect"] = (
435
<Progress
436
key="progress-peer-collect"
437
done={status.peer_collect}
438
not_done={status.not_peer_collect}
439
step="peer collected"
440
/>
441
);
442
}
443
444
if (renderedMap.return_graded) {
445
progress["return_graded"] = (
446
<Progress
447
key="progress-return"
448
done={status.return_graded}
449
not_done={status.not_return_graded}
450
step="returned"
451
/>
452
);
453
}
454
455
return (
456
<>
457
<StudentAssignmentInfoHeader
458
key="header"
459
title="Student"
460
peer_grade={peer}
461
mode="assignment"
462
actions={actions}
463
progress={progress}
464
filter={
465
<DebounceInput
466
debounceTimeout={500}
467
element={Input as any}
468
placeholder={"Filter students..."}
469
value={student_search}
470
onChange={(e) => set_student_search(e.target.value)}
471
/>
472
}
473
/>
474
</>
475
);
476
})()}
477
</Space>,
478
);
479
/* The whiteSpace:'normal' here is because we put this in an
480
antd Card title, which has line wrapping disabled. */
481
return <div style={{ whiteSpace: "normal" }}>{v}</div>;
482
}
483
484
function render_more() {
485
const num_files = assignment.get("listing")?.size ?? 0;
486
let body;
487
if (num_files == 0) {
488
body = render_no_content();
489
} else {
490
body = (
491
<StudentListForAssignment
492
redux={redux}
493
frame_id={frame_id}
494
name={name}
495
assignment={assignment}
496
students={students}
497
user_map={user_map}
498
active_feedback_edits={active_feedback_edits}
499
nbgrader_run_info={nbgrader_run_info}
500
search={student_search}
501
/>
502
);
503
}
504
return (
505
<Row key="more">
506
<Col sm={24}>
507
<ConfigProvider componentSize={size}>
508
<Card>
509
{render_more_header(num_files)}
510
{body}
511
</Card>
512
</ConfigProvider>
513
</Col>
514
</Row>
515
);
516
}
517
518
function open_assignment_path(): void {
519
if (assignment.get("listing")?.size == 0) {
520
// there are no files yet, so we *close* the assignment
521
// details panel. This is just **a hack** so that the user
522
// has to re-open it after adding files, which will trigger
523
// updating the directory listing, hence show the rest
524
// of the assignment info. The alternative would be
525
// polling the directory or watching listings, which is
526
// a lot more work to properly implement.
527
actions.toggle_item_expansion(
528
"assignment",
529
assignment.get("assignment_id"),
530
);
531
}
532
return redux
533
.getProjectActions(project_id)
534
.open_directory(assignment.get("path"));
535
}
536
537
function render_open_button() {
538
return (
539
<Tip
540
key="open"
541
title={
542
<span>
543
<Icon name="folder-open" /> Open Folder
544
</span>
545
}
546
tip="Open the directory in the current project that contains the original files for this assignment. Edit files in this folder to create the content that your students will see when they receive an assignment."
547
>
548
<Button onClick={open_assignment_path}>
549
<Icon name="folder-open" /> {intl.formatMessage(labels.open)}
550
</Button>
551
</Tip>
552
);
553
}
554
555
function render_step_popover(
556
step: AssignmentCopyStep,
557
opts: {
558
type: "primary" | "default";
559
content: ReactNode;
560
onOpen?: () => void;
561
onClose?: () => void;
562
},
563
) {
564
const open = opened_run_all === step;
565
const handleOpenChange = (next: boolean) => {
566
set_opened_run_all(next ? step : null);
567
set_confirm_all_students(false);
568
set_confirm_sync_grades(false);
569
if (next) {
570
opts.onOpen?.();
571
} else {
572
opts.onClose?.();
573
}
574
};
575
return render_run_all_popover({
576
key: step,
577
open,
578
onOpenChange: handleOpenChange,
579
type: opts.type,
580
content: opts.content,
581
ariaLabel: run_all_button_aria_label(step),
582
});
583
}
584
585
function render_run_all_popover(opts: {
586
key: AssignmentCopyStep | "grade";
587
open: boolean;
588
onOpenChange: (next: boolean) => void;
589
type: "primary" | "default";
590
content: ReactNode;
591
ariaLabel: string;
592
}) {
593
return (
594
<Popover
595
key={opts.key}
596
placement="bottom"
597
trigger="click"
598
open={opts.open}
599
onOpenChange={opts.onOpenChange}
600
content={opts.content}
601
overlayInnerStyle={{ maxWidth: 545 }}
602
>
603
<span style={{ display: "inline-block" }}>
604
<Button
605
type={opts.type}
606
size="small"
607
icon={<Icon name="forward" />}
608
aria-label={opts.ariaLabel}
609
onClick={() => opts.onOpenChange(true)}
610
/>
611
</span>
612
</Popover>
613
);
614
}
615
616
function render_grade_popover(opts: {
617
type: "primary" | "default";
618
content: ReactNode;
619
}) {
620
const handleOpenChange = (next: boolean) => {
621
set_opened_run_all(next ? "grade" : null);
622
set_confirm_all_students(false);
623
set_confirm_sync_grades(false);
624
};
625
return render_run_all_popover({
626
key: "grade",
627
open: opened_run_all === "grade",
628
onOpenChange: handleOpenChange,
629
type: opts.type,
630
content: opts.content,
631
ariaLabel: run_all_button_aria_label("grade"),
632
});
633
}
634
635
function render_assignment_button(status) {
636
const has_new = status.not_assignment > 0;
637
const type = run_all_button_type(
638
"assignment",
639
status,
640
has_new,
641
);
642
643
return [
644
render_step_popover("assignment", {
645
type,
646
content: render_step_confirm("assignment", status),
647
onOpen: () => {
648
const assignment_id: string | undefined =
649
assignment.get("assignment_id");
650
actions.assignments.update_listing(assignment_id);
651
},
652
onClose: () => {
653
set_copy_assignment_confirm_overwrite(false);
654
},
655
}),
656
<Progress
657
key="progress"
658
done={status.assignment}
659
not_done={status.not_assignment}
660
step="assigned"
661
skipped={assignment.get("skip_assignment")}
662
/>,
663
];
664
}
665
666
function render_step_confirm(step, status) {
667
return render_copy_confirm(step, status);
668
}
669
670
function step_complete_or_skipped(
671
step: AssignmentCopyStep,
672
status: AssignmentStatus,
673
) {
674
if (step === "assignment" && assignment.get("skip_assignment")) {
675
return true;
676
}
677
if (step === "collect" && assignment.get("skip_collect")) {
678
return true;
679
}
680
const not_done = status[`not_${step}`];
681
if (not_done == null) return false;
682
return not_done === 0;
683
}
684
685
function previous_steps_complete(
686
step: AssignmentCopyStep,
687
status: AssignmentStatus,
688
) {
689
const peer = is_peer_graded();
690
for (const s of STEPS(peer)) {
691
if (s === step) break;
692
if (!step_complete_or_skipped(s, status)) {
693
return false;
694
}
695
}
696
return true;
697
}
698
699
function run_all_button_type(
700
step: AssignmentCopyStep,
701
status: AssignmentStatus,
702
has_new: boolean,
703
): "primary" | "default" {
704
const prev_complete = previous_steps_complete(step, status);
705
if (has_new && prev_complete) {
706
return "primary";
707
}
708
return "default";
709
}
710
711
function run_all_button_aria_label(
712
step: AssignmentCopyStep | "grade",
713
): string {
714
switch (step) {
715
case "assignment":
716
return "Assign to all students options";
717
case "collect":
718
return "Collect from all students options";
719
case "peer_assignment":
720
return "Assign for peer grading options";
721
case "peer_collect":
722
return "Collect peer grading options";
723
case "return_graded":
724
return "Return to all students options";
725
case "grade":
726
return "Autograde options";
727
default:
728
return "Run all options";
729
}
730
}
731
732
function is_nbgrader_running(): boolean {
733
if (nbgrader_run_info == null) return false;
734
const t = nbgrader_run_info.get(assignmentId);
735
// Time starting is set and it's also within the last few minutes.
736
// This "few minutes" is just in case -- we probably shouldn't need
737
// that at all ever, but it could make cocalc state usable in case of
738
// weird issues, I guess). User could also just close and re-open
739
// the course file, which resets this state completely.
740
return webapp_client.server_time() - (t ?? 0) <= 1000 * 60 * 10;
741
}
742
743
function render_nbgrader_run_all() {
744
const assignment_id = assignment.get("assignment_id");
745
if (assignment_id == null) return null;
746
const nbgrader = nbgrader_status(assignment);
747
const total = nbgrader.attempted + nbgrader.not_attempted;
748
const todo = nbgrader.not_attempted + nbgrader.failed;
749
const running = is_nbgrader_running();
750
const show_remaining =
751
todo > 0 && !confirm_all_students && !confirm_sync_grades;
752
const alertType = confirm_all_students || confirm_sync_grades
753
? "error"
754
: show_remaining
755
? "warning"
756
: "success";
757
const message = (
758
<Space direction="vertical">
759
<span>Autograde this assignment using nbgrader for</span>
760
{show_remaining && (
761
<Button
762
disabled={running}
763
type="primary"
764
onClick={() => {
765
actions.assignments.run_nbgrader_for_all_students(
766
assignment_id,
767
true,
768
);
769
set_opened_run_all(null);
770
}}
771
>
772
The {todo} student{todo > 1 ? "s" : ""} not already autograded
773
</Button>
774
)}
775
{nbgrader.attempted > 0 && !confirm_sync_grades && (
776
<Button
777
danger
778
disabled={running || confirm_all_students}
779
onClick={() => {
780
set_confirm_all_students(true);
781
set_confirm_sync_grades(false);
782
}}
783
>
784
All {total} students...
785
</Button>
786
)}
787
{confirm_all_students && (
788
<Space direction="vertical">
789
<div>Are you sure you want to autograde ALL {total} students?</div>
790
<Space>
791
<Button
792
danger
793
type="primary"
794
disabled={running}
795
onClick={() => {
796
actions.assignments.run_nbgrader_for_all_students(
797
assignment_id,
798
);
799
set_opened_run_all(null);
800
set_confirm_all_students(false);
801
}}
802
>
803
Autograde all {total} students
804
</Button>
805
<Button
806
onClick={() => set_confirm_all_students(false)}
807
disabled={running}
808
>
809
Back
810
</Button>
811
</Space>
812
</Space>
813
)}
814
{!confirm_all_students && (
815
<Button
816
disabled={running || confirm_sync_grades}
817
onClick={() => {
818
set_confirm_sync_grades(true);
819
set_confirm_all_students(false);
820
}}
821
>
822
Sync grades...
823
</Button>
824
)}
825
{confirm_sync_grades && (
826
<Space direction="vertical">
827
<div>
828
Copy the nbgrader grades to be the assigned grades for all
829
students, even if there are ungraded manual problems, errors or
830
other issues? You probably don't need to do this.
831
</div>
832
<Space>
833
<Button
834
danger
835
type="primary"
836
disabled={running}
837
onClick={() => {
838
actions.assignments.set_nbgrader_scores_for_all_students({
839
assignment_id,
840
force: true,
841
commit: true,
842
});
843
set_opened_run_all(null);
844
}}
845
>
846
Sync grades for all students
847
</Button>
848
<Button
849
onClick={() => set_confirm_sync_grades(false)}
850
disabled={running}
851
>
852
Back
853
</Button>
854
</Space>
855
</Space>
856
)}
857
</Space>
858
);
859
return <Alert type={alertType} message={message} />;
860
}
861
862
function render_copy_assignment_confirm_overwrite(step) {
863
if (!copy_assignment_confirm_overwrite) {
864
return;
865
}
866
const do_it = () => {
867
copy_assignment(step, false, true);
868
set_copy_assignment_confirm_overwrite(false);
869
set_copy_assignment_confirm_overwrite_text("");
870
};
871
return (
872
<Space direction="vertical">
873
Type in "OVERWRITE" in the box below if you are sure you want to
874
overwrite any work the students may have done.
875
<Input
876
autoFocus
877
onChange={(e) =>
878
set_copy_assignment_confirm_overwrite_text((e.target as any).value)
879
}
880
/>
881
<Button
882
disabled={copy_assignment_confirm_overwrite_text !== "OVERWRITE"}
883
icon={<Icon name="exclamation-triangle" />}
884
danger
885
type="primary"
886
onClick={do_it}
887
>
888
Confirm replacing files
889
</Button>
890
</Space>
891
);
892
}
893
894
function copy_assignment(
895
step,
896
new_only: boolean,
897
overwrite: boolean = false,
898
) {
899
// assign assignment to all (non-deleted) students
900
const assignment_id: string | undefined = assignment.get("assignment_id");
901
if (assignment_id == null) throw Error("bug");
902
switch (step) {
903
case "assignment":
904
actions.assignments.copy_assignment_to_all_students(
905
assignment_id,
906
new_only,
907
overwrite,
908
);
909
break;
910
case "collect":
911
actions.assignments.copy_assignment_from_all_students(
912
assignment_id,
913
new_only,
914
);
915
break;
916
case "peer_assignment":
917
actions.assignments.peer_copy_to_all_students(assignment_id, new_only);
918
break;
919
case "peer_collect":
920
actions.assignments.peer_collect_from_all_students(
921
assignment_id,
922
new_only,
923
);
924
break;
925
case "return_graded":
926
actions.assignments.return_assignment_to_all_students(
927
assignment_id,
928
new_only,
929
);
930
break;
931
default:
932
console.log(`BUG -- unknown step: ${step}`);
933
}
934
set_confirm_all_students(false);
935
set_opened_run_all(null);
936
}
937
938
function render_skip_switch(
939
step: "assignment" | "collect" | "grading",
940
disabled?: boolean,
941
) {
942
const skipped = assignment.get(`skip_${step}` as any);
943
return (
944
<Tip
945
title="Skip step in workflow"
946
tip="Toggle to allow proceeding to the next step without completing this one."
947
>
948
<Switch
949
checked={!!skipped}
950
onChange={() =>
951
actions.assignments.set_skip(
952
assignment.get("assignment_id"),
953
step,
954
!skipped,
955
)
956
}
957
checkedChildren="Skip"
958
unCheckedChildren="Skip"
959
size="small"
960
disabled={disabled}
961
/>
962
</Tip>
963
);
964
}
965
966
function copy_confirm_all_caution(step: AssignmentCopyStep) {
967
const caution = "CAUTION: All files will be copied again.";
968
const it_will =
969
"it will get copied to a backup file ending in a tilde (~), or possibly only be available in snapshots.";
970
switch (step) {
971
case "assignment":
972
return (
973
<span>
974
{caution} If you updated a file that a student has also worked on,{" "}
975
{it_will} Select "Replace student files!" if you do <b>not</b> want
976
to create any backups and want to <b>delete</b> all other files in
977
the assignment folder of student projects.{" "}
978
<a
979
rel="noopener noreferrer"
980
target="_blank"
981
href="https://doc.cocalc.com/teaching-tips_and_tricks.html#how-exactly-are-assignments-copied-to-students"
982
>
983
Details
984
</a>
985
</span>
986
);
987
case "collect":
988
case "peer_collect":
989
return `${caution} If you have graded or edited a file that a student has updated, ${it_will}`;
990
case "peer_assignment":
991
return `${caution} If a student worked on a previously assigned file, ${it_will}`;
992
case "return_graded":
993
return `${caution} If a student edited a previously returned file, ${it_will}`;
994
}
995
}
996
997
function render_copy_confirm_overwrite_all(step: AssignmentCopyStep) {
998
return (
999
<Space direction="vertical" key={"copy_confirm_overwrite_all"}>
1000
{copy_confirm_all_caution(step)}
1001
<Space>
1002
<Button
1003
key={"all"}
1004
type="primary"
1005
disabled={copy_assignment_confirm_overwrite}
1006
onClick={() => copy_assignment(step, false)}
1007
>
1008
Yes, do it (with backup)
1009
</Button>
1010
{step === "assignment" ? (
1011
<Button
1012
key={"all-overwrite"}
1013
danger
1014
onClick={() => set_copy_assignment_confirm_overwrite(true)}
1015
disabled={copy_assignment_confirm_overwrite}
1016
>
1017
Replace student files!
1018
</Button>
1019
) : undefined}
1020
<Button
1021
key="back"
1022
onClick={() => {
1023
set_confirm_all_students(false);
1024
set_copy_assignment_confirm_overwrite(false);
1025
}}
1026
>
1027
Back
1028
</Button>
1029
</Space>
1030
{render_copy_assignment_confirm_overwrite(step)}
1031
</Space>
1032
);
1033
}
1034
1035
function render_copy_confirm(step: AssignmentCopyStep, status) {
1036
const not_done = status[`not_${step}`];
1037
const possible = not_done + status[step];
1038
const total = get_store().num_students();
1039
const show_new_button = not_done && !confirm_all_students;
1040
const alertType = confirm_all_students
1041
? "error"
1042
: show_new_button
1043
? "warning"
1044
: "success";
1045
const message = (
1046
<Space
1047
direction="vertical"
1048
style={{ display: "inline-flex", alignItems: "stretch" }}
1049
>
1050
{/* Only the student/ subdirectory will be copied to the students. nbgrader docs */}
1051
{step === "assignment" && assignment.get("has_student_subdir") ? (
1052
<Alert
1053
type="info"
1054
message={
1055
<span>
1056
Only the {STUDENT_SUBDIR}/ subdirectory will be copied to the
1057
students.{" "}
1058
<a
1059
rel="noopener noreferrer"
1060
target="_blank"
1061
href="https://doc.cocalc.com/teaching-nbgrader.html#student-version"
1062
>
1063
nbgrader docs
1064
</a>
1065
</span>
1066
}
1067
/>
1068
) : undefined}
1069
{/* Assign this assignment to */}
1070
<div>
1071
{capitalize(step_verb(step))} this assignment {step_direction(step)}
1072
</div>
1073
{/* The 15 students not already assigned to */}
1074
{show_new_button ? (
1075
<Button
1076
key="new"
1077
type="primary"
1078
onClick={() => copy_assignment(step, true)}
1079
>
1080
{not_done === total ? (
1081
<>All {total} students</>
1082
) : (
1083
<>
1084
The {not_done} student{not_done > 1 ? "s" : ""} not already{" "}
1085
{step_verb(step)}ed {step_direction(step)}
1086
</>
1087
)}
1088
</Button>
1089
) : undefined}
1090
{/* All 19 students... */}
1091
{not_done !== possible ? (
1092
<Button
1093
key="all"
1094
danger
1095
disabled={confirm_all_students}
1096
onClick={() => {
1097
set_confirm_all_students(true);
1098
}}
1099
>
1100
All {possible} students
1101
{step_ready(step, possible)}...
1102
</Button>
1103
) : undefined}
1104
{confirm_all_students
1105
? render_copy_confirm_overwrite_all(step)
1106
: undefined}
1107
</Space>
1108
);
1109
return (
1110
<Alert key={`copy_confirm_${step}`} type={alertType} message={message} />
1111
);
1112
}
1113
1114
function render_step_run_all(state: AssignmentCopyStep, status) {
1115
switch (state) {
1116
case "collect":
1117
return render_collect_button(status);
1118
case "return_graded":
1119
return render_return_graded_button(status);
1120
case "peer_assignment":
1121
return render_peer_assignment_button(status);
1122
case "peer_collect":
1123
return render_peer_collect_button(status);
1124
case "assignment":
1125
return render_assignment_button(status);
1126
}
1127
}
1128
1129
function render_grade_run_all(status) {
1130
if (!assignment.get("nbgrader") || status.collect === 0) {
1131
return;
1132
}
1133
const nbgrader = nbgrader_status(assignment);
1134
if (nbgrader.attempted + nbgrader.not_attempted === 0) {
1135
return;
1136
}
1137
const has_new = nbgrader.not_attempted + nbgrader.failed > 0;
1138
const type = run_all_button_type(
1139
"return_graded",
1140
status,
1141
has_new,
1142
);
1143
return render_grade_popover({
1144
type,
1145
content: render_nbgrader_run_all(),
1146
});
1147
}
1148
1149
function render_collect_button(status) {
1150
if (status.assignment === 0) {
1151
// no button if nothing ever assigned
1152
return;
1153
}
1154
const has_new = status.not_collect > 0;
1155
const type = run_all_button_type(
1156
"collect",
1157
status,
1158
has_new,
1159
);
1160
return [
1161
render_step_popover("collect", {
1162
type,
1163
content: render_step_confirm("collect", status),
1164
}),
1165
<Progress
1166
key="progress"
1167
done={status.collect}
1168
not_done={status.not_collect}
1169
step="collected"
1170
skipped={assignment.get("skip_collect")}
1171
/>,
1172
];
1173
}
1174
1175
function render_peer_assignment_button(status) {
1176
// Render the "Peer Assign..." button in the top row, for peer assigning to all
1177
// students in the course.
1178
if (status.peer_assignment == null) {
1179
// not peer graded
1180
return;
1181
}
1182
if (status.not_collect + status.not_assignment > 0) {
1183
// collect everything before peer grading
1184
return;
1185
}
1186
if (status.collect === 0) {
1187
// nothing to peer assign
1188
return;
1189
}
1190
const has_new = status.not_peer_assignment > 0;
1191
const type = run_all_button_type(
1192
"peer_assignment",
1193
status,
1194
has_new,
1195
);
1196
return [
1197
render_step_popover("peer_assignment", {
1198
type,
1199
content: render_step_confirm("peer_assignment", status),
1200
}),
1201
<Progress
1202
key="progress"
1203
done={status.peer_assignment}
1204
not_done={status.not_peer_assignment}
1205
step="peer assigned"
1206
/>,
1207
];
1208
}
1209
1210
function render_peer_collect_button(status) {
1211
// Render the "Peer Collect..." button in the top row, for collecting peer grading from all
1212
// students in the course.
1213
if (status.peer_collect == null) {
1214
return;
1215
}
1216
if (status.peer_assignment === 0) {
1217
// haven't even peer assigned anything -- so nothing to collect
1218
return;
1219
}
1220
if (status.not_peer_assignment > 0) {
1221
// everybody must have received peer assignment, or collecting isn't allowed
1222
return;
1223
}
1224
const has_new = status.not_peer_collect > 0;
1225
const type = run_all_button_type(
1226
"peer_collect",
1227
status,
1228
has_new,
1229
);
1230
return [
1231
render_step_popover("peer_collect", {
1232
type,
1233
content: render_step_confirm("peer_collect", status),
1234
}),
1235
<Progress
1236
key="progress"
1237
done={status.peer_collect}
1238
not_done={status.not_peer_collect}
1239
step="peer collected"
1240
/>,
1241
];
1242
}
1243
1244
function render_return_graded_button(status) {
1245
if (status.collect === 0) {
1246
// No button if nothing collected.
1247
return;
1248
}
1249
if (status.peer_collect != null && status.peer_collect === 0) {
1250
// Peer grading enabled, but we didn't collect anything yet
1251
return;
1252
}
1253
if (
1254
!assignment.get("skip_grading") &&
1255
status.not_return_graded === 0 &&
1256
status.return_graded === 0
1257
) {
1258
// Nothing unreturned and ungraded yet and also nothing returned yet
1259
return;
1260
}
1261
const has_new = status.not_return_graded > 0;
1262
const type = run_all_button_type(
1263
"return_graded",
1264
status,
1265
has_new,
1266
);
1267
return [
1268
render_step_popover("return_graded", {
1269
type,
1270
content: render_step_confirm("return_graded", status),
1271
}),
1272
<Progress
1273
key="progress"
1274
done={status.return_graded}
1275
not_done={status.not_return_graded}
1276
step="returned"
1277
/>,
1278
];
1279
}
1280
1281
function delete_assignment() {
1282
actions.assignments.delete_assignment(assignment.get("assignment_id"));
1283
}
1284
1285
function undelete_assignment() {
1286
return actions.assignments.undelete_assignment(
1287
assignment.get("assignment_id"),
1288
);
1289
}
1290
1291
function render_delete_button() {
1292
if (assignment.get("deleted")) {
1293
return (
1294
<Tip
1295
key="delete"
1296
placement="left"
1297
title={intl.formatMessage({
1298
id: "course.assignment.undelete.title",
1299
defaultMessage: "Undelete assignment",
1300
})}
1301
tip={intl.formatMessage({
1302
id: "course.assignment.undelete.tooltip",
1303
defaultMessage:
1304
"Make the assignment visible again in the assignment list and in student grade lists.",
1305
})}
1306
>
1307
<Button onClick={undelete_assignment}>
1308
<Icon name="trash" /> {intl.formatMessage(labels.undelete)}
1309
</Button>
1310
</Tip>
1311
);
1312
} else {
1313
return (
1314
<Popconfirm
1315
title={
1316
<div style={{ maxWidth: "400px" }}>
1317
<FormattedMessage
1318
id="course.assignment.delete.confirm.info"
1319
defaultMessage={`<b>Are you sure you want to delete {name}"?</b>
1320
{br}
1321
This removes it from the assignment list and student grade lists,
1322
but does not delete any files off of disk.
1323
You can undelete an assignment later by showing it using the 'Show deleted assignments' button.`}
1324
values={{
1325
name: trunc_middle(assignment.get("path"), 24),
1326
br: <br />,
1327
}}
1328
/>
1329
</div>
1330
}
1331
onConfirm={delete_assignment}
1332
cancelText={intl.formatMessage(labels.cancel)}
1333
>
1334
<Button>
1335
<Icon name="trash" /> {intl.formatMessage(labels.delete)}...
1336
</Button>
1337
</Popconfirm>
1338
);
1339
}
1340
}
1341
1342
function render_peer_button() {
1343
let icon;
1344
if (is_peer_graded()) {
1345
icon = "check-square-o";
1346
} else {
1347
icon = "square-o";
1348
}
1349
const disabledForNbgrader = !!assignment.get("nbgrader");
1350
const button = (
1351
<Button
1352
disabled={expand_peer_config || disabledForNbgrader}
1353
onClick={() =>
1354
actions.toggle_item_expansion(
1355
"peer_config",
1356
assignment.get("assignment_id"),
1357
)
1358
}
1359
>
1360
<Icon name={icon} /> Peer Grading...
1361
</Button>
1362
);
1363
if (!disabledForNbgrader) {
1364
return button;
1365
}
1366
return (
1367
<Tip title="Peer grading is disabled because nbgrader notebooks were detected">
1368
<span>{button}</span>
1369
</Tip>
1370
);
1371
}
1372
1373
function render_summary_due_date() {
1374
const due_date = assignment.get("due_date");
1375
if (due_date) {
1376
return (
1377
<div style={{ marginTop: "12px" }}>
1378
Due <BigTime date={due_date} />
1379
</div>
1380
);
1381
}
1382
}
1383
1384
function render_assignment_name() {
1385
const num_items = assignment.get("listing")?.size ?? 0;
1386
return (
1387
<span>
1388
{trunc_middle(assignment.get("path"), 80)}
1389
{assignment.get("deleted") ? <b> (deleted)</b> : undefined}
1390
{num_items == 0 ? " - add content to this assignment..." : undefined}
1391
</span>
1392
);
1393
}
1394
1395
function render_assignment_title_link() {
1396
return (
1397
<a
1398
href=""
1399
onClick={(e) => {
1400
e.preventDefault();
1401
actions.toggle_item_expansion(
1402
"assignment",
1403
assignment.get("assignment_id"),
1404
);
1405
}}
1406
>
1407
<Icon
1408
style={{ marginRight: "10px" }}
1409
name={is_expanded ? "caret-down" : "caret-right"}
1410
/>
1411
{render_assignment_name()}
1412
</a>
1413
);
1414
}
1415
1416
function render_summary_line() {
1417
return (
1418
<Row key="summary" style={{ backgroundColor: background }}>
1419
<Col md={12}>
1420
<h5>{render_assignment_title_link()}</h5>
1421
</Col>
1422
<Col md={12}>{render_summary_due_date()}</Col>
1423
</Row>
1424
);
1425
}
1426
1427
return (
1428
<div>
1429
<Row style={is_expanded ? styles.selected_entry : styles.entry_style}>
1430
<Col xs={24}>
1431
{render_summary_line()}
1432
{is_expanded ? render_more() : undefined}
1433
</Col>
1434
</Row>
1435
</div>
1436
);
1437
}
1438
1439