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/students-panel-student.tsx
Views: 687
/*1* This file is part of CoCalc: Copyright © 2020 Sagemath, Inc.2* License: MS-RSL – see LICENSE.md for details3*/4import {5Button,6Card,7Col,8Input,9Popconfirm,10Row,11Space,12Tooltip,13} from "antd";14import { useEffect, useState } from "react";15import { FormattedMessage, useIntl } from "react-intl";1617import {18Icon,19MarkdownInput,20Text,21TimeAgo,22Tip,23} from "@cocalc/frontend/components";24import { labels } from "@cocalc/frontend/i18n";25import { ProjectMap, UserMap } from "@cocalc/frontend/todo-types";26import { User } from "@cocalc/frontend/users";27import { webapp_client } from "@cocalc/frontend/webapp-client";28import { search_match, search_split, trunc_middle } from "@cocalc/util/misc";29import { COLORS } from "@cocalc/util/theme";30import { CourseActions } from "../actions";31import { StudentAssignmentInfo, StudentAssignmentInfoHeader } from "../common";32import {33AssignmentsMap,34IsGradingMap,35NBgraderRunInfo,36StudentRecord,37} from "../store";38import { RESEND_INVITE_BEFORE } from "../student-projects/actions";39import * as styles from "../styles";40import * as util from "../util";41import { useButtonSize } from "../util";42import DeletedAccount from "./deleted-account";4344export interface StudentNameDescription {45full: string;46first: string;47last: string;48}4950/*51Updates based on:52- Expanded/Collapsed53- If collapsed: First name, last name, email, last active, hosting type54- If expanded: Above +, Student's status on all assignments,55*/5657interface StudentProps {58redux: any;59name: string;60student: StudentRecord;61student_id: string;62user_map: UserMap;63project_map: ProjectMap; // here entirely to cause an update when project activity happens64assignments: AssignmentsMap; // here entirely to cause an update when project activity happens65background?: string;66is_expanded?: boolean;67student_name: StudentNameDescription;68display_account_name?: boolean;69active_feedback_edits: IsGradingMap;70nbgrader_run_info?: NBgraderRunInfo;71assignmentFilter?;72}7374export function Student({75redux,76name,77student,78student_id,79user_map,80project_map,81//assignments,82background,83is_expanded,84student_name,85display_account_name,86active_feedback_edits,87nbgrader_run_info,88assignmentFilter,89}: StudentProps) {90const intl = useIntl();91const actions: CourseActions = redux.getActions(name);92const store = actions.get_store();93if (store == null) throw Error("store must be defined");9495const deletedAccount = !!student.get("deleted_account");96const hasAccount = student.get("account_id") != null;9798const size = useButtonSize();99100const [editing_student, set_editing_student] = useState<boolean>(false);101const [edited_first_name, set_edited_first_name] = useState<string>(102student_name.first || "",103);104const [edited_last_name, set_edited_last_name] = useState<string>(105student_name.last || "",106);107const [edited_email_address, set_edited_email_address] = useState<string>(108student.get("email_address") || "",109);110const [more, set_more] = useState<boolean>(false);111function reset_initial_state() {112set_editing_student(false);113set_edited_first_name(student_name.first || "");114set_edited_last_name(student_name.last || "");115set_edited_email_address(student.get("email_address") || "");116set_more(false);117actions.students.setAssignmentFilter(student_id, "");118}119120useEffect(() => {121set_edited_first_name(student_name.first);122}, [student_name.first]);123useEffect(() => {124set_edited_last_name(student_name.last);125}, [student_name.last]);126useEffect(() => {127set_edited_email_address(student.get("email_address") ?? "");128}, [student.get("email_address")]);129130function on_key_down(e) {131switch (e.keyCode) {132case 13:133return save_student_changes();134case 27:135return cancel_student_edit();136}137}138139function toggle_show_more(e) {140e.preventDefault();141if (editing_student) {142cancel_student_edit();143}144const item_id = student.get("student_id");145actions.toggle_item_expansion("student", item_id);146}147148function render_student() {149return (150<a href="" onClick={toggle_show_more}>151<div style={{ width: "20px", display: "inline-block" }}>152<Icon153style={{ marginRight: "10px" }}154name={is_expanded ? "caret-down" : "caret-right"}155/>156</div>157{render_student_name()}158</a>159);160}161162function render_student_name() {163const account_id = student.get("account_id");164if (account_id != null) {165return (166<User167account_id={account_id}168user_map={user_map}169name={student_name.full}170show_original={display_account_name}171/>172);173}174const name = store.get_student_name(student.get("student_id"));175return (176<span>177{name} ({intl.formatMessage(labels.invited)})178</span>179);180}181182function render_student_email() {183const email = student.get("email_address");184return (185<a target={"_blank"} href={`mailto:${email}`} rel={"noopener"}>186{email}187</a>188);189}190191function open_project() {192redux.getActions("projects").open_project({193project_id: student.get("project_id"),194});195}196197function create_project() {198actions.student_projects.create_student_project(student_id);199}200201function render_last_active() {202if (deletedAccount) {203return (204<DeletedAccount205actions={actions}206student_id={student_id}207name={render_student_name()}208email_address={student.get("email_address")}209/>210);211}212if (!hasAccount) {213return (214<span style={{ color: COLORS.GRAY_M }}>215<FormattedMessage216id="course.students-panel-student.last_active.no_account"217defaultMessage="(has not created account yet)"218description="The student in the online course has no account yet"219/>220</span>221);222}223const student_project_id = student.get("project_id");224if (student_project_id == null) {225return;226}227const p = project_map.get(student_project_id);228if (p == null) {229// no info about this project? maybe we need to load full list or230// users isn't a collab, so don't know.231const project_actions = redux.getActions("projects");232if (project_actions != null) {233// If this does load all (since not loaded), then will try again to234// render with new project_map.235project_actions.load_all_projects();236}237return;238}239const u = p.get("last_active");240const last_active = u != null ? u.get(student.get("account_id")) : null;241if (last_active) {242// student has definitely been active (and we know about this project).243return (244<Text type="secondary">245<FormattedMessage246id="course.students-panel-student.last_active.time_ago"247defaultMessage={"(last used project {timeago})"}248values={{ timeago: <TimeAgo date={last_active} /> }}249/>250</Text>251);252} else {253return (254<Text type="secondary">255<FormattedMessage256id="course.students-panel-student.last_active.never_used_project"257defaultMessage={"(has never used project)"}258/>259</Text>260);261}262}263264function render_hosting() {265const { description, tip, state, icon } = util.projectStatus(266student.get("project_id"),267redux,268intl,269);270return (271<Tip272placement="left"273title={274<span>275<Icon name={icon} /> {description}276</span>277}278tip={tip}279>280<span style={{ color: COLORS.GRAY_M, cursor: "pointer" }}>281<Icon name={icon} /> {description}282{state}283</span>284</Tip>285);286}287288function render_project_access(): JSX.Element {289// first check if the project is currently being created290const create = student.get("create_project");291if (create != null) {292// if so, how long ago did it start293const how_long = (webapp_client.server_time() - create) / 1000;294if (how_long < 120) {295// less than 2 minutes -- still hope, so render that creating296return (297<div>298<Icon name="cocalc-ring" spin /> Creating project... (started{" "}299<TimeAgo date={create} />)300</div>301);302}303}304// otherwise, maybe user killed file before finished or something and305// it is lost; give them the chance306// to attempt creation again by clicking the create button.307const student_project_id = student.get("project_id");308if (student_project_id != null) {309const accessMsg = intl.formatMessage({310id: "course.student-panel.project_access.access_button",311defaultMessage: "Open student project",312});313return (314<Button onClick={open_project} size={size}>315<Tip316placement="right"317title={accessMsg}318tip={intl.formatMessage({319id: "course.student-panel.project_access.access_button.tooltip",320defaultMessage: "Open the course project for this student.",321})}322>323<Icon name="edit" /> {accessMsg}324</Tip>325</Button>326);327} else {328const createMsg = intl.formatMessage({329id: "course.student-panel.project_access.create_button",330defaultMessage: "Create student project",331});332return (333<Tip334placement="right"335title={createMsg}336tip={intl.formatMessage({337id: "course.student-panel.project_access.create_button.tooltip",338defaultMessage:339"Create a new project for this student, then add the student as a collaborator, and also add any collaborators on the project containing this course.",340})}341>342<Button onClick={create_project} size={size}>343<Icon name="plus-circle" /> {createMsg}344</Button>345</Tip>346);347}348}349350function student_changed() {351return (352student_name.first !== edited_first_name ||353student_name.last !== edited_last_name ||354student.get("email_address") !== edited_email_address355);356}357358function render_edit_student() {359if (editing_student) {360const disable_save = !student_changed();361return (362<Space>363<Button onClick={cancel_student_edit} size={size}>364{intl.formatMessage(labels.cancel)}365</Button>366<Button367onClick={save_student_changes}368type="primary"369disabled={disable_save}370size={size}371>372<Icon name="save" /> {intl.formatMessage(labels.save)}373</Button>374</Space>375);376} else {377return (378<Button onClick={show_edit_name_dialogue} size={size}>379<Icon name="address-card" />{" "}380<FormattedMessage381id="course.students-panel-student.edit_student.button"382defaultMessage="Edit student..."383description="Button label to open a dialog to modify data about a student in an online course"384/>385</Button>386);387}388}389390function render_search_assignment() {391return (392<Input.Search393allowClear394style={{ width: "100%" }}395placeholder={"Filter assignments..."}396value={assignmentFilter ?? ""}397onChange={(e) =>398actions.students.setAssignmentFilter(student_id, e.target.value)399}400/>401);402}403404function cancel_student_edit() {405reset_initial_state();406}407408function save_student_changes() {409actions.students.set_internal_student_info(student.get("student_id"), {410first_name: edited_first_name,411last_name: edited_last_name,412email_address: edited_email_address,413});414415set_editing_student(false);416}417418function show_edit_name_dialogue() {419set_editing_student(true);420}421422function delete_student(noTrash: boolean) {423actions.students.delete_student(student.get("student_id"), noTrash);424}425426function undelete_student() {427actions.students.undelete_student(student.get("student_id"));428}429430function render_delete_button() {431if (!is_expanded) {432return;433}434if (student.get("deleted")) {435return (436<Button onClick={undelete_student} size={size}>437<Icon name="trash" /> {intl.formatMessage(labels.undelete)}438</Button>439);440} else {441return (442<Popconfirm443title={444<div style={{ maxWidth: "400px" }}>445<FormattedMessage446id="course.student-panel.delete-student.confirm"447defaultMessage={`Are you sure you want to delete "{name}"?448All grades and other data about them will be removed,449but you can still undelete them.`}450values={{ name: render_student_name() }}451/>452</div>453}454onConfirm={() => delete_student(false)}455>456<Button size={size}>457<Icon name="trash" /> {intl.formatMessage(labels.delete)}...458</Button>459</Popconfirm>460);461}462}463464function render_resend_invitation() {465// don't invite student if there is already an account466if (hasAccount) return;467const last_email_invite = student.get("last_email_invite");468const allowResending =469!last_email_invite || new Date(last_email_invite) < RESEND_INVITE_BEFORE;470471const msg = intl.formatMessage(472{473id: "course.student-panel.resend_invitation.button",474defaultMessage: `{allowResending, select, true {Resend invitation} other {Recently invited}}`,475},476{ allowResending },477);478const when =479last_email_invite != null480? `Last invitation sent on ${new Date(481last_email_invite,482).toLocaleString()}`483: "never";484485return (486<Tooltip placement="bottom" title={when}>487<Button488size={size}489onClick={() => {490const email = student.get("email_address");491if (email) {492actions.student_projects.invite_student_to_project({493student: email, // we use email address to trigger sending an actual email!494student_project_id: student.get("project_id"),495student_id: student.get("student_id"),496});497}498}}499disabled={!allowResending}500>501<Icon name="mail" /> {msg}502</Button>503</Tooltip>504);505}506507function render_title_due(assignment) {508const date = assignment.get("due_date");509if (date) {510return (511<span>512(Due <TimeAgo date={date} />)513</span>514);515}516}517518function render_title(assignment) {519return (520<span>521<em>{trunc_middle(assignment.get("path"), 50)}</em>{" "}522{render_title_due(assignment)}523</span>524);525}526527function render_assignments_info_rows() {528const result: any[] = [];529const terms = search_split(assignmentFilter ?? "");530// TODO instead of accessing the store, use the state to react to data changes -- that's why we chech in "isSame" above.531for (const assignment of store.get_sorted_assignments()) {532if (terms.length > 0) {533const aPath = assignment.get("path")?.toLowerCase() ?? "";534if (!search_match(aPath, terms)) continue;535}536const grade = store.get_grade(537assignment.get("assignment_id"),538student.get("student_id"),539);540const comments = store.get_comments(541assignment.get("assignment_id"),542student.get("student_id"),543);544const info = store.student_assignment_info(545student.get("student_id"),546assignment.get("assignment_id"),547);548const key = util.assignment_identifier(549assignment.get("assignment_id"),550student.get("student_id"),551);552const edited_feedback = active_feedback_edits.get(key);553result.push(554<StudentAssignmentInfo555key={assignment.get("assignment_id")}556title={render_title(assignment)}557name={name}558student={student}559assignment={assignment}560grade={grade}561comments={comments}562nbgrader_scores={store.get_nbgrader_scores(563assignment.get("assignment_id"),564student.get("student_id"),565)}566info={info}567is_editing={!!edited_feedback}568nbgrader_run_info={nbgrader_run_info}569/>,570);571}572return result;573}574575function render_assignments_info() {576const peer_grade = store.any_assignment_uses_peer_grading();577const header = (578<StudentAssignmentInfoHeader579key="header"580title="Assignment"581peer_grade={peer_grade}582/>583);584return [header, render_assignments_info_rows()];585}586587function render_note() {588const title = intl.formatMessage({589id: "course.students-panel-student.note.title",590defaultMessage: "Private Student Notes",591description: "About a student in an online course",592});593const tooltipTitle = intl.formatMessage({594id: "course.students-panel-student.note.tooltip.title",595defaultMessage: "Notes about this student",596description: "About a student in an online course",597});598const tooltip = intl.formatMessage({599id: "course.students-panel-student.note.tooltip",600defaultMessage:601"Record notes about this student here. These notes are only visible to you, not to the student. In particular, you might want to include an email address or other identifying information here, and notes about late assignments, excuses, etc.",602description: "About a student in an online course",603});604const placeholder = intl.formatMessage({605id: "course.students-panel-student.note.placeholder",606defaultMessage: "Notes about student (not visible to student)",607description: "About a student in an online course",608});609return (610<Row key="note" style={styles.note}>611<Col xs={4}>612<Tip title={tooltipTitle} tip={tooltip}>613{title}614</Tip>615</Col>616<Col xs={20}>617<MarkdownInput618persist_id={student.get("student_id") + "note"}619attach_to={name}620rows={6}621placeholder={placeholder}622default_value={student.get("note")}623on_save={(value) =>624actions.students.set_student_note(625student.get("student_id"),626value,627)628}629/>630</Col>631</Row>632);633}634635function render_more_info() {636// Info for each assignment about the student.637return (638<>639<Row key="more">640<Col md={24}>{render_assignments_info()}</Col>641</Row>642{render_note()}643{render_push_missing_handouts_and_assignments()}644</>645);646}647648function render_basic_info() {649return (650<Row key="basic" style={{ backgroundColor: background }}>651<Col md={6}>652<h6>653{render_student()}654{render_deleted()}655</h6>656</Col>657<Col md={4}>658<h6 style={{ color: "#666", overflow: "hidden" }}>659{render_student_email()}660</h6>661</Col>662<Col md={8} style={{ paddingTop: "10px" }}>663{render_last_active()}664</Col>665<Col md={6} style={{ paddingTop: "10px" }}>666{render_hosting()}667</Col>668</Row>669);670}671672function render_push_missing_handouts_and_assignments() {673const title = intl.formatMessage({674id: "course.students-panel-student.catch-up.title",675defaultMessage: "Catch up this student",676description:677"Copy all not yet sent files to this student in an online course",678});679const tooltip = intl.formatMessage({680id: "course.students-panel-student.catch-up.tooltip",681defaultMessage:682"Copy any assignments and handouts to this student that have been copied to at least one other student",683description: "Files for a student in an online course",684});685686return (687<Row key="catchup" style={{ marginTop: "15px" }}>688<Col xs={4}>689<Tip title={title} tip={tooltip}>690<FormattedMessage691id="course.students-panel-student.catch-up.info"692defaultMessage={"Copy missing assignments and handouts"}693/>694</Tip>695</Col>696<Col xs={8}>697<Button698onClick={() =>699actions.students.push_missing_handouts_and_assignments(700student.get("student_id"),701)702}703>704<Icon name="share-square" /> {title}705</Button>706</Col>707</Row>708);709}710711function render_deleted() {712if (student.get("deleted")) {713return <b> (deleted)</b>;714}715}716717function render_panel_header() {718// The whiteSpace normal is because the title of an719// antd Card doesn't wrap, and I don't want to restructure720// this whole student delete code right now to not put721// confirmation in the title. When it is restructured722// it'll be the antd modal popup anyways...723// See https://github.com/sagemathinc/cocalc/issues/4286724return (725<div style={{ whiteSpace: "normal" }}>726<Row>727<Col md={4}>{render_project_access()}</Col>728<Col md={4}>{render_edit_student()}</Col>729<Col md={4}>{render_search_assignment()}</Col>730<Col md={2} offset={3}>731{render_resend_invitation()}732</Col>733<Col md={4} offset={3}>734{render_delete_button()}735</Col>736</Row>737{editing_student ? (738<Row>739<Col md={8}>{render_edit_student_interface()}</Col>740</Row>741) : undefined}742</div>743);744}745746function render_edit_student_interface() {747return (748<Card style={{ marginTop: "10px" }}>749<Row>750<Col md={12} style={{ paddingRight: "15px" }}>751First Name752<Input753autoFocus754value={edited_first_name}755onClick={(e) => {756e.stopPropagation();757e.preventDefault();758}}759onChange={(e) => set_edited_first_name(e.target.value)}760onKeyDown={on_key_down}761/>762</Col>763<Col md={12}>764Last Name765<Input766value={edited_last_name}767onClick={(e) => {768e.stopPropagation();769e.preventDefault();770}}771onChange={(e) => set_edited_last_name((e.target as any).value)}772onKeyDown={on_key_down}773/>774</Col>775</Row>776<Row>777<Col md={24}>778Email Address779<Input780type="text"781value={edited_email_address}782onClick={(e) => {783e.stopPropagation();784e.preventDefault();785}}786onChange={(e) =>787set_edited_email_address((e.target as any).value)788}789onKeyDown={on_key_down}790/>791</Col>792</Row>793</Card>794);795}796797function render_more_panel() {798return (799<Row>800<Col xs={24}>801<Card title={render_panel_header()}>{render_more_info()}</Card>802</Col>803</Row>804);805}806807return (808<div>809<Row style={more ? styles.selected_entry : undefined}>810<Col xs={24}>811{render_basic_info()}812{is_expanded ? render_more_panel() : undefined}813</Col>814</Row>815</div>816);817}818819820