Real-time collaboration for Jupyter Notebooks, Linux Terminals, LaTeX, VS Code, R IDE, and more,
all in one place.
Real-time collaboration for Jupyter Notebooks, Linux Terminals, LaTeX, VS Code, R IDE, and more,
all in one place.
Path: blob/master/src/packages/frontend/course/students/add-students.tsx
Views: 687
/*1Component for adding one or more students to the course.2*/34import { Button, Checkbox, 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(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 [include_name_search, set_include_name_search] =54useState<boolean>(false);55const [add_select, set_add_select] = useState<any>(undefined);56const [existing_students, set_existing_students] = useState<any | undefined>(57undefined,58);59const [selected_option_nodes, set_selected_option_nodes] = useState<60any | undefined61>(undefined);62const [selected_option_num, set_selected_option_num] = useState<number>(0);63const isMounted = useIsMountedRef();6465useEffect(() => {66set_selected_option_num(selected_option_nodes?.length ?? 0);67}, [selected_option_nodes]);6869async function do_add_search(e): Promise<void> {70// Search for people to add to the course71if (e != null) {72e.preventDefault();73}74if (students == null) return;75// already searching76if (add_searching) return;77const search = add_search.trim();78if (search.length === 0) {79set_err(undefined);80set_add_select(undefined);81set_existing_students(undefined);82set_selected_option_nodes(undefined);83return;84}85set_add_searching(true);86set_add_select(undefined);87set_existing_students(undefined);88set_selected_option_nodes(undefined);89let select;90try {91select = await webapp_client.users_client.user_search({92query: add_search,93limit: 150,94only_email: !include_name_search,95});96} catch (err) {97if (!isMounted) return;98set_add_searching(false);99set_err(err);100set_add_select(undefined);101set_existing_students(undefined);102return;103}104if (!isMounted) return;105106// Get the current collaborators/owners of the project that107// contains the course.108const users = redux.getStore("projects").get_users(project_id);109// Make a map with keys the email or account_id is already part of the course.110const already_added: { [key: string]: boolean } = (users?.toJS() ??111{}) as any; // start with collabs on project112// also track **which** students are already part of the course113const existing_students: any = {};114existing_students.account = {};115existing_students.email = {};116// For each student in course add account_id and/or email_address:117students.map((val) => {118for (const n of ["account_id", "email_address"] as const) {119const k = val.get(n);120if (k != null) {121already_added[k] = true;122}123}124});125// This function returns true if we shouldn't list the given account_id or email_address126// in the search selector for adding to the class.127const exclude_add = (account_id, email_address): boolean => {128const aa = already_added[account_id] || already_added[email_address];129if (aa) {130if (account_id != null) {131existing_students.account[account_id] = true;132}133if (email_address != null) {134existing_students.email[email_address] = true;135}136}137return aa;138};139const select2 = select.filter(140(x) => !exclude_add(x.account_id, x.email_address),141);142// Put at the front of the list any email addresses not known to CoCalc (sorted in order) and also not invited to course.143// NOTE (see comment on https://github.com/sagemathinc/cocalc/issues/677): it is very important to pass in144// the original select list to nonclude_emails below, **NOT** select2 above. Otherwise, we end up145// bringing back everything in the search, which is a bug.146const unknown = noncloud_emails(select, add_search).filter(147(x) => !exclude_add(null, x.email_address),148);149const select3 = concat(unknown, select2);150// We are no longer searching, but now show an options selector.151set_add_searching(false);152set_add_select(select3);153set_existing_students(existing_students);154}155156function student_add_button() {157const disabled = add_search?.trim().length === 0;158const icon = add_searching ? (159<Icon name="cocalc-ring" spin />160) : (161<Icon name="search" />162);163164return (165<Flex vertical={true} align="start" gap={5}>166<Button onClick={do_add_search} icon={icon} disabled={disabled}>167<FormattedMessage168id="course.add-students.search-button"169defaultMessage="Search (shift+enter)"170/>171</Button>172{!disabled && (173<Checkbox174checked={include_name_search}175onChange={() => {176set_include_name_search(!include_name_search);177}}178>179<FormattedMessage180id="course.add-students.search-students-by-name"181defaultMessage="Search by name too"182/>183</Checkbox>184)}185</Flex>186);187}188189function add_selector_changed(e): void {190const opts = e.target.selectedOptions;191// It's important to make a shallow copy, because somehow this array is modified in-place192// and hence this call to set the array doesn't register a change (e.g. selected_option_num stays in sync)193set_selected_option_nodes([...opts]);194}195196function add_selected_students(options) {197const emails = {};198for (const x of add_select) {199if (x.account_id != null) {200emails[x.account_id] = x.email_address;201}202}203const students: any[] = [];204const selections: any[] = [];205206// first check, if no student is selected and there is just one in the list207if (208(selected_option_nodes == null || selected_option_nodes?.length === 0) &&209options?.length === 1210) {211selections.push(options[0].key);212} else {213for (const option of selected_option_nodes) {214selections.push(option.getAttribute("value"));215}216}217218for (const y of selections) {219if (is_valid_uuid_string(y)) {220students.push({221account_id: y,222email_address: emails[y],223});224} else {225students.push({ email_address: y });226}227}228actions.students.add_students(students);229clear();230close?.();231}232233function add_all_students() {234const students: any[] = [];235for (const entry of add_select) {236const { account_id } = entry;237if (is_valid_uuid_string(account_id)) {238students.push({239account_id,240email_address: entry.email_address,241});242} else {243students.push({ email_address: entry.email_address });244}245}246actions.students.add_students(students);247clear();248close?.();249}250251function clear(): void {252set_err(undefined);253set_add_select(undefined);254set_selected_option_nodes(undefined);255set_add_search("");256set_existing_students(undefined);257}258259function get_add_selector_options() {260const v: any[] = [];261const seen = {};262for (const x of add_select) {263const key = x.account_id != null ? x.account_id : x.email_address;264if (seen[key]) continue;265seen[key] = true;266const student_name =267x.account_id != null268? x.first_name + " " + x.last_name269: x.email_address;270const email =271!include_name_search && x.account_id != null && x.email_address272? " (" + x.email_address + ")"273: "";274v.push(275<option key={key} value={key} label={student_name + email}>276{student_name + email}277</option>,278);279}280return v;281}282283function render_add_selector() {284if (add_select == null) return;285const options = get_add_selector_options();286return (287<>288<Form.Item style={{ margin: "5px 0 15px 0" }}>289<select290style={{291width: "100%",292border: "1px solid lightgray",293padding: "4px 11px",294}}295multiple296ref={addSelectRef}297size={8}298onChange={add_selector_changed}299>300{options}301</select>302</Form.Item>303<Space>304{render_cancel()}305{render_add_selector_button(options)}306{render_add_all_students_button(options)}307</Space>308</>309);310}311312function get_add_selector_button_text(existing) {313switch (selected_option_num) {314case 0:315return intl.formatMessage(316{317id: "course.add-students.add-selector-button.case0",318defaultMessage: `{existing, select,319true {Student already added}320other {Select student(s)}}`,321},322{ existing },323);324325case 1:326return intl.formatMessage({327id: "course.add-students.add-selector-button.case1",328defaultMessage: "Add student",329});330default:331return intl.formatMessage(332{333id: "course.add-students.add-selector-button.caseDefault",334defaultMessage: `{num, select,3350 {Select student above}3361 {Add selected student}337other {Add {num} students}}`,338},339{ num: selected_option_num },340);341}342}343344function render_add_selector_button(options) {345let existing;346const es = existing_students;347if (es != null) {348existing = keys(es.email).length + keys(es.account).length > 0;349} else {350// es not defined when user clicks the close button on the warning.351existing = 0;352}353const btn_text = get_add_selector_button_text(existing);354const disabled =355options.length === 0 ||356(options.length >= 1 && selected_option_num === 0);357return (358<Button359onClick={() => add_selected_students(options)}360disabled={disabled}361>362<Icon name="user-plus" /> {btn_text}363</Button>364);365}366367function render_add_all_students_button(options) {368return (369<Button370onClick={() => add_all_students()}371disabled={options.length === 0}372>373<Icon name={"user-plus"} />{" "}374<FormattedMessage375id="course.add-students.add-all-students.button"376defaultMessage={"Add all students"}377description={"Students in an online course"}378/>379</Button>380);381}382383function render_cancel() {384return (385<Button onClick={() => clear()}>386{intl.formatMessage(labels.cancel)}387</Button>388);389}390391function render_error_display() {392if (err) {393return <ShowError error={trunc(err, 1024)} setError={set_err} />;394} else if (existing_students != null) {395const existing: any[] = [];396for (const email in existing_students.email) {397existing.push(email);398}399for (const account_id in existing_students.account) {400const user = user_map.get(account_id);401// user could be null, since there is no guaranteee about what is in user_map.402if (user != null) {403existing.push(`${user.get("first_name")} ${user.get("last_name")}`);404} else {405existing.push(`Student with account ${account_id}`);406}407}408if (existing.length > 0) {409const existingStr = existing.join(", ");410const msg = `Already added (or deleted) students or project collaborators: ${existingStr}`;411return (412<ShowError413style={{ margin: "15px 0" }}414error={msg}415setError={() => set_existing_students(undefined)}416/>417);418}419}420}421422function render_error() {423const ed = render_error_display();424if (ed != null) {425return (426<Col md={24} style={{ marginBottom: "20px" }}>427{ed}428</Col>429);430}431}432433function student_add_input_onChange() {434const value =435(studentAddInputRef?.current as any).resizableTextArea?.textArea.value ??436"";437set_add_select(undefined);438set_add_search(value);439}440441function student_add_input_onKeyDown(e) {442// ESC key443if (e.keyCode === 27) {444set_add_search("");445set_add_select(undefined);446447// Shift+Return448} else if (e.keyCode === 13 && e.shiftKey) {449e.preventDefault();450student_add_input_onChange();451do_add_search(e);452}453}454455const rows = add_search.trim().length == 0 && !studentInputFocused ? 1 : 4;456457const placeholder = intl.formatMessage(458{459id: "course.add-students.textarea.placeholder",460defaultMessage: `Add students by {include_name_search, select, true {names or} other {}} email addresses...`,461},462{ include_name_search },463);464465return (466<Form onFinish={do_add_search} style={{ marginLeft: "15px" }}>467<Row>468<Col md={18}>469<Form.Item style={{ margin: "0 0 5px 0" }}>470<Input.TextArea471ref={studentAddInputRef}472placeholder={placeholder}473value={add_search}474rows={rows}475onChange={() => student_add_input_onChange()}476onKeyDown={(e) => student_add_input_onKeyDown(e)}477onFocus={() => setStudentInputFocused(true)}478onBlur={() => setStudentInputFocused(false)}479/>480</Form.Item>481</Col>482<Col md={6}>483<div style={{ marginLeft: "15px", width: "100%" }}>484{student_add_button()}485</div>486</Col>487<Col md={24}>{render_add_selector()}</Col>488{render_error()}489</Row>490</Form>491);492}493494// Given a list v of user_search results, and a search string s,495// return entries for each email address not in v, in order.496function noncloud_emails(v, s) {497const { email_queries } = parse_user_search(s);498499const result_emails = dict(500v501.filter((r) => r.email_address != null)502.map((r) => [r.email_address, true]),503);504505return sortBy(506email_queries507.filter((r) => !result_emails[r])508.map((r) => {509return { email_address: r };510}),511"email_address",512);513}514515516