Path: blob/master/src/packages/frontend/course/common/student-assignment-info.tsx
5950 views
/*1* This file is part of CoCalc: Copyright © 2020 Sagemath, Inc.2* License: MS-RSL – see LICENSE.md for details3*/45import { Button, Col, Input, Row, Space, Spin } from "antd";6import type { TooltipPlacement } from "antd/lib/tooltip";7import { ReactNode, useRef, useState } from "react";8import { FormattedMessage, useIntl } from "react-intl";910import { useActions } from "@cocalc/frontend/app-framework";11import { Icon, Markdown, Tip } from "@cocalc/frontend/components";12import ShowError from "@cocalc/frontend/components/error";13import { COPY_TIMEOUT_MS } from "@cocalc/frontend/course/consts";14import { MarkdownInput } from "@cocalc/frontend/editors/markdown-input";15import { labels } from "@cocalc/frontend/i18n";16import { NotebookScores } from "@cocalc/frontend/jupyter/nbgrader/autograde";17import { webapp_client } from "@cocalc/frontend/webapp-client";18import { BigTime } from ".";19import { CourseActions } from "../actions";20import { NbgraderScores } from "../nbgrader/scores";21import {22AssignmentRecord,23LastCopyInfo,24NBgraderRunInfo,25StudentRecord,26} from "../store";27import { AssignmentCopyType } from "../types";28import { useButtonSize } from "../util";29import {30GRADE_FLEX,31STEP_NAMES,32Steps,33STEPS_INTL,34STEPS_INTL_ACTIVE,35} from "./consts";3637interface StudentAssignmentInfoProps {38name: string;39title: ReactNode;40student: StudentRecord;41assignment: AssignmentRecord;42grade?: string;43comments?: string;44info: {45assignment_id: string;46student_id: string;47peer_assignment: boolean;48peer_collect: boolean;49last_assignment?: LastCopyInfo;50last_collect?: LastCopyInfo;51last_peer_assignment?: LastCopyInfo;52last_peer_collect?: LastCopyInfo;53last_return_graded?: LastCopyInfo;54};55nbgrader_scores?: { [ipynb: string]: NotebookScores | string };56nbgrader_score_ids?: { [ipynb: string]: string[] };57is_editing: boolean;58nbgrader_run_info?: NBgraderRunInfo;59}6061interface RenderLastProps {62step: Steps;63type: AssignmentCopyType;64data?: any;65enable_copy?: boolean;66copy_tip?: string;67open_tip?: string;68omit_errors?: boolean;69}7071const RECOPY_INIT: Record<Steps, false> = {72Assign: false,73Collect: false,74"Peer Assign": false,75Return: false,76"Peer Collect": false,77} as const;7879function useRecopy(): [80typeof RECOPY_INIT,81(key: Steps, value: boolean) => void,82] {83const [recopy, set_recopy] = useState<typeof RECOPY_INIT>(RECOPY_INIT);84function set(key: Steps, value: boolean) {85set_recopy({ ...recopy, [key]: value });86}87return [recopy, set];88}8990export function StudentAssignmentInfo({91name,92title,93student,94assignment,95grade = "",96comments = "",97info,98nbgrader_scores,99nbgrader_score_ids,100is_editing,101nbgrader_run_info,102}: StudentAssignmentInfoProps) {103const intl = useIntl();104const clicked_nbgrader = useRef<Date | undefined>(undefined);105const actions = useActions<CourseActions>({ name });106const size = useButtonSize();107const [recopy, set_recopy] = useRecopy();108109function open(110type: AssignmentCopyType,111assignment_id: string,112student_id: string,113) {114return actions.assignments.open_assignment(type, assignment_id, student_id);115}116117function copy(118type: AssignmentCopyType,119assignment_id: string,120student_id: string,121) {122return actions.assignments.copy_assignment(type, assignment_id, student_id);123}124125function stop(126type: AssignmentCopyType,127assignment_id: string,128student_id: string,129) {130actions.assignments.stop_copying_assignment(131assignment_id,132student_id,133type,134);135}136137function set_edited_feedback() {138actions.assignments.update_edited_feedback(139assignment.get("assignment_id"),140student.get("student_id"),141);142}143144function stop_editing() {145actions.assignments.clear_edited_feedback(146assignment.get("assignment_id"),147student.get("student_id"),148);149}150151function render_grade() {152if (is_editing) {153return (154<Space align="start" style={{ margin: "5px 0" }}>155<Input156placeholder="Grade..."157value={grade ?? ""}158onChange={(e) =>159actions.assignments.set_grade(160assignment.get("assignment_id"),161student.get("student_id"),162e.target.value,163)164}165onPressEnter={() => stop_editing()}166size={size}167style={{ maxWidth: 180 }}168autoFocus169/>170<Button type="primary" size={size} onClick={() => stop_editing()}>171Done172</Button>173</Space>174);175} else {176const hasGrade = !!(grade ?? "").trim();177const gradeText = intl.formatMessage(178{179id: "course.student-assignment-info.grade.label",180defaultMessage: `{show, select, true {Grade: {grade}} other {Enter grade...}}`,181description: "Grade of an assignment in an online course",182},183{ grade, show: hasGrade },184);185186if (hasGrade) {187return (188<Space align="center">189<span>{gradeText}</span>190<Button191icon={<Icon name="pencil" />}192onClick={() => set_edited_feedback()}193disabled={is_editing}194size={size}195aria-label="Edit grade"196title="Edit grade"197/>198</Space>199);200} else {201return (202<Button203key="edit"204icon={<Icon name="pencil" />}205onClick={() => set_edited_feedback()}206disabled={is_editing}207size={size}208>209{gradeText}210</Button>211);212}213}214}215216function render_comments() {217if (!is_editing) {218if (!comments?.trim()) return;219return (220<div style={{ width: "100%", paddingRight: "5px" }}>221<Markdown222value={comments}223style={{224width: "100%",225maxHeight: "4em",226overflowY: "auto",227padding: "5px",228border: "1px solid lightgray",229cursor: "pointer",230display: "inline-block",231}}232onClick={() => set_edited_feedback()}233/>234</div>235);236} else {237return (238<MarkdownInput239placeholder="Optional markdown comments..."240value={comments || ""}241onBlur={(comment) => {242actions.assignments.set_comment(243assignment.get("assignment_id"),244student.get("student_id"),245comment,246);247}}248onShiftEnter={() => stop_editing()}249height="7em"250hideHelp251/>252);253}254}255256function render_nbgrader_scores() {257const scores = nbgrader_scores ?? {};258const hasScores = Object.keys(scores).length > 0;259return (260<div>261<NbgraderScores262show_all={is_editing}263set_show_all={() => set_edited_feedback()}264nbgrader_scores={scores}265nbgrader_score_ids={nbgrader_score_ids}266name={name}267student_id={student.get("student_id")}268assignment_id={assignment.get("assignment_id")}269run_button={render_run_nbgrader(hasScores ? "redo" : "first")}270buttonSize={size}271/>272</div>273);274}275276function render_run_nbgrader(mode: "first" | "redo") {277let running = false;278if (nbgrader_run_info != null) {279const t = nbgrader_run_info.get(280assignment.get("assignment_id") + "-" + student.get("student_id"),281);282if (t && webapp_client.server_time() - t <= 1000 * 60 * 10) {283// Time starting is set and it's also within the last few minutes.284// This "few minutes" is just in case -- we probably shouldn't need285// that at all ever, but it could make cocalc state usable in case of286// weird issues, I guess). User could also just close and re-open287// the course file, which resets this state completely.288running = true;289}290}291292const isFirst = mode === "first";293const iconName = isFirst ? "caret-right" : "redo";294const tipTitle = isFirst ? "Run nbgrader" : "Run nbgrader again";295const tipText = isFirst296? "Run nbgrader on this student's collected submission."297: "Re-run nbgrader for this student's collected submission.";298299return (300<Tip title={tipTitle} tip={tipText}>301<Button302key="nbgrader"303icon={<Icon name={iconName} />}304disabled={running}305loading={running}306size={size}307aria-label={tipTitle}308onClick={() => {309if (310clicked_nbgrader.current != null &&311webapp_client.server_time() -312clicked_nbgrader.current.valueOf() <=3133000314) {315// avoid firing nbgrader twice on rapid double-clicks316return;317}318319clicked_nbgrader.current = new Date();320actions.assignments.run_nbgrader_for_one_student(321assignment.get("assignment_id"),322student.get("student_id"),323);324}}325/>326</Tip>327);328}329330function render_nbgrader() {331if (!assignment.get("nbgrader")) return;332333return render_nbgrader_scores();334}335336function render_last_time(time: string | number | Date) {337return (338<Space key="time" wrap>339<BigTime date={time} />340</Space>341);342}343344function render_recopy_confirm(345step: Steps,346copy: Function,347copy_tip: string,348placement,349) {350if (recopy[step]) {351const v: React.JSX.Element[] = [];352v.push(353<Tip354key="copy_cancel"355title={intl.formatMessage(labels.cancel)}356tip={intl.formatMessage(labels.cancel)}357>358<Button size={size} onClick={() => set_recopy(step, false)}>359{intl.formatMessage(labels.cancel)}360</Button>361</Tip>,362);363v.push(364<Tip365key="recopy_confirm"366title={step}367placement={placement}368tip={copy_tip}369>370<Button371danger372size={size}373onClick={() => {374set_recopy(step, false);375copy();376}}377>378<FormattedMessage379id="course.student-assignment-info.recopy_confirm.label"380defaultMessage={`Yes, {activity} again`}381description={"Confirm an activity, like 'assign', 'collect', ..."}382values={{ activity: step_intl(step, false).toLowerCase() }}383/>384</Button>385</Tip>,386);387if (step.toLowerCase() === "assign") {388// inline-block because buttons above are float:left389v.push(390<div key="what-happens">391<a392target="_blank"393rel="noopener noreferrer"394href="https://doc.cocalc.com/teaching-tips_and_tricks.html#how-exactly-are-assignments-copied-to-students"395>396{intl.formatMessage({397id: "course.student-assignment-info.recopy.what_happens",398defaultMessage: "What happens when I assign again?",399description:400"Asking the question, what happens if all files are transferred to all students in an online course once again.",401})}402</a>403</div>,404);405}406return v;407} else {408return [409<Tip key="copy" title={step} placement={placement} tip={copy_tip}>410<Button411size={size}412icon={<Icon name="redo" />}413onClick={() => set_recopy(step, true)}414aria-label={`Redo ${step.toLowerCase()} for this student`}415/>416</Tip>,417];418}419}420421function render_open(open, tip: string, placement: TooltipPlacement) {422return (423<Tip key="open" title="Open assignment" tip={tip} placement={placement}>424<Button425onClick={open}426size={size}427icon={<Icon name="folder-open" />}428aria-label="Open assignment folder"429/>430</Tip>431);432}433434function step_intl(step: Steps, active: boolean): string {435return intl.formatMessage(active ? STEPS_INTL_ACTIVE : STEPS_INTL, {436step: STEP_NAMES.indexOf(step),437});438}439440function render_copying(step: Steps, stop) {441return [442<Button key="stop" danger onClick={stop} size={size}>443{intl.formatMessage(labels.cancel)}444</Button>,445<Button key="copy" disabled={true} size={size}>446<Spin /> {step_intl(step, true)}447</Button>,448];449}450451function render_copy(452step: Steps,453copy: () => void,454tip: string,455placement: TooltipPlacement,456) {457return (458<Tip key="copy" title={step} tip={tip} placement={placement}>459<Button460onClick={copy}461size={size}462icon={<Icon name="caret-right" />}463aria-label={`${step} this student`}464/>465</Tip>466);467}468469function render_error(step: Steps, error) {470if (typeof error !== "string") {471error = `${error}`;472}473if (error.includes("[object Object]")) {474// already too late to know the actual error -- it got mangled/reported incorrectly475error = "";476}477// We search for two different error messages, since different errors happen in478// KuCalc versus other places cocalc runs. It depends on what is doing the copy.479if (480error.indexOf("No such file or directory") !== -1 ||481error.indexOf("ENOENT") != -1482) {483error = `The student might have renamed or deleted the directory that contained their assignment. Open their project and see what happened. If they renamed it, you could rename it back, then collect the assignment again -- \n${error}`;484} else {485error = `Try to ${step.toLowerCase()} again -- \n${error}`;486}487return (488<ShowError489key="error"490error={error}491style={{ padding: "4px 4px", overflowWrap: "anywhere" }}492/>493);494}495496function Status({497step,498type,499data = {},500enable_copy = false,501copy_tip = "",502open_tip = "",503omit_errors = false,504}: RenderLastProps): React.JSX.Element {505const do_open = () => open(type, info.assignment_id, info.student_id);506const do_copy = () => copy(type, info.assignment_id, info.student_id);507const do_stop = () => stop(type, info.assignment_id, info.student_id);508const v: React.JSX.Element[] = [];509const placement: TooltipPlacement = step === "Return" ? "left" : "right";510if (enable_copy) {511const now = webapp_client.server_time();512const in_progress =513data.start != null && now - data.start < COPY_TIMEOUT_MS;514if (in_progress) {515v.push(...render_copying(step, do_stop));516v.push(render_open(do_open, open_tip, placement));517} else if (data.time) {518v.push(render_open(do_open, open_tip as string, placement));519v.push(520...render_recopy_confirm(521step,522do_copy,523copy_tip as string,524placement,525),526);527} else {528v.push(render_copy(step, do_copy, copy_tip as string, placement));529}530}531if (data.time) {532v.push(render_last_time(data.time));533}534if (data.error && !omit_errors) {535v.push(render_error(step, data.error));536}537return <Space wrap>{v}</Space>;538}539540let show_grade_col, show_return_graded;541const peer_grade: boolean = !!assignment.getIn(["peer_grade", "enabled"]);542const skip_grading: boolean = !!assignment.get("skip_grading");543const skip_assignment: boolean = !!assignment.get("skip_assignment");544const skip_collect: boolean = !!assignment.get("skip_collect");545if (peer_grade) {546show_grade_col = info.last_peer_collect;547show_return_graded = grade || (skip_grading && info.last_peer_collect);548} else {549show_grade_col = info.last_collect || skip_collect;550show_return_graded =551grade ||552(skip_grading && info.last_collect) ||553(skip_grading && skip_collect);554}555556function render_assignment_col() {557return (558<Col flex="1" key="last_assignment">559<Status560step="Assign"561data={info.last_assignment}562type="assigned"563enable_copy={true}564copy_tip={intl.formatMessage({565id: "course.student-assignment-info.assignment_col.copy.tooltip",566defaultMessage: `Copy the assignment from your project to this student's project so they can do their homework.`,567description: "files of a student in an online course",568})}569open_tip={intl.formatMessage({570id: "course.student-assignment-info.assignment_col.open.tooltip",571defaultMessage: `Open the student's copy of this assignment directly in their project.572You will be able to see them type, chat with them, leave them hints, etc.`,573description: "files of a student in an online course",574})}575omit_errors={skip_assignment}576/>577</Col>578);579}580581function render_collect_col() {582return (583<Col flex="1" key="last_collect">584{skip_assignment ||585!(info.last_assignment != null586? info.last_assignment.error587: undefined) ? (588<Status589step="Collect"590data={info.last_collect}591type="collected"592enable_copy={info.last_assignment != null || skip_assignment}593copy_tip={intl.formatMessage({594id: "course.student-assignment-info.collect_col.copy.tooltip",595defaultMessage:596"Copy the assignment from your student's project back to your project so you can grade their work.",597description: "files of a student in an online course",598})}599open_tip={intl.formatMessage({600id: "course.student-assignment-info.collect_col.open.tooltip",601defaultMessage:602"Open the copy of your student's work in your own project, so that you can grade their work.",603description: "files of a student in an online course",604})}605omit_errors={skip_collect}606/>607) : undefined}608</Col>609);610}611612function render_peer_assign_col() {613if (!peer_grade) return;614if (!info.peer_assignment || info.last_collect?.error != null) {615return <Col flex="1" key="peer_assign" />;616}617return (618<Col flex="1" key="peer_assign">619<Status620step="Peer Assign"621data={info.last_peer_assignment}622type={"peer-assigned"}623enable_copy={info.last_collect != null}624copy_tip={intl.formatMessage({625id: "course.student-assignment-info.peer_assign_col.copy.tooltip",626defaultMessage:627"Copy collected assignments from your project to this student's project so they can grade them.",628description: "files of a student in an online course",629})}630open_tip={intl.formatMessage({631id: "course.student-assignment-info.peer_assign_col.open.tooltip",632defaultMessage:633"Open the student's copies of this assignment directly in their project, so you can see what they are peer grading.",634description: "files of a student in an online course",635})}636/>637</Col>638);639}640641function render_peer_collect_col() {642if (!peer_grade) return;643if (!info.peer_collect) return <Col flex="1" key="peer_collect" />;644return (645<Col flex="1" key="peer_collect">646<Status647step="Peer Collect"648data={info.last_peer_collect}649type="peer-collected"650enable_copy={info.last_peer_assignment != null}651copy_tip={intl.formatMessage({652id: "course.student-assignment-info.peer_collect_col.copy.tooltip",653defaultMessage:654"Copy the peer-graded assignments from various student projects back to your project so you can assign their official grade.",655description: "files of a student in an online course",656})}657open_tip={intl.formatMessage({658id: "course.student-assignment-info.peer_collect_col.open.tooltip",659defaultMessage:660"Open your copy of your student's peer grading work in your own project, so that you can grade their work.",661662description: "files of a student in an online course",663})}664/>665</Col>666);667}668669function render_grade_col() {670// {render_enter_grade()}671return (672<Col flex={GRADE_FLEX} key="grade">673{show_grade_col && (674<div>675{render_grade()}676{render_comments()}677{render_nbgrader()}678</div>679)}680</Col>681);682}683684function render_return_graded_col() {685return (686<Col flex="1" key="return_graded">687{show_return_graded ? (688<Status689step="Return"690data={info.last_return_graded}691type="graded"692enable_copy={info.last_collect != null || skip_collect}693copy_tip={intl.formatMessage({694id: "course.student-assignment-info.graded_col.copy.tooltip",695defaultMessage: `Copy the graded assignment back to your student's project.`,696description: "files of a student in an online course",697})}698open_tip={intl.formatMessage({699id: "course.student-assignment-info.graded_col.open.tooltip",700defaultMessage: `Open the copy of your student's work that you returned to them.701This opens the returned assignment directly in their project.`,702description: "the files of a student in an online course",703})}704/>705) : undefined}706</Col>707);708}709710return (711<div>712<Row713style={{714borderTop: "1px solid #aaa",715paddingTop: "5px",716paddingBottom: "5px",717}}718>719<Col md={4} key="title">720{title}721</Col>722<Col md={20} key="rest">723<Row gutter={[8, 0]}>724{render_assignment_col()}725{render_collect_col()}726{render_peer_assign_col()}727{render_peer_collect_col()}728{render_grade_col()}729{render_return_graded_col()}730</Row>731</Col>732</Row>733</div>734);735}736737738