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.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
6
import { Alert, Col, Input, Row } from "antd";
7
import { Set } from "immutable";
8
import { isEqual } from "lodash";
9
import { useEffect, useMemo, useState } from "react";
10
import { FormattedMessage, useIntl } from "react-intl";
11
12
import { AppRedux, useRedux } from "@cocalc/frontend/app-framework";
13
import { Gap, Icon, Tip } from "@cocalc/frontend/components";
14
import ScrollableList from "@cocalc/frontend/components/scrollable-list";
15
import { course, labels } from "@cocalc/frontend/i18n";
16
import { ProjectMap, UserMap } from "@cocalc/frontend/todo-types";
17
import { search_match, search_split } from "@cocalc/util/misc";
18
import type { CourseActions } from "../actions";
19
import {
20
AssignmentsMap,
21
IsGradingMap,
22
NBgraderRunInfo,
23
SortDescription,
24
StudentRecord,
25
StudentsMap,
26
} from "../store";
27
import * as util from "../util";
28
import AddStudents from "./add-students";
29
import { Student, StudentNameDescription } from "./students-panel-student";
30
31
interface StudentsPanelReactProps {
32
frame_id?: string; // used for state caching
33
actions: CourseActions;
34
name: string;
35
redux: AppRedux;
36
project_id: string;
37
students: StudentsMap;
38
user_map: UserMap;
39
project_map: ProjectMap;
40
assignments: AssignmentsMap;
41
frameActions;
42
}
43
44
interface StudentList {
45
students: any[];
46
num_omitted: number;
47
num_deleted: number;
48
}
49
50
export function StudentsPanel({
51
actions,
52
frame_id,
53
name,
54
redux,
55
project_id,
56
students,
57
user_map,
58
project_map,
59
assignments,
60
frameActions,
61
}: StudentsPanelReactProps) {
62
const intl = useIntl();
63
64
const expanded_students: Set<string> | undefined = useRedux(
65
name,
66
"expanded_students",
67
);
68
const active_student_sort: SortDescription | undefined = useRedux(
69
name,
70
"active_student_sort",
71
);
72
const active_feedback_edits: IsGradingMap = useRedux(
73
name,
74
"active_feedback_edits",
75
);
76
const nbgrader_run_info: NBgraderRunInfo | undefined = useRedux(
77
name,
78
"nbgrader_run_info",
79
);
80
const assignmentFilter = useRedux(name, "assignmentFilter");
81
const pageFilter = useRedux(name, "pageFilter");
82
const filter = pageFilter?.get("students") ?? "";
83
const setFilter = (filter: string) => {
84
actions.setPageFilter("students", filter);
85
};
86
87
// the type is copy/paste from what TS infers in the util.parse_students function
88
const [students_unordered, set_students_unordered] = useState<
89
{
90
create_project?: number;
91
account_id?: string;
92
student_id: string;
93
first_name?: string;
94
last_name?: string;
95
last_active?: number;
96
hosting?: string;
97
email_address?: string;
98
project_id?: string;
99
deleted?: boolean;
100
deleted_account?: boolean;
101
note?: string;
102
last_email_invite?: number;
103
}[]
104
>([]);
105
const [show_deleted, set_show_deleted] = useState<boolean>(false);
106
107
// this updates a JS list from the ever changing user_map immutableMap
108
useEffect(() => {
109
const v = util.parse_students(students, user_map, redux, intl);
110
if (!isEqual(v, students_unordered)) {
111
set_students_unordered(v);
112
}
113
}, [students, user_map]);
114
115
// student_list not a list, but has one, plus some extra info.
116
const student_list: StudentList = useMemo(() => {
117
// turn map of students into a list
118
// account_id : "bed84c9e-98e0-494f-99a1-ad9203f752cb" # Student's CoCalc account ID
119
// email_address : "[email protected]" # Email the instructor signed the student up with.
120
// first_name : "Rachel" # Student's first name they use for CoCalc
121
// last_name : "Florence" # Student's last name they use for CoCalc
122
// project_id : "6bea25c7-da96-4e92-aa50-46ebee1994ca" # Student's project ID for this course
123
// student_id : "920bdad2-9c3a-40ab-b5c0-eb0b3979e212" # Student's id for this course
124
// last_active : 2357025
125
// create_project : number -- server timestamp of when create started
126
// deleted : False
127
// note : "Is younger sister of Abby Florence (TA)"
128
129
const students_ordered = [...students_unordered];
130
131
if (active_student_sort != null) {
132
students_ordered.sort(
133
util.pick_student_sorter(active_student_sort.toJS()),
134
);
135
if (active_student_sort.get("is_descending")) {
136
students_ordered.reverse();
137
}
138
}
139
140
// Deleted and non-deleted students
141
const deleted: any[] = [];
142
const non_deleted: any[] = [];
143
for (const x of students_ordered) {
144
if (x.deleted) {
145
deleted.push(x);
146
} else {
147
non_deleted.push(x);
148
}
149
}
150
const num_deleted = deleted.length;
151
152
const students_shown = show_deleted
153
? non_deleted.concat(deleted) // show deleted ones at the end...
154
: non_deleted;
155
156
let num_omitted = 0;
157
const students_next = (function () {
158
if (filter) {
159
const words = search_split(filter.toLowerCase());
160
const students_filtered: any[] = [];
161
for (const x of students_shown) {
162
const target = [
163
x.first_name ?? "",
164
x.last_name ?? "",
165
x.email_address ?? "",
166
]
167
.join(" ")
168
.toLowerCase();
169
if (search_match(target, words)) {
170
students_filtered.push(x);
171
} else {
172
num_omitted += 1;
173
}
174
}
175
return students_filtered;
176
} else {
177
return students_shown;
178
}
179
})();
180
181
return { students: students_next, num_omitted, num_deleted };
182
}, [students, students_unordered, show_deleted, filter, active_student_sort]);
183
184
function render_header(num_omitted) {
185
// TODO: get rid of all of the bootstrap form crap below. I'm basically
186
// using inline styles to undo the spacing screwups they cause, so it doesn't
187
// look like total crap.
188
189
return (
190
<div>
191
<Row>
192
<Col md={6}>
193
<Input.Search
194
allowClear
195
placeholder={intl.formatMessage({
196
id: "course.students-panel.filter_students.placeholder",
197
defaultMessage: "Filter existing students...",
198
})}
199
value={filter}
200
onChange={(e) => setFilter(e.target.value)}
201
/>
202
</Col>
203
<Col md={6}>
204
{num_omitted ? (
205
<h5 style={{ marginLeft: "15px" }}>
206
{intl.formatMessage(
207
{
208
id: "course.students-panel.filter_students.info",
209
defaultMessage: "(Omitting {num_omitted} students)",
210
},
211
{ num_omitted },
212
)}
213
</h5>
214
) : undefined}
215
</Col>
216
<Col md={11}>
217
<AddStudents
218
name={name}
219
students={students}
220
user_map={user_map}
221
project_id={project_id}
222
/>
223
</Col>
224
</Row>
225
</div>
226
);
227
}
228
229
function render_sort_icon(column_name: string) {
230
if (
231
active_student_sort == null ||
232
active_student_sort.get("column_name") != column_name
233
)
234
return;
235
return (
236
<Icon
237
style={{ marginRight: "10px" }}
238
name={
239
active_student_sort.get("is_descending") ? "caret-up" : "caret-down"
240
}
241
/>
242
);
243
}
244
245
function render_sort_link(column_name: string, display_name: string) {
246
return (
247
<a
248
href=""
249
onClick={(e) => {
250
e.preventDefault();
251
actions.students.set_active_student_sort(column_name);
252
}}
253
>
254
{display_name}
255
<Gap />
256
{render_sort_icon(column_name)}
257
</a>
258
);
259
}
260
261
function render_student_table_header(num_deleted: number) {
262
// HACK: that marginRight is to get things to line up with students.
263
const firstName = intl.formatMessage(labels.account_first_name);
264
const lastName = intl.formatMessage(labels.account_last_name);
265
const lastActive = intl.formatMessage(labels.last_active);
266
const projectStatus = intl.formatMessage(labels.project_status);
267
const emailAddress = intl.formatMessage(labels.email_address);
268
269
return (
270
<div>
271
<Row style={{ marginRight: 0 }}>
272
<Col md={6}>
273
<div style={{ display: "inline-block", width: "50%" }}>
274
{render_sort_link("first_name", firstName)}
275
</div>
276
<div style={{ display: "inline-block" }}>
277
{render_sort_link("last_name", lastName)}
278
</div>
279
</Col>
280
<Col md={4}>{render_sort_link("email", emailAddress)}</Col>
281
<Col md={8}>{render_sort_link("last_active", lastActive)}</Col>
282
<Col md={3}>{render_sort_link("hosting", projectStatus)}</Col>
283
<Col md={3}>
284
{num_deleted ? render_show_deleted(num_deleted) : undefined}
285
</Col>
286
</Row>
287
</div>
288
);
289
}
290
291
function get_student(id: string): StudentRecord {
292
const student = students.get(id);
293
if (student == null) {
294
console.warn(`Tried to access undefined student ${id}`);
295
}
296
return student as StudentRecord;
297
}
298
299
function render_student(student_id: string, index: number) {
300
const x = student_list.students[index];
301
if (x == null) return null;
302
const store = actions.get_store();
303
if (store == null) return null;
304
const studentName: StudentNameDescription = {
305
full: store.get_student_name(x.student_id),
306
first: x.first_name,
307
last: x.last_name,
308
};
309
const student = get_student(student_id);
310
if (student == null) {
311
// temporary and better than crashing
312
return null;
313
}
314
return (
315
<Student
316
background={index % 2 === 0 ? "#eee" : undefined}
317
key={student_id}
318
student_id={student_id}
319
student={student}
320
user_map={user_map}
321
redux={redux}
322
name={name}
323
project_map={project_map}
324
assignments={assignments}
325
is_expanded={expanded_students?.has(student_id) ?? false}
326
student_name={studentName}
327
display_account_name={true}
328
active_feedback_edits={active_feedback_edits}
329
nbgrader_run_info={nbgrader_run_info}
330
assignmentFilter={assignmentFilter?.get(student_id)}
331
/>
332
);
333
}
334
335
function render_students(students) {
336
if (students.length == 0) {
337
return render_no_students();
338
}
339
return (
340
<ScrollableList
341
virtualize
342
rowCount={students.length}
343
rowRenderer={({ key, index }) => render_student(key, index)}
344
rowKey={(index) =>
345
students[index] != null ? students[index].student_id : undefined
346
}
347
cacheId={`course-student-${name}-${frame_id}`}
348
/>
349
);
350
}
351
352
function render_no_students() {
353
return (
354
<div>
355
<Alert
356
type="info"
357
style={{
358
margin: "15px auto",
359
fontSize: "12pt",
360
maxWidth: "800px",
361
}}
362
message={
363
<b>
364
<a onClick={() => frameActions.setModal("add-students")}>
365
<FormattedMessage
366
id="course.students-panel.no_students.title"
367
defaultMessage="Add Students to your Course"
368
/>
369
</a>
370
</b>
371
}
372
description={
373
<div>
374
<FormattedMessage
375
id="course.students-panel.no_students.descr"
376
defaultMessage={`<A>Add some students</A> to your course
377
by entering their email addresses in the box in the upper right,
378
then click on Search.`}
379
values={{
380
A: (c) => (
381
<a onClick={() => frameActions.setModal("add-students")}>
382
{c}
383
</a>
384
),
385
}}
386
/>
387
</div>
388
}
389
/>
390
</div>
391
);
392
}
393
394
function render_show_deleted(num_deleted: number) {
395
if (show_deleted) {
396
return (
397
<a onClick={() => set_show_deleted(false)}>
398
<Tip
399
placement="left"
400
title="Hide deleted"
401
tip={intl.formatMessage(course.show_deleted_students_tooltip, {
402
show: false,
403
})}
404
>
405
{intl.formatMessage(course.show_deleted_students_msg, {
406
num_deleted,
407
show: false,
408
})}
409
</Tip>
410
</a>
411
);
412
} else {
413
return (
414
<a
415
onClick={() => {
416
set_show_deleted(true);
417
setFilter("");
418
}}
419
>
420
<Tip
421
placement="left"
422
title="Show deleted"
423
tip={intl.formatMessage(course.show_deleted_students_tooltip, {
424
show: true,
425
})}
426
>
427
{intl.formatMessage(course.show_deleted_students_msg, {
428
num_deleted,
429
show: true,
430
})}
431
</Tip>
432
</a>
433
);
434
}
435
}
436
437
function render_student_info(students, num_deleted) {
438
/* The "|| num_deleted > 0" below is because we show
439
header even if no non-deleted students if there are deleted
440
students, since it's important to show the link to show
441
deleted students if there are any. */
442
return (
443
<div className="smc-vfill">
444
{students.length > 0 || num_deleted > 0
445
? render_student_table_header(num_deleted)
446
: undefined}
447
{render_students(students)}
448
</div>
449
);
450
}
451
452
{
453
const { students, num_omitted, num_deleted } = student_list;
454
return (
455
<div className="smc-vfill" style={{ margin: "0" }}>
456
{render_header(num_omitted)}
457
{render_student_info(students, num_deleted)}
458
</div>
459
);
460
}
461
}
462
463