Path: blob/master/src/packages/frontend/course/common/course-unit-card.tsx
10975 views
/*1* This file is part of CoCalc: Copyright © 2020 Sagemath, Inc.2* License: MS-RSL – see LICENSE.md for details3*/45import { Alert, Card, ConfigProvider, Divider, Space } from "antd";6import { MouseEvent, ReactNode, useEffect, useState } from "react";7import { useIntl } from "react-intl";89import { AppRedux } from "@cocalc/frontend/app-framework";10import { webapp_client } from "@cocalc/frontend/webapp-client";1112import { CourseActions } from "../actions";13import { StudentListForAssignment } from "../assignments/assignment-student-list";14import { StudentListForHandout } from "../handouts/handout-student-list";15import type {16AssignmentRecord,17CourseStore,18HandoutRecord,19IsGradingMap,20NBgraderRunInfo,21StudentsMap,22} from "../store";23import type { UserMap } from "../../todo-types";24import { useButtonSize } from "../util";25import { CourseUnitControls } from "./course-unit-controls";26import { noContentMessages, noteMessages } from "./course-unit-strings";27import type { UnitLabel } from "./course-unit-strings";28import { isAssignmentUnit } from "./course-unit-types";29import type { HandoutStatus } from "./course-unit-types";30import { AssignmentHeader } from "./assignment-header";31import { HandoutHeader } from "./handout-header";32import { PrivateNotes } from "./private-notes";3334interface CourseUnitCardCommonProps {35actions: CourseActions;36redux: AppRedux;37name: string;38students: StudentsMap;39user_map: UserMap;40frame_id?: string;41project_id: string;42}4344interface CourseUnitCardAssignmentProps {45unit: AssignmentRecord;46active_feedback_edits: IsGradingMap;47nbgrader_run_info?: NBgraderRunInfo;48expand_peer_config?: boolean;49}5051interface CourseUnitCardHandoutProps {52unit: HandoutRecord;53}5455type CourseUnitCardProps = CourseUnitCardCommonProps &56(CourseUnitCardAssignmentProps | CourseUnitCardHandoutProps);5758export function CourseUnitCard(props: CourseUnitCardProps) {59const size = useButtonSize();60const intl = useIntl();6162const {63unit,64actions,65redux,66name,67students,68user_map,69frame_id,70project_id,71} = props;72const [studentSearch, setStudentSearch] = useState<string>("");73const [showPeerDisabledAlert, setShowPeerDisabledAlert] =74useState<boolean>(false);7576const assignment = isAssignmentUnit(unit) ? unit : undefined;77const unitLabel: UnitLabel = assignment ? "assignment" : "handout";78const unitId = assignment79? assignment.get("assignment_id")80: (unit as HandoutRecord).get("handout_id");81const noteValue =82(assignment83? assignment.get("note")84: (unit as HandoutRecord).get("note")) ?? "";85const assignmentId = assignment ? assignment.get("assignment_id") : "";86const peerEnabled = assignment?.getIn(["peer_grade", "enabled"]);87const nbgraderEnabled = assignment?.get("nbgrader");88const unitPath = assignment89? assignment.get("path")90: (unit as HandoutRecord).get("path");91const unitDeleted = assignment92? assignment.get("deleted")93: (unit as HandoutRecord).get("deleted");94const initialHasFiles = assignment95? (assignment.get("listing")?.size ?? 0) > 096: null;97const [hasFiles, setHasFiles] = useState<boolean | null>(initialHasFiles);9899useEffect(() => {100if (!assignmentId || !peerEnabled) return;101for (const step of ["assignment", "collect"] as const) {102if (assignment?.get(`skip_${step}` as any)) {103actions.assignments.set_skip(assignmentId, step, false);104}105}106}, [assignmentId, peerEnabled]);107108useEffect(() => {109if (!assignmentId || !nbgraderEnabled || !peerEnabled) return;110actions.assignments.set_peer_grade(assignmentId, { enabled: false });111setShowPeerDisabledAlert(true);112}, [assignmentId, nbgraderEnabled, peerEnabled]);113114async function refreshHasFiles() {115if (unitDeleted || project_id == null || unitPath == null) return;116try {117const { files } = await webapp_client.project_client.directory_listing({118project_id,119path: unitPath,120hidden: false,121compute_server_id: 0,122});123setHasFiles((files?.length ?? 0) > 0);124} catch (_err) {125// Keep previous state if listing fails (e.g. permissions/temporary issues).126}127}128129useEffect(() => {130setHasFiles(initialHasFiles);131}, [unitId, initialHasFiles]);132133useEffect(() => {134if (unitDeleted) return;135void refreshHasFiles();136const onFocus = () => {137void refreshHasFiles();138};139window.addEventListener("focus", onFocus);140return () => window.removeEventListener("focus", onFocus);141}, [project_id, unitPath, unitId, unitDeleted]);142143useEffect(() => {144if (unitDeleted || hasFiles !== false) return;145const id = window.setInterval(() => {146void refreshHasFiles();147}, 3000);148return () => window.clearInterval(id);149}, [hasFiles, project_id, unitPath, unitId, unitDeleted]);150151function getStore(): CourseStore {152const store = redux.getStore(name);153if (store == null) throw Error("store must be defined");154return store as unknown as CourseStore;155}156157const store = getStore();158let controlsNode: ReactNode = null;159let warningNode: ReactNode = null;160let runAllNode: ReactNode = null;161let bodyNode: ReactNode = null;162let saveNote: (value: string) => void;163const noContent = noContentMessages(intl, unitLabel);164165function renderNoContentWarning(166onOpenUnitPath: (e?: MouseEvent<HTMLElement>) => void,167): ReactNode {168if (unitDeleted || hasFiles !== false) return null;169return (170<Alert171type="warning"172showIcon173style={{ margin: "15px auto", maxWidth: "800px" }}174message={noContent.message}175description={noContent.description((chunks) => (176<a onClick={onOpenUnitPath}>{chunks}</a>177))}178/>179);180}181182function renderPrivateNotes(): ReactNode {183const { title, tip, placeholder } = noteMessages(intl, unitLabel);184return (185<PrivateNotes186title={title}187tip={tip}188value={noteValue}189onSave={saveNote}190placeholder={placeholder}191persistId={unitId}192/>193);194}195196if (assignment) {197const assignmentProps = props as CourseUnitCardAssignmentProps;198const openUnitPath = (e?: MouseEvent<HTMLElement>) => {199e?.preventDefault();200return redux201.getProjectActions(project_id)202.open_directory(assignment.get("path"));203};204205controlsNode = (206<CourseUnitControls207unit={assignment}208actions={actions}209onOpenUnitPath={openUnitPath}210expandPeerConfig={assignmentProps.expand_peer_config}211showPeerDisabledAlert={showPeerDisabledAlert}212setShowPeerDisabledAlert={setShowPeerDisabledAlert}213/>214);215warningNode = renderNoContentWarning(openUnitPath);216// Intentional: the full assignment workflow (action buttons, student list) is217// rendered even when the directory has no files yet. Assigning an empty folder218// still creates the student directory and writes the due-date file, so the219// instructor can set up the roster/due-dates first and add content later.220// A warning banner (warningNode above) is shown, but the actions are not blocked.221runAllNode = (222<AssignmentHeader223assignment={assignment}224status={store.get_assignment_status(assignmentId) ?? null}225numStudents={store.num_students()}226actions={actions}227studentSearch={studentSearch}228setStudentSearch={setStudentSearch}229nbgraderRunInfo={assignmentProps.nbgrader_run_info}230/>231);232bodyNode = (233<StudentListForAssignment234redux={redux}235frame_id={frame_id}236name={name}237assignment={assignment}238students={students}239user_map={user_map}240active_feedback_edits={assignmentProps.active_feedback_edits}241nbgrader_run_info={assignmentProps.nbgrader_run_info}242search={studentSearch}243/>244);245246saveNote = (value) =>247actions.assignments.set_assignment_note(assignmentId, value);248} else {249const handout = unit as HandoutRecord;250const status =251store.get_handout_status(handout.get("handout_id")) ??252({ handout: 0, not_handout: 0 } as HandoutStatus);253const openUnitPath = (e?: MouseEvent<HTMLElement>) => {254e?.preventDefault();255return redux256.getProjectActions(project_id)257.open_directory(handout.get("path"));258};259260controlsNode = (261<CourseUnitControls262unit={handout}263actions={actions}264onOpenUnitPath={openUnitPath}265/>266);267warningNode = renderNoContentWarning(openUnitPath);268runAllNode = (269<HandoutHeader270handout={handout}271status={status}272numStudents={store.num_students()}273actions={actions}274studentSearch={studentSearch}275setStudentSearch={setStudentSearch}276/>277);278bodyNode = (279<StudentListForHandout280frame_id={frame_id}281handout={handout}282students={students}283user_map={user_map}284actions={actions}285name={name}286search={studentSearch}287/>288);289290saveNote = (value) => actions.handouts.set_handout_note(unitId, value);291}292293return (294<ConfigProvider componentSize={size}>295<Card>296<Space direction="vertical" style={{ width: "100%" }}>297{controlsNode}298{renderPrivateNotes()}299<Divider style={{ borderTopWidth: 3, margin: 0 }} />300{warningNode}301{runAllNode}302{bodyNode}303</Space>304</Card>305</ConfigProvider>306);307}308309310