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/add-students.tsx
Views: 687
1
/*
2
Component for adding one or more students to the course.
3
*/
4
5
import { Button, Checkbox, Col, Flex, Form, Input, Row, Space } from "antd";
6
import { concat, sortBy } from "lodash";
7
import { useEffect, useRef, useState } from "react";
8
import { FormattedMessage, useIntl } from "react-intl";
9
10
import {
11
redux,
12
useActions,
13
useIsMountedRef,
14
} from "@cocalc/frontend/app-framework";
15
import { Icon } from "@cocalc/frontend/components";
16
import ShowError from "@cocalc/frontend/components/error";
17
import { labels } from "@cocalc/frontend/i18n";
18
import type { UserMap } from "@cocalc/frontend/todo-types";
19
import { webapp_client } from "@cocalc/frontend/webapp-client";
20
import {
21
dict,
22
is_valid_uuid_string,
23
keys,
24
parse_user_search,
25
trunc,
26
} from "@cocalc/util/misc";
27
import type { CourseActions } from "../actions";
28
import type { StudentsMap } from "../store";
29
30
interface Props {
31
name: string;
32
students: StudentsMap;
33
user_map: UserMap;
34
project_id;
35
close?: Function;
36
}
37
38
export default function AddStudents({
39
name,
40
students,
41
user_map,
42
project_id,
43
close,
44
}: Props) {
45
const intl = useIntl();
46
const addSelectRef = useRef<HTMLSelectElement>(null);
47
const studentAddInputRef = useRef(null);
48
const actions = useActions<CourseActions>({ name });
49
const [studentInputFocused, setStudentInputFocused] =
50
useState<boolean>(false);
51
const [err, set_err] = useState<string | undefined>(undefined);
52
const [add_search, set_add_search] = useState<string>("");
53
const [add_searching, set_add_searching] = useState<boolean>(false);
54
const [include_name_search, set_include_name_search] =
55
useState<boolean>(false);
56
const [add_select, set_add_select] = useState<any>(undefined);
57
const [existing_students, set_existing_students] = useState<any | undefined>(
58
undefined,
59
);
60
const [selected_option_nodes, set_selected_option_nodes] = useState<
61
any | undefined
62
>(undefined);
63
const [selected_option_num, set_selected_option_num] = useState<number>(0);
64
const isMounted = useIsMountedRef();
65
66
useEffect(() => {
67
set_selected_option_num(selected_option_nodes?.length ?? 0);
68
}, [selected_option_nodes]);
69
70
async function do_add_search(e): Promise<void> {
71
// Search for people to add to the course
72
if (e != null) {
73
e.preventDefault();
74
}
75
if (students == null) return;
76
// already searching
77
if (add_searching) return;
78
const search = add_search.trim();
79
if (search.length === 0) {
80
set_err(undefined);
81
set_add_select(undefined);
82
set_existing_students(undefined);
83
set_selected_option_nodes(undefined);
84
return;
85
}
86
set_add_searching(true);
87
set_add_select(undefined);
88
set_existing_students(undefined);
89
set_selected_option_nodes(undefined);
90
let select;
91
try {
92
select = await webapp_client.users_client.user_search({
93
query: add_search,
94
limit: 150,
95
only_email: !include_name_search,
96
});
97
} catch (err) {
98
if (!isMounted) return;
99
set_add_searching(false);
100
set_err(err);
101
set_add_select(undefined);
102
set_existing_students(undefined);
103
return;
104
}
105
if (!isMounted) return;
106
107
// Get the current collaborators/owners of the project that
108
// contains the course.
109
const users = redux.getStore("projects").get_users(project_id);
110
// Make a map with keys the email or account_id is already part of the course.
111
const already_added: { [key: string]: boolean } = (users?.toJS() ??
112
{}) as any; // start with collabs on project
113
// also track **which** students are already part of the course
114
const existing_students: any = {};
115
existing_students.account = {};
116
existing_students.email = {};
117
// For each student in course add account_id and/or email_address:
118
students.map((val) => {
119
for (const n of ["account_id", "email_address"] as const) {
120
const k = val.get(n);
121
if (k != null) {
122
already_added[k] = true;
123
}
124
}
125
});
126
// This function returns true if we shouldn't list the given account_id or email_address
127
// in the search selector for adding to the class.
128
const exclude_add = (account_id, email_address): boolean => {
129
const aa = already_added[account_id] || already_added[email_address];
130
if (aa) {
131
if (account_id != null) {
132
existing_students.account[account_id] = true;
133
}
134
if (email_address != null) {
135
existing_students.email[email_address] = true;
136
}
137
}
138
return aa;
139
};
140
const select2 = select.filter(
141
(x) => !exclude_add(x.account_id, x.email_address),
142
);
143
// Put at the front of the list any email addresses not known to CoCalc (sorted in order) and also not invited to course.
144
// NOTE (see comment on https://github.com/sagemathinc/cocalc/issues/677): it is very important to pass in
145
// the original select list to nonclude_emails below, **NOT** select2 above. Otherwise, we end up
146
// bringing back everything in the search, which is a bug.
147
const unknown = noncloud_emails(select, add_search).filter(
148
(x) => !exclude_add(null, x.email_address),
149
);
150
const select3 = concat(unknown, select2);
151
// We are no longer searching, but now show an options selector.
152
set_add_searching(false);
153
set_add_select(select3);
154
set_existing_students(existing_students);
155
}
156
157
function student_add_button() {
158
const disabled = add_search?.trim().length === 0;
159
const icon = add_searching ? (
160
<Icon name="cocalc-ring" spin />
161
) : (
162
<Icon name="search" />
163
);
164
165
return (
166
<Flex vertical={true} align="start" gap={5}>
167
<Button onClick={do_add_search} icon={icon} disabled={disabled}>
168
<FormattedMessage
169
id="course.add-students.search-button"
170
defaultMessage="Search (shift+enter)"
171
/>
172
</Button>
173
{!disabled && (
174
<Checkbox
175
checked={include_name_search}
176
onChange={() => {
177
set_include_name_search(!include_name_search);
178
}}
179
>
180
<FormattedMessage
181
id="course.add-students.search-students-by-name"
182
defaultMessage="Search by name too"
183
/>
184
</Checkbox>
185
)}
186
</Flex>
187
);
188
}
189
190
function add_selector_changed(e): void {
191
const opts = e.target.selectedOptions;
192
// It's important to make a shallow copy, because somehow this array is modified in-place
193
// and hence this call to set the array doesn't register a change (e.g. selected_option_num stays in sync)
194
set_selected_option_nodes([...opts]);
195
}
196
197
function add_selected_students(options) {
198
const emails = {};
199
for (const x of add_select) {
200
if (x.account_id != null) {
201
emails[x.account_id] = x.email_address;
202
}
203
}
204
const students: any[] = [];
205
const selections: any[] = [];
206
207
// first check, if no student is selected and there is just one in the list
208
if (
209
(selected_option_nodes == null || selected_option_nodes?.length === 0) &&
210
options?.length === 1
211
) {
212
selections.push(options[0].key);
213
} else {
214
for (const option of selected_option_nodes) {
215
selections.push(option.getAttribute("value"));
216
}
217
}
218
219
for (const y of selections) {
220
if (is_valid_uuid_string(y)) {
221
students.push({
222
account_id: y,
223
email_address: emails[y],
224
});
225
} else {
226
students.push({ email_address: y });
227
}
228
}
229
actions.students.add_students(students);
230
clear();
231
close?.();
232
}
233
234
function add_all_students() {
235
const students: any[] = [];
236
for (const entry of add_select) {
237
const { account_id } = entry;
238
if (is_valid_uuid_string(account_id)) {
239
students.push({
240
account_id,
241
email_address: entry.email_address,
242
});
243
} else {
244
students.push({ email_address: entry.email_address });
245
}
246
}
247
actions.students.add_students(students);
248
clear();
249
close?.();
250
}
251
252
function clear(): void {
253
set_err(undefined);
254
set_add_select(undefined);
255
set_selected_option_nodes(undefined);
256
set_add_search("");
257
set_existing_students(undefined);
258
}
259
260
function get_add_selector_options() {
261
const v: any[] = [];
262
const seen = {};
263
for (const x of add_select) {
264
const key = x.account_id != null ? x.account_id : x.email_address;
265
if (seen[key]) continue;
266
seen[key] = true;
267
const student_name =
268
x.account_id != null
269
? x.first_name + " " + x.last_name
270
: x.email_address;
271
const email =
272
!include_name_search && x.account_id != null && x.email_address
273
? " (" + x.email_address + ")"
274
: "";
275
v.push(
276
<option key={key} value={key} label={student_name + email}>
277
{student_name + email}
278
</option>,
279
);
280
}
281
return v;
282
}
283
284
function render_add_selector() {
285
if (add_select == null) return;
286
const options = get_add_selector_options();
287
return (
288
<>
289
<Form.Item style={{ margin: "5px 0 15px 0" }}>
290
<select
291
style={{
292
width: "100%",
293
border: "1px solid lightgray",
294
padding: "4px 11px",
295
}}
296
multiple
297
ref={addSelectRef}
298
size={8}
299
onChange={add_selector_changed}
300
>
301
{options}
302
</select>
303
</Form.Item>
304
<Space>
305
{render_cancel()}
306
{render_add_selector_button(options)}
307
{render_add_all_students_button(options)}
308
</Space>
309
</>
310
);
311
}
312
313
function get_add_selector_button_text(existing) {
314
switch (selected_option_num) {
315
case 0:
316
return intl.formatMessage(
317
{
318
id: "course.add-students.add-selector-button.case0",
319
defaultMessage: `{existing, select,
320
true {Student already added}
321
other {Select student(s)}}`,
322
},
323
{ existing },
324
);
325
326
case 1:
327
return intl.formatMessage({
328
id: "course.add-students.add-selector-button.case1",
329
defaultMessage: "Add student",
330
});
331
default:
332
return intl.formatMessage(
333
{
334
id: "course.add-students.add-selector-button.caseDefault",
335
defaultMessage: `{num, select,
336
0 {Select student above}
337
1 {Add selected student}
338
other {Add {num} students}}`,
339
},
340
{ num: selected_option_num },
341
);
342
}
343
}
344
345
function render_add_selector_button(options) {
346
let existing;
347
const es = existing_students;
348
if (es != null) {
349
existing = keys(es.email).length + keys(es.account).length > 0;
350
} else {
351
// es not defined when user clicks the close button on the warning.
352
existing = 0;
353
}
354
const btn_text = get_add_selector_button_text(existing);
355
const disabled =
356
options.length === 0 ||
357
(options.length >= 1 && selected_option_num === 0);
358
return (
359
<Button
360
onClick={() => add_selected_students(options)}
361
disabled={disabled}
362
>
363
<Icon name="user-plus" /> {btn_text}
364
</Button>
365
);
366
}
367
368
function render_add_all_students_button(options) {
369
return (
370
<Button
371
onClick={() => add_all_students()}
372
disabled={options.length === 0}
373
>
374
<Icon name={"user-plus"} />{" "}
375
<FormattedMessage
376
id="course.add-students.add-all-students.button"
377
defaultMessage={"Add all students"}
378
description={"Students in an online course"}
379
/>
380
</Button>
381
);
382
}
383
384
function render_cancel() {
385
return (
386
<Button onClick={() => clear()}>
387
{intl.formatMessage(labels.cancel)}
388
</Button>
389
);
390
}
391
392
function render_error_display() {
393
if (err) {
394
return <ShowError error={trunc(err, 1024)} setError={set_err} />;
395
} else if (existing_students != null) {
396
const existing: any[] = [];
397
for (const email in existing_students.email) {
398
existing.push(email);
399
}
400
for (const account_id in existing_students.account) {
401
const user = user_map.get(account_id);
402
// user could be null, since there is no guaranteee about what is in user_map.
403
if (user != null) {
404
existing.push(`${user.get("first_name")} ${user.get("last_name")}`);
405
} else {
406
existing.push(`Student with account ${account_id}`);
407
}
408
}
409
if (existing.length > 0) {
410
const existingStr = existing.join(", ");
411
const msg = `Already added (or deleted) students or project collaborators: ${existingStr}`;
412
return (
413
<ShowError
414
style={{ margin: "15px 0" }}
415
error={msg}
416
setError={() => set_existing_students(undefined)}
417
/>
418
);
419
}
420
}
421
}
422
423
function render_error() {
424
const ed = render_error_display();
425
if (ed != null) {
426
return (
427
<Col md={24} style={{ marginBottom: "20px" }}>
428
{ed}
429
</Col>
430
);
431
}
432
}
433
434
function student_add_input_onChange() {
435
const value =
436
(studentAddInputRef?.current as any).resizableTextArea?.textArea.value ??
437
"";
438
set_add_select(undefined);
439
set_add_search(value);
440
}
441
442
function student_add_input_onKeyDown(e) {
443
// ESC key
444
if (e.keyCode === 27) {
445
set_add_search("");
446
set_add_select(undefined);
447
448
// Shift+Return
449
} else if (e.keyCode === 13 && e.shiftKey) {
450
e.preventDefault();
451
student_add_input_onChange();
452
do_add_search(e);
453
}
454
}
455
456
const rows = add_search.trim().length == 0 && !studentInputFocused ? 1 : 4;
457
458
const placeholder = intl.formatMessage(
459
{
460
id: "course.add-students.textarea.placeholder",
461
defaultMessage: `Add students by {include_name_search, select, true {names or} other {}} email addresses...`,
462
},
463
{ include_name_search },
464
);
465
466
return (
467
<Form onFinish={do_add_search} style={{ marginLeft: "15px" }}>
468
<Row>
469
<Col md={18}>
470
<Form.Item style={{ margin: "0 0 5px 0" }}>
471
<Input.TextArea
472
ref={studentAddInputRef}
473
placeholder={placeholder}
474
value={add_search}
475
rows={rows}
476
onChange={() => student_add_input_onChange()}
477
onKeyDown={(e) => student_add_input_onKeyDown(e)}
478
onFocus={() => setStudentInputFocused(true)}
479
onBlur={() => setStudentInputFocused(false)}
480
/>
481
</Form.Item>
482
</Col>
483
<Col md={6}>
484
<div style={{ marginLeft: "15px", width: "100%" }}>
485
{student_add_button()}
486
</div>
487
</Col>
488
<Col md={24}>{render_add_selector()}</Col>
489
{render_error()}
490
</Row>
491
</Form>
492
);
493
}
494
495
// Given a list v of user_search results, and a search string s,
496
// return entries for each email address not in v, in order.
497
function noncloud_emails(v, s) {
498
const { email_queries } = parse_user_search(s);
499
500
const result_emails = dict(
501
v
502
.filter((r) => r.email_address != null)
503
.map((r) => [r.email_address, true]),
504
);
505
506
return sortBy(
507
email_queries
508
.filter((r) => !result_emails[r])
509
.map((r) => {
510
return { email_address: r };
511
}),
512
"email_address",
513
);
514
}
515
516