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