Book a Demo!
CoCalc Logo Icon
StoreFeaturesDocsShareSupportNewsAboutPoliciesSign UpSign In
sagemathinc
GitHub Repository: sagemathinc/cocalc
Path: blob/master/src/packages/frontend/course/nbgrader/scores.tsx
5997 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
/*
7
Component that shows all the scores for all problems and notebooks in a given assignment.
8
*/
9
10
import { Alert, Button, Card } from "antd";
11
import { Icon } from "@cocalc/frontend/components";
12
import { useActions } from "@cocalc/frontend/app-framework";
13
import { ReactNode, useState } from "react";
14
import {
15
NotebookScores,
16
Score,
17
} from "@cocalc/frontend/jupyter/nbgrader/autograde";
18
import { get_nbgrader_score } from "../store";
19
import { CourseActions } from "../actions";
20
import { autograded_filename } from "../util";
21
22
interface Props {
23
nbgrader_scores: { [ipynb: string]: NotebookScores | string };
24
nbgrader_score_ids?: { [ipynb: string]: string[] };
25
assignment_id: string;
26
student_id: string;
27
name: string;
28
show_all?: boolean;
29
set_show_all?: () => void;
30
run_button?: ReactNode;
31
buttonSize?: "small" | "middle" | "large";
32
}
33
34
interface State {
35
filename?: string;
36
id?: string;
37
}
38
39
export function NbgraderScores({
40
nbgrader_scores,
41
nbgrader_score_ids,
42
assignment_id,
43
student_id,
44
name,
45
show_all,
46
set_show_all,
47
run_button,
48
buttonSize,
49
}: Props) {
50
const actions = useActions<CourseActions>({ name });
51
52
const [editingScore, setEditingScore] = useState<State>({});
53
54
function render_show_all() {
55
if (!show_all) return;
56
const v: React.JSX.Element[] = [];
57
for (const filename in nbgrader_scores) {
58
v.push(render_info_for_file(filename, nbgrader_scores[filename]));
59
}
60
return <div>{v}</div>;
61
}
62
63
function render_info_for_file(
64
filename: string,
65
scores: NotebookScores | string,
66
) {
67
return (
68
<div key={filename} style={{ marginBottom: "5px" }}>
69
{render_filename_links(filename)}
70
{render_scores_for_file(filename, scores)}
71
</div>
72
);
73
}
74
75
function open_filename(filename: string): void {
76
actions.assignments.open_file_in_collected_assignment(
77
assignment_id,
78
student_id,
79
filename,
80
);
81
}
82
83
function render_filename_links(filename: string) {
84
const filename2 = autograded_filename(filename);
85
return (
86
<div style={{ fontSize: "12px" }}>
87
<a
88
style={{ fontFamily: "monospace" }}
89
onClick={() => open_filename(filename)}
90
>
91
{filename}
92
</a>
93
<br />
94
<a
95
style={{ fontFamily: "monospace" }}
96
onClick={() => open_filename(filename2)}
97
>
98
{filename2}
99
</a>
100
</div>
101
);
102
}
103
104
function render_scores_for_file(
105
filename: string,
106
scores: NotebookScores | string,
107
) {
108
if (typeof scores == "string") {
109
return (
110
<Alert
111
type="error"
112
message={scores + "\n- try running nbgrader again."}
113
/>
114
);
115
}
116
const v: React.JSX.Element[] = [];
117
118
const ids: string[] = nbgrader_score_ids?.[filename] ?? [];
119
for (const id in scores) {
120
if (!ids.includes(id)) {
121
ids.push(id);
122
}
123
}
124
125
for (const id of ids) {
126
if (scores[id] != null) {
127
v.push(render_score(filename, id, scores[id]));
128
}
129
}
130
131
const style = { padding: "5px" };
132
return (
133
<table
134
style={{
135
border: "1px solid lightgray",
136
width: "100%",
137
borderRadius: "3px",
138
borderCollapse: "collapse",
139
}}
140
>
141
<thead>
142
<tr key={"header"} style={{ border: "1px solid grey" }}>
143
<th style={style}>Problem</th>
144
<th style={style}>Score</th>
145
</tr>
146
</thead>
147
<tbody>{v}</tbody>
148
</table>
149
);
150
}
151
152
function set_score(filename: string, id: string, value: string): void {
153
const score = parseFloat(value);
154
if (isNaN(score) || !isFinite(score)) {
155
return; // invalid scores gets thrown away
156
}
157
actions.assignments.set_specific_nbgrader_score(
158
assignment_id,
159
student_id,
160
filename,
161
id,
162
score,
163
true,
164
);
165
}
166
167
function render_assigned_score(filename: string, id: string, score: Score) {
168
if (!score.manual) {
169
return <>{score.score ?? "?"}</>;
170
}
171
172
const value = `${score.score != null ? score.score : ""}`;
173
const style = {
174
width: "48px",
175
color: "#666",
176
fontSize: "14px",
177
border: "1px solid lightgrey",
178
display: "inline-block",
179
padding: "1px",
180
};
181
if (editingScore.filename == filename && editingScore.id == id) {
182
return (
183
<input
184
spellCheck={false}
185
autoFocus
186
type="input"
187
defaultValue={value}
188
onBlur={(e) => stop_editing_score((e.target as any).value)}
189
style={style}
190
/>
191
);
192
} else {
193
return (
194
<span style={style} onClick={() => setEditingScore({ filename, id })}>
195
{value ? value : "-"}
196
</span>
197
);
198
}
199
}
200
201
function stop_editing_score(value: string): void {
202
if (editingScore.id != null && editingScore.filename != null) {
203
set_score(editingScore.filename, editingScore.id, value);
204
}
205
setEditingScore({
206
filename: undefined,
207
id: undefined,
208
});
209
}
210
211
function render_score(filename: string, id: string, score: Score) {
212
const backgroundColor = score.score == null ? "#fff1f0" : undefined;
213
const style = { padding: "5px", backgroundColor };
214
return (
215
<tr key={id}>
216
<td style={style}>{id}</td>
217
<td style={style}>
218
{render_assigned_score(filename, id, score)} / {score.points}
219
{render_needs_score(score)}
220
</td>
221
</tr>
222
);
223
}
224
225
function render_needs_score(score: Score) {
226
if (!score.manual || score.score != null) return;
227
return (
228
<div>
229
<Icon name="exclamation-triangle" /> Enter score above
230
</div>
231
);
232
}
233
234
function render_more_toggle({
235
hasScores,
236
manual_needed,
237
error,
238
buttonSize,
239
}: {
240
hasScores: boolean;
241
manual_needed: boolean;
242
error: boolean;
243
buttonSize?: "small" | "middle" | "large";
244
}) {
245
if (!hasScores) return undefined;
246
const showManual = manual_needed || error;
247
const editButton = (
248
<Button
249
size={buttonSize}
250
icon={<Icon name="pencil" />}
251
aria-label="Edit nbgrader scores"
252
onClick={() => set_show_all?.()}
253
/>
254
);
255
return (
256
<span style={{ display: "inline-flex", alignItems: "center", gap: 4 }}>
257
{showManual ? <span>Manual</span> : null}
258
{editButton}
259
</span>
260
);
261
}
262
263
function render_title(content: string) {
264
return (
265
<span>
266
<b>nbgrader:</b> {content}
267
</span>
268
);
269
}
270
271
const { score, points, error = false, manual_needed } =
272
get_nbgrader_score(nbgrader_scores);
273
274
const hasScores = Object.keys(nbgrader_scores ?? {}).length > 0;
275
const action_required: boolean = !!(
276
!show_all &&
277
(manual_needed || error || !hasScores)
278
);
279
280
const backgroundColor = action_required ? "#fff1f0" : undefined;
281
282
const titleContent = hasScores ? (error ? "error" : `${score}/${points}`) : "--/--";
283
284
const titleWithRun =
285
run_button == null ? (
286
render_title(titleContent)
287
) : (
288
<div style={{ display: "flex", alignItems: "center", gap: "8px" }}>
289
{run_button}
290
{render_title(titleContent)}
291
</div>
292
);
293
294
return (
295
<Card
296
size="small"
297
style={{ marginTop: "5px", backgroundColor }}
298
extra={render_more_toggle({
299
hasScores,
300
manual_needed,
301
error,
302
buttonSize,
303
})}
304
title={titleWithRun}
305
styles={{ body: show_all ? {} : { padding: 0 } }}
306
>
307
{render_show_all()}
308
</Card>
309
);
310
}
311
312