Path: blob/master/src/packages/frontend/course/students/add-students.tsx
1503 views
/*1Component for adding one or more students to the course.2*/34import { Alert, Button, Col, Flex, Form, Input, Row, Space } from "antd";5import { concat, sortBy } from "lodash";6import { useEffect, useRef, useState } from "react";7import { FormattedMessage, useIntl } from "react-intl";89import {10redux,11useActions,12useIsMountedRef,13} from "@cocalc/frontend/app-framework";14import { Icon } from "@cocalc/frontend/components";15import ShowError from "@cocalc/frontend/components/error";16import { labels } from "@cocalc/frontend/i18n";17import type { UserMap } from "@cocalc/frontend/todo-types";18import { webapp_client } from "@cocalc/frontend/webapp-client";19import {20dict,21is_valid_uuid_string,22keys,23parse_user_search,24trunc,25} from "@cocalc/util/misc";26import type { CourseActions } from "../actions";27import type { StudentsMap } from "../store";2829interface Props {30name: string;31students: StudentsMap;32user_map: UserMap;33project_id;34close?: Function;35}3637export default function AddStudents({38name,39students,40user_map,41project_id,42close,43}: Props) {44const intl = useIntl();45const addSelectRef = useRef<HTMLSelectElement>(null);46const studentAddInputRef = useRef<any>(null);47const actions = useActions<CourseActions>({ name });48const [studentInputFocused, setStudentInputFocused] =49useState<boolean>(false);50const [err, set_err] = useState<string | undefined>(undefined);51const [add_search, set_add_search] = useState<string>("");52const [add_searching, set_add_searching] = useState<boolean>(false);53const [add_select, set_add_select] = useState<any>(undefined);54const [existing_students, set_existing_students] = useState<any | undefined>(55undefined,56);57const [selected_option_nodes, set_selected_option_nodes] = useState<58any | undefined59>(undefined);60const [selected_option_num, set_selected_option_num] = useState<number>(0);61const isMounted = useIsMountedRef();6263useEffect(() => {64set_selected_option_num(selected_option_nodes?.length ?? 0);65}, [selected_option_nodes]);6667async function do_add_search(e, only_email = true): Promise<void> {68// Search for people to add to the course69if (e != null) {70e.preventDefault();71}72if (students == null) return;73// already searching74if (add_searching) return;75const search = add_search.trim();76if (search.length === 0) {77set_err(undefined);78set_add_select(undefined);79set_existing_students(undefined);80set_selected_option_nodes(undefined);81return;82}83set_add_searching(true);84set_add_select(undefined);85set_existing_students(undefined);86set_selected_option_nodes(undefined);87let select;88try {89select = await webapp_client.users_client.user_search({90query: add_search,91limit: 150,92only_email,93});94} catch (err) {95if (!isMounted) return;96set_add_searching(false);97set_err(err);98set_add_select(undefined);99set_existing_students(undefined);100return;101}102if (!isMounted) return;103104// Get the current collaborators/owners of the project that105// contains the course.106const users = redux.getStore("projects").get_users(project_id);107// Make a map with keys the email or account_id is already part of the course.108const already_added: { [key: string]: boolean } = (users?.toJS() ??109{}) as any; // start with collabs on project110// also track **which** students are already part of the course111const existing_students: any = {};112existing_students.account = {};113existing_students.email = {};114// For each student in course add account_id and/or email_address:115students.map((val) => {116for (const n of ["account_id", "email_address"] as const) {117const k = val.get(n);118if (k != null) {119already_added[k] = true;120}121}122});123// This function returns true if we shouldn't list the given account_id or email_address124// in the search selector for adding to the class.125const exclude_add = (account_id, email_address): boolean => {126const aa = already_added[account_id] || already_added[email_address];127if (aa) {128if (account_id != null) {129existing_students.account[account_id] = true;130}131if (email_address != null) {132existing_students.email[email_address] = true;133}134}135return aa;136};137const select2 = select.filter(138(x) => !exclude_add(x.account_id, x.email_address),139);140// Put at the front of the list any email addresses not known to CoCalc (sorted in order) and also not invited to course.141// NOTE (see comment on https://github.com/sagemathinc/cocalc/issues/677): it is very important to pass in142// the original select list to nonclude_emails below, **NOT** select2 above. Otherwise, we end up143// bringing back everything in the search, which is a bug.144const unknown = noncloud_emails(select, add_search).filter(145(x) => !exclude_add(null, x.email_address),146);147const select3 = concat(unknown, select2);148// We are no longer searching, but now show an options selector.149set_add_searching(false);150set_add_select(select3);151set_existing_students(existing_students);152}153154function student_add_button() {155const disabled = add_search?.trim().length === 0;156const icon = add_searching ? (157<Icon name="cocalc-ring" spin />158) : (159<Icon name="search" />160);161162return (163<Flex vertical={true} align="start" gap={5}>164<Button165type="primary"166onClick={(e) => do_add_search(e, true)}167icon={icon}168disabled={disabled}169>170Search by Email Address (shift+enter)171</Button>172<Button173onClick={(e) => do_add_search(e, false)}174icon={icon}175disabled={disabled}176>177Search by Name178</Button>179</Flex>180);181}182183function add_selector_changed(e): void {184const opts = e.target.selectedOptions;185// It's important to make a shallow copy, because somehow this array is modified in-place186// and hence this call to set the array doesn't register a change (e.g. selected_option_num stays in sync)187set_selected_option_nodes([...opts]);188}189190function add_selected_students(options) {191const emails = {};192for (const x of add_select) {193if (x.account_id != null) {194emails[x.account_id] = x.email_address;195}196}197const students: any[] = [];198const selections: any[] = [];199200// first check, if no student is selected and there is just one in the list201if (202(selected_option_nodes == null || selected_option_nodes?.length === 0) &&203options?.length === 1204) {205selections.push(options[0].key);206} else {207for (const option of selected_option_nodes) {208selections.push(option.getAttribute("value"));209}210}211212for (const y of selections) {213if (is_valid_uuid_string(y)) {214students.push({215account_id: y,216email_address: emails[y],217});218} else {219students.push({ email_address: y });220}221}222actions.students.add_students(students);223clear();224close?.();225}226227function add_all_students() {228const students: any[] = [];229for (const entry of add_select) {230const { account_id } = entry;231if (is_valid_uuid_string(account_id)) {232students.push({233account_id,234email_address: entry.email_address,235});236} else {237students.push({ email_address: entry.email_address });238}239}240actions.students.add_students(students);241clear();242close?.();243}244245function clear(): void {246set_err(undefined);247set_add_select(undefined);248set_selected_option_nodes(undefined);249set_add_search("");250set_existing_students(undefined);251}252253function get_add_selector_options() {254const v: any[] = [];255const seen = {};256for (const x of add_select) {257const key = x.account_id != null ? x.account_id : x.email_address;258if (seen[key]) continue;259seen[key] = true;260const student_name =261x.account_id != null262? x.first_name + " " + x.last_name263: x.email_address;264const email =265x.account_id != null && x.email_address266? " (" + x.email_address + ")"267: "";268v.push(269<option key={key} value={key} label={student_name + email}>270{student_name + email}271</option>,272);273}274return v;275}276277function render_add_selector() {278if (add_select == null) return;279const options = get_add_selector_options();280return (281<>282<Form.Item style={{ margin: "5px 0 15px 0" }}>283<select284style={{285width: "100%",286border: "1px solid lightgray",287padding: "4px 11px",288}}289multiple290ref={addSelectRef}291size={8}292onChange={add_selector_changed}293>294{options}295</select>296</Form.Item>297<Space>298{render_cancel()}299{render_add_selector_button(options)}300{render_add_all_students_button(options)}301</Space>302</>303);304}305306function get_add_selector_button_text(existing) {307switch (selected_option_num) {308case 0:309return intl.formatMessage(310{311id: "course.add-students.add-selector-button.case0",312defaultMessage: `{existing, select,313true {Student already added}314other {Select student(s)}}`,315},316{ existing },317);318319case 1:320return intl.formatMessage({321id: "course.add-students.add-selector-button.case1",322defaultMessage: "Add student",323});324default:325return intl.formatMessage(326{327id: "course.add-students.add-selector-button.caseDefault",328defaultMessage: `{num, select,3290 {Select student above}3301 {Add selected student}331other {Add {num} students}}`,332},333{ num: selected_option_num },334);335}336}337338function render_add_selector_button(options) {339let existing;340const es = existing_students;341if (es != null) {342existing = keys(es.email).length + keys(es.account).length > 0;343} else {344// es not defined when user clicks the close button on the warning.345existing = 0;346}347const btn_text = get_add_selector_button_text(existing);348const disabled =349options.length === 0 ||350(options.length >= 1 && selected_option_num === 0);351return (352<Button353onClick={() => add_selected_students(options)}354disabled={disabled}355>356<Icon name="user-plus" /> {btn_text}357</Button>358);359}360361function render_add_all_students_button(options) {362return (363<Button364onClick={() => add_all_students()}365disabled={options.length === 0}366>367<Icon name={"user-plus"} />{" "}368<FormattedMessage369id="course.add-students.add-all-students.button"370defaultMessage={"Add all students"}371description={"Students in an online course"}372/>373</Button>374);375}376377function render_cancel() {378return (379<Button onClick={() => clear()}>380{intl.formatMessage(labels.cancel)}381</Button>382);383}384385function render_error_display() {386if (err) {387return <ShowError error={trunc(err, 1024)} setError={set_err} />;388} else if (existing_students != null) {389const existing: any[] = [];390for (const email in existing_students.email) {391existing.push(email);392}393for (const account_id in existing_students.account) {394const user = user_map.get(account_id);395// user could be null, since there is no guaranteee about what is in user_map.396if (user != null) {397existing.push(`${user.get("first_name")} ${user.get("last_name")}`);398} else {399existing.push(`Student with account ${account_id}`);400}401}402if (existing.length > 0) {403const existingStr = existing.join(", ");404const msg = `Already added (or deleted) students or project collaborators: ${existingStr}`;405return (406<Alert407type="info"408message={msg}409style={{ margin: "15px 0" }}410closable411onClose={() => set_existing_students(undefined)}412/>413);414}415}416}417418function render_error() {419const ed = render_error_display();420if (ed != null) {421return (422<Col md={24} style={{ marginBottom: "20px" }}>423{ed}424</Col>425);426}427}428429function student_add_input_onChange() {430const value =431(studentAddInputRef?.current as any).resizableTextArea?.textArea.value ??432"";433set_add_select(undefined);434set_add_search(value);435}436437function student_add_input_onKeyDown(e) {438// ESC key439if (e.keyCode === 27) {440set_add_search("");441set_add_select(undefined);442443// Shift+Return444} else if (e.keyCode === 13 && e.shiftKey) {445e.preventDefault();446student_add_input_onChange();447do_add_search(e);448}449}450451const rows = add_search.trim().length == 0 && !studentInputFocused ? 1 : 4;452453const placeholder = "Add students by email address or name...";454455return (456<Form onFinish={do_add_search} style={{ marginLeft: "15px" }}>457<Row>458<Col md={14}>459<Form.Item style={{ margin: "0 0 5px 0" }}>460<Input.TextArea461ref={studentAddInputRef}462placeholder={placeholder}463value={add_search}464rows={rows}465onChange={() => student_add_input_onChange()}466onKeyDown={(e) => student_add_input_onKeyDown(e)}467onFocus={() => setStudentInputFocused(true)}468onBlur={() => setStudentInputFocused(false)}469/>470</Form.Item>471</Col>472<Col md={10}>473<div style={{ marginLeft: "15px", width: "100%" }}>474{student_add_button()}475</div>476</Col>477<Col md={24}>{render_add_selector()}</Col>478{render_error()}479</Row>480</Form>481);482}483484// Given a list v of user_search results, and a search string s,485// return entries for each email address not in v, in order.486function noncloud_emails(v, s) {487const { email_queries } = parse_user_search(s);488489const result_emails = dict(490v491.filter((r) => r.email_address != null)492.map((r) => [r.email_address, true]),493);494495return sortBy(496email_queries497.filter((r) => !result_emails[r])498.map((r) => {499return { email_address: r };500}),501"email_address",502);503}504505506