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