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