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