CoCalc Logo Icon
StoreFeaturesDocsShareSupportNewsAboutSign UpSign In
sagemathinc

Real-time collaboration for Jupyter Notebooks, Linux Terminals, LaTeX, VS Code, R IDE, and more,
all in one place.

GitHub Repository: sagemathinc/cocalc
Path: blob/master/src/packages/frontend/course/students/students-panel-student.tsx
Views: 687
1
/*
2
* This file is part of CoCalc: Copyright © 2020 Sagemath, Inc.
3
* License: MS-RSL – see LICENSE.md for details
4
*/
5
import {
6
Button,
7
Card,
8
Col,
9
Input,
10
Popconfirm,
11
Row,
12
Space,
13
Tooltip,
14
} from "antd";
15
import { useEffect, useState } from "react";
16
import { FormattedMessage, useIntl } from "react-intl";
17
18
import {
19
Icon,
20
MarkdownInput,
21
Text,
22
TimeAgo,
23
Tip,
24
} from "@cocalc/frontend/components";
25
import { labels } from "@cocalc/frontend/i18n";
26
import { ProjectMap, UserMap } from "@cocalc/frontend/todo-types";
27
import { User } from "@cocalc/frontend/users";
28
import { webapp_client } from "@cocalc/frontend/webapp-client";
29
import { search_match, search_split, trunc_middle } from "@cocalc/util/misc";
30
import { COLORS } from "@cocalc/util/theme";
31
import { CourseActions } from "../actions";
32
import { StudentAssignmentInfo, StudentAssignmentInfoHeader } from "../common";
33
import {
34
AssignmentsMap,
35
IsGradingMap,
36
NBgraderRunInfo,
37
StudentRecord,
38
} from "../store";
39
import { RESEND_INVITE_BEFORE } from "../student-projects/actions";
40
import * as styles from "../styles";
41
import * as util from "../util";
42
import { useButtonSize } from "../util";
43
import DeletedAccount from "./deleted-account";
44
45
export interface StudentNameDescription {
46
full: string;
47
first: string;
48
last: string;
49
}
50
51
/*
52
Updates based on:
53
- Expanded/Collapsed
54
- If collapsed: First name, last name, email, last active, hosting type
55
- If expanded: Above +, Student's status on all assignments,
56
*/
57
58
interface StudentProps {
59
redux: any;
60
name: string;
61
student: StudentRecord;
62
student_id: string;
63
user_map: UserMap;
64
project_map: ProjectMap; // here entirely to cause an update when project activity happens
65
assignments: AssignmentsMap; // here entirely to cause an update when project activity happens
66
background?: string;
67
is_expanded?: boolean;
68
student_name: StudentNameDescription;
69
display_account_name?: boolean;
70
active_feedback_edits: IsGradingMap;
71
nbgrader_run_info?: NBgraderRunInfo;
72
assignmentFilter?;
73
}
74
75
export function Student({
76
redux,
77
name,
78
student,
79
student_id,
80
user_map,
81
project_map,
82
//assignments,
83
background,
84
is_expanded,
85
student_name,
86
display_account_name,
87
active_feedback_edits,
88
nbgrader_run_info,
89
assignmentFilter,
90
}: StudentProps) {
91
const intl = useIntl();
92
const actions: CourseActions = redux.getActions(name);
93
const store = actions.get_store();
94
if (store == null) throw Error("store must be defined");
95
96
const deletedAccount = !!student.get("deleted_account");
97
const hasAccount = student.get("account_id") != null;
98
99
const size = useButtonSize();
100
101
const [editing_student, set_editing_student] = useState<boolean>(false);
102
const [edited_first_name, set_edited_first_name] = useState<string>(
103
student_name.first || "",
104
);
105
const [edited_last_name, set_edited_last_name] = useState<string>(
106
student_name.last || "",
107
);
108
const [edited_email_address, set_edited_email_address] = useState<string>(
109
student.get("email_address") || "",
110
);
111
const [more, set_more] = useState<boolean>(false);
112
function reset_initial_state() {
113
set_editing_student(false);
114
set_edited_first_name(student_name.first || "");
115
set_edited_last_name(student_name.last || "");
116
set_edited_email_address(student.get("email_address") || "");
117
set_more(false);
118
actions.students.setAssignmentFilter(student_id, "");
119
}
120
121
useEffect(() => {
122
set_edited_first_name(student_name.first);
123
}, [student_name.first]);
124
useEffect(() => {
125
set_edited_last_name(student_name.last);
126
}, [student_name.last]);
127
useEffect(() => {
128
set_edited_email_address(student.get("email_address") ?? "");
129
}, [student.get("email_address")]);
130
131
function on_key_down(e) {
132
switch (e.keyCode) {
133
case 13:
134
return save_student_changes();
135
case 27:
136
return cancel_student_edit();
137
}
138
}
139
140
function toggle_show_more(e) {
141
e.preventDefault();
142
if (editing_student) {
143
cancel_student_edit();
144
}
145
const item_id = student.get("student_id");
146
actions.toggle_item_expansion("student", item_id);
147
}
148
149
function render_student() {
150
return (
151
<a href="" onClick={toggle_show_more}>
152
<div style={{ width: "20px", display: "inline-block" }}>
153
<Icon
154
style={{ marginRight: "10px" }}
155
name={is_expanded ? "caret-down" : "caret-right"}
156
/>
157
</div>
158
{render_student_name()}
159
</a>
160
);
161
}
162
163
function render_student_name() {
164
const account_id = student.get("account_id");
165
if (account_id != null) {
166
return (
167
<User
168
account_id={account_id}
169
user_map={user_map}
170
name={student_name.full}
171
show_original={display_account_name}
172
/>
173
);
174
}
175
const name = store.get_student_name(student.get("student_id"));
176
return (
177
<span>
178
{name} ({intl.formatMessage(labels.invited)})
179
</span>
180
);
181
}
182
183
function render_student_email() {
184
const email = student.get("email_address");
185
return (
186
<a target={"_blank"} href={`mailto:${email}`} rel={"noopener"}>
187
{email}
188
</a>
189
);
190
}
191
192
function open_project() {
193
redux.getActions("projects").open_project({
194
project_id: student.get("project_id"),
195
});
196
}
197
198
function create_project() {
199
actions.student_projects.create_student_project(student_id);
200
}
201
202
function render_last_active() {
203
if (deletedAccount) {
204
return (
205
<DeletedAccount
206
actions={actions}
207
student_id={student_id}
208
name={render_student_name()}
209
email_address={student.get("email_address")}
210
/>
211
);
212
}
213
if (!hasAccount) {
214
return (
215
<span style={{ color: COLORS.GRAY_M }}>
216
<FormattedMessage
217
id="course.students-panel-student.last_active.no_account"
218
defaultMessage="(has not created account yet)"
219
description="The student in the online course has no account yet"
220
/>
221
</span>
222
);
223
}
224
const student_project_id = student.get("project_id");
225
if (student_project_id == null) {
226
return;
227
}
228
const p = project_map.get(student_project_id);
229
if (p == null) {
230
// no info about this project? maybe we need to load full list or
231
// users isn't a collab, so don't know.
232
const project_actions = redux.getActions("projects");
233
if (project_actions != null) {
234
// If this does load all (since not loaded), then will try again to
235
// render with new project_map.
236
project_actions.load_all_projects();
237
}
238
return;
239
}
240
const u = p.get("last_active");
241
const last_active = u != null ? u.get(student.get("account_id")) : null;
242
if (last_active) {
243
// student has definitely been active (and we know about this project).
244
return (
245
<Text type="secondary">
246
<FormattedMessage
247
id="course.students-panel-student.last_active.time_ago"
248
defaultMessage={"(last used project {timeago})"}
249
values={{ timeago: <TimeAgo date={last_active} /> }}
250
/>
251
</Text>
252
);
253
} else {
254
return (
255
<Text type="secondary">
256
<FormattedMessage
257
id="course.students-panel-student.last_active.never_used_project"
258
defaultMessage={"(has never used project)"}
259
/>
260
</Text>
261
);
262
}
263
}
264
265
function render_hosting() {
266
const { description, tip, state, icon } = util.projectStatus(
267
student.get("project_id"),
268
redux,
269
intl,
270
);
271
return (
272
<Tip
273
placement="left"
274
title={
275
<span>
276
<Icon name={icon} /> {description}
277
</span>
278
}
279
tip={tip}
280
>
281
<span style={{ color: COLORS.GRAY_M, cursor: "pointer" }}>
282
<Icon name={icon} /> {description}
283
{state}
284
</span>
285
</Tip>
286
);
287
}
288
289
function render_project_access(): JSX.Element {
290
// first check if the project is currently being created
291
const create = student.get("create_project");
292
if (create != null) {
293
// if so, how long ago did it start
294
const how_long = (webapp_client.server_time() - create) / 1000;
295
if (how_long < 120) {
296
// less than 2 minutes -- still hope, so render that creating
297
return (
298
<div>
299
<Icon name="cocalc-ring" spin /> Creating project... (started{" "}
300
<TimeAgo date={create} />)
301
</div>
302
);
303
}
304
}
305
// otherwise, maybe user killed file before finished or something and
306
// it is lost; give them the chance
307
// to attempt creation again by clicking the create button.
308
const student_project_id = student.get("project_id");
309
if (student_project_id != null) {
310
const accessMsg = intl.formatMessage({
311
id: "course.student-panel.project_access.access_button",
312
defaultMessage: "Open student project",
313
});
314
return (
315
<Button onClick={open_project} size={size}>
316
<Tip
317
placement="right"
318
title={accessMsg}
319
tip={intl.formatMessage({
320
id: "course.student-panel.project_access.access_button.tooltip",
321
defaultMessage: "Open the course project for this student.",
322
})}
323
>
324
<Icon name="edit" /> {accessMsg}
325
</Tip>
326
</Button>
327
);
328
} else {
329
const createMsg = intl.formatMessage({
330
id: "course.student-panel.project_access.create_button",
331
defaultMessage: "Create student project",
332
});
333
return (
334
<Tip
335
placement="right"
336
title={createMsg}
337
tip={intl.formatMessage({
338
id: "course.student-panel.project_access.create_button.tooltip",
339
defaultMessage:
340
"Create a new project for this student, then add the student as a collaborator, and also add any collaborators on the project containing this course.",
341
})}
342
>
343
<Button onClick={create_project} size={size}>
344
<Icon name="plus-circle" /> {createMsg}
345
</Button>
346
</Tip>
347
);
348
}
349
}
350
351
function student_changed() {
352
return (
353
student_name.first !== edited_first_name ||
354
student_name.last !== edited_last_name ||
355
student.get("email_address") !== edited_email_address
356
);
357
}
358
359
function render_edit_student() {
360
if (editing_student) {
361
const disable_save = !student_changed();
362
return (
363
<Space>
364
<Button onClick={cancel_student_edit} size={size}>
365
{intl.formatMessage(labels.cancel)}
366
</Button>
367
<Button
368
onClick={save_student_changes}
369
type="primary"
370
disabled={disable_save}
371
size={size}
372
>
373
<Icon name="save" /> {intl.formatMessage(labels.save)}
374
</Button>
375
</Space>
376
);
377
} else {
378
return (
379
<Button onClick={show_edit_name_dialogue} size={size}>
380
<Icon name="address-card" />{" "}
381
<FormattedMessage
382
id="course.students-panel-student.edit_student.button"
383
defaultMessage="Edit student..."
384
description="Button label to open a dialog to modify data about a student in an online course"
385
/>
386
</Button>
387
);
388
}
389
}
390
391
function render_search_assignment() {
392
return (
393
<Input.Search
394
allowClear
395
style={{ width: "100%" }}
396
placeholder={"Filter assignments..."}
397
value={assignmentFilter ?? ""}
398
onChange={(e) =>
399
actions.students.setAssignmentFilter(student_id, e.target.value)
400
}
401
/>
402
);
403
}
404
405
function cancel_student_edit() {
406
reset_initial_state();
407
}
408
409
function save_student_changes() {
410
actions.students.set_internal_student_info(student.get("student_id"), {
411
first_name: edited_first_name,
412
last_name: edited_last_name,
413
email_address: edited_email_address,
414
});
415
416
set_editing_student(false);
417
}
418
419
function show_edit_name_dialogue() {
420
set_editing_student(true);
421
}
422
423
function delete_student(noTrash: boolean) {
424
actions.students.delete_student(student.get("student_id"), noTrash);
425
}
426
427
function undelete_student() {
428
actions.students.undelete_student(student.get("student_id"));
429
}
430
431
function render_delete_button() {
432
if (!is_expanded) {
433
return;
434
}
435
if (student.get("deleted")) {
436
return (
437
<Button onClick={undelete_student} size={size}>
438
<Icon name="trash" /> {intl.formatMessage(labels.undelete)}
439
</Button>
440
);
441
} else {
442
return (
443
<Popconfirm
444
title={
445
<div style={{ maxWidth: "400px" }}>
446
<FormattedMessage
447
id="course.student-panel.delete-student.confirm"
448
defaultMessage={`Are you sure you want to delete "{name}"?
449
All grades and other data about them will be removed,
450
but you can still undelete them.`}
451
values={{ name: render_student_name() }}
452
/>
453
</div>
454
}
455
onConfirm={() => delete_student(false)}
456
>
457
<Button size={size}>
458
<Icon name="trash" /> {intl.formatMessage(labels.delete)}...
459
</Button>
460
</Popconfirm>
461
);
462
}
463
}
464
465
function render_resend_invitation() {
466
// don't invite student if there is already an account
467
if (hasAccount) return;
468
const last_email_invite = student.get("last_email_invite");
469
const allowResending =
470
!last_email_invite || new Date(last_email_invite) < RESEND_INVITE_BEFORE;
471
472
const msg = intl.formatMessage(
473
{
474
id: "course.student-panel.resend_invitation.button",
475
defaultMessage: `{allowResending, select, true {Resend invitation} other {Recently invited}}`,
476
},
477
{ allowResending },
478
);
479
const when =
480
last_email_invite != null
481
? `Last invitation sent on ${new Date(
482
last_email_invite,
483
).toLocaleString()}`
484
: "never";
485
486
return (
487
<Tooltip placement="bottom" title={when}>
488
<Button
489
size={size}
490
onClick={() => {
491
const email = student.get("email_address");
492
if (email) {
493
actions.student_projects.invite_student_to_project({
494
student: email, // we use email address to trigger sending an actual email!
495
student_project_id: student.get("project_id"),
496
student_id: student.get("student_id"),
497
});
498
}
499
}}
500
disabled={!allowResending}
501
>
502
<Icon name="mail" /> {msg}
503
</Button>
504
</Tooltip>
505
);
506
}
507
508
function render_title_due(assignment) {
509
const date = assignment.get("due_date");
510
if (date) {
511
return (
512
<span>
513
(Due <TimeAgo date={date} />)
514
</span>
515
);
516
}
517
}
518
519
function render_title(assignment) {
520
return (
521
<span>
522
<em>{trunc_middle(assignment.get("path"), 50)}</em>{" "}
523
{render_title_due(assignment)}
524
</span>
525
);
526
}
527
528
function render_assignments_info_rows() {
529
const result: any[] = [];
530
const terms = search_split(assignmentFilter ?? "");
531
// TODO instead of accessing the store, use the state to react to data changes -- that's why we chech in "isSame" above.
532
for (const assignment of store.get_sorted_assignments()) {
533
if (terms.length > 0) {
534
const aPath = assignment.get("path")?.toLowerCase() ?? "";
535
if (!search_match(aPath, terms)) continue;
536
}
537
const grade = store.get_grade(
538
assignment.get("assignment_id"),
539
student.get("student_id"),
540
);
541
const comments = store.get_comments(
542
assignment.get("assignment_id"),
543
student.get("student_id"),
544
);
545
const info = store.student_assignment_info(
546
student.get("student_id"),
547
assignment.get("assignment_id"),
548
);
549
const key = util.assignment_identifier(
550
assignment.get("assignment_id"),
551
student.get("student_id"),
552
);
553
const edited_feedback = active_feedback_edits.get(key);
554
result.push(
555
<StudentAssignmentInfo
556
key={assignment.get("assignment_id")}
557
title={render_title(assignment)}
558
name={name}
559
student={student}
560
assignment={assignment}
561
grade={grade}
562
comments={comments}
563
nbgrader_scores={store.get_nbgrader_scores(
564
assignment.get("assignment_id"),
565
student.get("student_id"),
566
)}
567
info={info}
568
is_editing={!!edited_feedback}
569
nbgrader_run_info={nbgrader_run_info}
570
/>,
571
);
572
}
573
return result;
574
}
575
576
function render_assignments_info() {
577
const peer_grade = store.any_assignment_uses_peer_grading();
578
const header = (
579
<StudentAssignmentInfoHeader
580
key="header"
581
title="Assignment"
582
peer_grade={peer_grade}
583
/>
584
);
585
return [header, render_assignments_info_rows()];
586
}
587
588
function render_note() {
589
const title = intl.formatMessage({
590
id: "course.students-panel-student.note.title",
591
defaultMessage: "Private Student Notes",
592
description: "About a student in an online course",
593
});
594
const tooltipTitle = intl.formatMessage({
595
id: "course.students-panel-student.note.tooltip.title",
596
defaultMessage: "Notes about this student",
597
description: "About a student in an online course",
598
});
599
const tooltip = intl.formatMessage({
600
id: "course.students-panel-student.note.tooltip",
601
defaultMessage:
602
"Record notes about this student here. These notes are only visible to you, not to the student. In particular, you might want to include an email address or other identifying information here, and notes about late assignments, excuses, etc.",
603
description: "About a student in an online course",
604
});
605
const placeholder = intl.formatMessage({
606
id: "course.students-panel-student.note.placeholder",
607
defaultMessage: "Notes about student (not visible to student)",
608
description: "About a student in an online course",
609
});
610
return (
611
<Row key="note" style={styles.note}>
612
<Col xs={4}>
613
<Tip title={tooltipTitle} tip={tooltip}>
614
{title}
615
</Tip>
616
</Col>
617
<Col xs={20}>
618
<MarkdownInput
619
persist_id={student.get("student_id") + "note"}
620
attach_to={name}
621
rows={6}
622
placeholder={placeholder}
623
default_value={student.get("note")}
624
on_save={(value) =>
625
actions.students.set_student_note(
626
student.get("student_id"),
627
value,
628
)
629
}
630
/>
631
</Col>
632
</Row>
633
);
634
}
635
636
function render_more_info() {
637
// Info for each assignment about the student.
638
return (
639
<>
640
<Row key="more">
641
<Col md={24}>{render_assignments_info()}</Col>
642
</Row>
643
{render_note()}
644
{render_push_missing_handouts_and_assignments()}
645
</>
646
);
647
}
648
649
function render_basic_info() {
650
return (
651
<Row key="basic" style={{ backgroundColor: background }}>
652
<Col md={6}>
653
<h6>
654
{render_student()}
655
{render_deleted()}
656
</h6>
657
</Col>
658
<Col md={4}>
659
<h6 style={{ color: "#666", overflow: "hidden" }}>
660
{render_student_email()}
661
</h6>
662
</Col>
663
<Col md={8} style={{ paddingTop: "10px" }}>
664
{render_last_active()}
665
</Col>
666
<Col md={6} style={{ paddingTop: "10px" }}>
667
{render_hosting()}
668
</Col>
669
</Row>
670
);
671
}
672
673
function render_push_missing_handouts_and_assignments() {
674
const title = intl.formatMessage({
675
id: "course.students-panel-student.catch-up.title",
676
defaultMessage: "Catch up this student",
677
description:
678
"Copy all not yet sent files to this student in an online course",
679
});
680
const tooltip = intl.formatMessage({
681
id: "course.students-panel-student.catch-up.tooltip",
682
defaultMessage:
683
"Copy any assignments and handouts to this student that have been copied to at least one other student",
684
description: "Files for a student in an online course",
685
});
686
687
return (
688
<Row key="catchup" style={{ marginTop: "15px" }}>
689
<Col xs={4}>
690
<Tip title={title} tip={tooltip}>
691
<FormattedMessage
692
id="course.students-panel-student.catch-up.info"
693
defaultMessage={"Copy missing assignments and handouts"}
694
/>
695
</Tip>
696
</Col>
697
<Col xs={8}>
698
<Button
699
onClick={() =>
700
actions.students.push_missing_handouts_and_assignments(
701
student.get("student_id"),
702
)
703
}
704
>
705
<Icon name="share-square" /> {title}
706
</Button>
707
</Col>
708
</Row>
709
);
710
}
711
712
function render_deleted() {
713
if (student.get("deleted")) {
714
return <b> (deleted)</b>;
715
}
716
}
717
718
function render_panel_header() {
719
// The whiteSpace normal is because the title of an
720
// antd Card doesn't wrap, and I don't want to restructure
721
// this whole student delete code right now to not put
722
// confirmation in the title. When it is restructured
723
// it'll be the antd modal popup anyways...
724
// See https://github.com/sagemathinc/cocalc/issues/4286
725
return (
726
<div style={{ whiteSpace: "normal" }}>
727
<Row>
728
<Col md={4}>{render_project_access()}</Col>
729
<Col md={4}>{render_edit_student()}</Col>
730
<Col md={4}>{render_search_assignment()}</Col>
731
<Col md={2} offset={3}>
732
{render_resend_invitation()}
733
</Col>
734
<Col md={4} offset={3}>
735
{render_delete_button()}
736
</Col>
737
</Row>
738
{editing_student ? (
739
<Row>
740
<Col md={8}>{render_edit_student_interface()}</Col>
741
</Row>
742
) : undefined}
743
</div>
744
);
745
}
746
747
function render_edit_student_interface() {
748
return (
749
<Card style={{ marginTop: "10px" }}>
750
<Row>
751
<Col md={12} style={{ paddingRight: "15px" }}>
752
First Name
753
<Input
754
autoFocus
755
value={edited_first_name}
756
onClick={(e) => {
757
e.stopPropagation();
758
e.preventDefault();
759
}}
760
onChange={(e) => set_edited_first_name(e.target.value)}
761
onKeyDown={on_key_down}
762
/>
763
</Col>
764
<Col md={12}>
765
Last Name
766
<Input
767
value={edited_last_name}
768
onClick={(e) => {
769
e.stopPropagation();
770
e.preventDefault();
771
}}
772
onChange={(e) => set_edited_last_name((e.target as any).value)}
773
onKeyDown={on_key_down}
774
/>
775
</Col>
776
</Row>
777
<Row>
778
<Col md={24}>
779
Email Address
780
<Input
781
type="text"
782
value={edited_email_address}
783
onClick={(e) => {
784
e.stopPropagation();
785
e.preventDefault();
786
}}
787
onChange={(e) =>
788
set_edited_email_address((e.target as any).value)
789
}
790
onKeyDown={on_key_down}
791
/>
792
</Col>
793
</Row>
794
</Card>
795
);
796
}
797
798
function render_more_panel() {
799
return (
800
<Row>
801
<Col xs={24}>
802
<Card title={render_panel_header()}>{render_more_info()}</Card>
803
</Col>
804
</Row>
805
);
806
}
807
808
return (
809
<div>
810
<Row style={more ? styles.selected_entry : undefined}>
811
<Col xs={24}>
812
{render_basic_info()}
813
{is_expanded ? render_more_panel() : undefined}
814
</Col>
815
</Row>
816
</div>
817
);
818
}
819
820