Book a Demo!
CoCalc Logo Icon
StoreFeaturesDocsShareSupportNewsAboutPoliciesSign UpSign In
sagemathinc
GitHub Repository: sagemathinc/cocalc
Path: blob/master/src/packages/frontend/course/util.ts
5716 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 type { IconName } from "@cocalc/frontend/components/icon";
7
import type { SizeType } from "antd/lib/config-provider/SizeContext";
8
9
import { Map } from "immutable";
10
import { IntlShape } from "react-intl";
11
12
import {
13
TypedMap,
14
useEffect,
15
useState,
16
useWindowDimensions,
17
} from "@cocalc/frontend/app-framework";
18
import { labels } from "@cocalc/frontend/i18n";
19
import { KUCALC_COCALC_COM } from "@cocalc/util/db-schema/site-defaults";
20
import {
21
cmp,
22
defaults,
23
merge,
24
required,
25
search_match,
26
search_split,
27
separate_file_extension,
28
} from "@cocalc/util/misc";
29
import { ProjectsStore } from "../projects/store";
30
import { UserMap } from "../todo-types";
31
import { StudentsMap } from "./store";
32
import { AssignmentCopyStep } from "./types";
33
34
// Pure functions used in the course manager
35
export function STEPS(peer: boolean): AssignmentCopyStep[] {
36
if (peer) {
37
return [
38
"assignment",
39
"collect",
40
"peer_assignment",
41
"peer_collect",
42
"return_graded",
43
];
44
} else {
45
return ["assignment", "collect", "return_graded"];
46
}
47
}
48
49
export function previous_step(
50
step: AssignmentCopyStep,
51
peer: boolean,
52
): AssignmentCopyStep {
53
let prev: AssignmentCopyStep | undefined;
54
for (const s of STEPS(peer)) {
55
if (step === s) {
56
if (prev === undefined) break;
57
return prev;
58
}
59
prev = s;
60
}
61
throw Error(`BUG! previous_step('${step}, ${peer}')`);
62
}
63
64
export function step_direction(step: AssignmentCopyStep): "to" | "from" {
65
switch (step) {
66
case "assignment":
67
return "to";
68
case "collect":
69
return "from";
70
case "return_graded":
71
return "to";
72
case "peer_assignment":
73
return "to";
74
case "peer_collect":
75
return "from";
76
default:
77
throw Error(`BUG! step_direction('${step}')`);
78
}
79
}
80
81
export function step_verb(step: AssignmentCopyStep) {
82
switch (step) {
83
case "assignment":
84
return "assign";
85
case "collect":
86
return "collect";
87
case "return_graded":
88
return "return";
89
case "peer_assignment":
90
return "assign";
91
case "peer_collect":
92
return "collect";
93
default:
94
throw Error(`BUG! step_verb('${step}')`);
95
}
96
}
97
98
export function step_ready(step: AssignmentCopyStep, n) {
99
switch (step) {
100
case "assignment":
101
return "";
102
case "collect":
103
if (n > 1) {
104
return " who have already received it";
105
} else {
106
return " who has already received it";
107
}
108
case "return_graded":
109
return " whose work you have graded";
110
case "peer_assignment":
111
return " for peer grading";
112
case "peer_collect":
113
return " who should have peer graded it";
114
}
115
}
116
117
// Takes a student immutable.Map with key 'student_id'
118
// Returns a list of students `x` shaped like:
119
// {
120
// first_name : string
121
// last_name : string
122
// last_active : integer
123
// hosting : string
124
// email_address : string
125
// }
126
export function parse_students(
127
student_map: StudentsMap,
128
user_map: UserMap,
129
redux,
130
intl?: IntlShape,
131
) {
132
const v = immutable_to_list(student_map, "student_id");
133
for (const x of v) {
134
if (x.account_id != null) {
135
const user = user_map.get(x.account_id);
136
if (x.first_name == null) {
137
x.first_name = user == null ? "" : user.get("first_name", "");
138
}
139
if (x.last_name == null) {
140
x.last_name = user == null ? "" : user.get("last_name", "");
141
}
142
if (x.project_id != null) {
143
const projects_store = redux.getStore("projects");
144
if (projects_store != null) {
145
const last_active = projects_store.get_last_active(x.project_id);
146
if (last_active != null) {
147
x.last_active = last_active.get(x.account_id);
148
}
149
}
150
}
151
}
152
if (intl != null) {
153
const { description, state } = projectStatus(x.project_id, redux, intl);
154
x.hosting = description + state;
155
}
156
157
if (x.first_name == null) {
158
x.first_name = "";
159
}
160
if (x.last_name == null) {
161
x.last_name = "";
162
}
163
if (x.last_active == null) {
164
x.last_active = 0;
165
}
166
if (x.email_address == null) {
167
x.email_address = "";
168
}
169
}
170
return v;
171
}
172
173
// Transforms Iterable<K, M<i, m>> to [M<i + primary_key, m + K>] where primary_key maps to K
174
// Dunno if either of these is readable...
175
// Turns Map(Keys -> Objects{...}) into [Objects{primary_key : Key, ...}]
176
// TODO: Type return array better
177
export function immutable_to_list(x: undefined): undefined;
178
export function immutable_to_list<T, P>(
179
x: Map<string, T>,
180
primary_key: P,
181
): T extends TypedMap<infer S>
182
? S[]
183
: T extends Map<string, infer S>
184
? S[]
185
: any;
186
export function immutable_to_list(x: any, primary_key?): any {
187
if (x == null || x == undefined) {
188
return;
189
}
190
const v: any[] = [];
191
x.map((val, key) => v.push(merge(val.toJS(), { [primary_key]: key })));
192
return v;
193
}
194
195
// Returns a list of matched objects and the number of objects
196
// which were in the original list but omitted in the returned list
197
export function compute_match_list(opts: {
198
list: any[];
199
search_key: string;
200
search: string;
201
}) {
202
opts = defaults(opts, {
203
list: required, // list of objects<M>
204
search_key: required, // M.search_key property to match over
205
search: required, // matches to M.search_key
206
});
207
let { list, search, search_key } = opts;
208
if (!search) {
209
// why are you even calling this..
210
return { list, num_omitted: 0 };
211
}
212
213
const words = search_split(search);
214
const matches = (x) =>
215
search_match(x[search_key]?.toLowerCase?.() ?? "", words);
216
const n = list.length;
217
list = list.filter(matches);
218
const num_omitted = n - list.length;
219
return { list, num_omitted };
220
}
221
222
// Returns
223
// `list` partitioned into [not deleted, deleted]
224
// where each partition is sorted based on the given `compare_function`
225
// deleted is not included by default
226
export function order_list<T extends { deleted: boolean }>(opts: {
227
list: T[];
228
compare_function: (a: T, b: T) => number;
229
reverse: boolean;
230
include_deleted: boolean;
231
}) {
232
opts = defaults(opts, {
233
list: required,
234
compare_function: required,
235
reverse: false,
236
include_deleted: false,
237
});
238
let { list, compare_function, include_deleted } = opts;
239
240
const x = list.filter((x) => x.deleted);
241
const sorted_deleted = x.sort(compare_function);
242
243
const y = list.filter((x) => !x.deleted);
244
list = y.sort(compare_function);
245
246
if (opts.reverse) {
247
list.reverse();
248
}
249
250
if (include_deleted) {
251
list = list.concat(sorted_deleted);
252
}
253
254
return { list, deleted: x, num_deleted: sorted_deleted.length };
255
}
256
257
const cmp_strings = (a, b, field) => {
258
return cmp(a[field]?.toLowerCase() ?? "", b[field]?.toLowerCase() ?? "");
259
};
260
261
// first sort by domain, then address at that domain... since there will be many students
262
// at same domain, and '[email protected]' > '[email protected]' > '[email protected]' is true but not helpful
263
const cmp_email = (a, b) => {
264
const v = a.split("@");
265
const w = b.split("@");
266
const c = cmp(v[1], w[1]);
267
if (c) {
268
return c;
269
}
270
return cmp(v[0], w[0]);
271
};
272
273
const sort_on_string_field = (field, field2) => (a, b) => {
274
const c =
275
field == "email_address"
276
? cmp_email(a[field], b[field])
277
: cmp_strings(a, b, field);
278
return c != 0 ? c : cmp_strings(a, b, field2);
279
};
280
281
const sort_on_numerical_field = (field, field2) => (a, b) => {
282
const c = cmp((a[field] ?? 0) * -1, (b[field] ?? 0) * -1);
283
return c != 0 ? c : cmp_strings(a, b, field2);
284
};
285
286
type StudentField =
287
| "email"
288
| "first_name"
289
| "last_name"
290
| "last_active"
291
| "hosting";
292
293
export function pick_student_sorter({
294
column_name,
295
is_descending,
296
}: {
297
column_name: StudentField;
298
is_descending?: boolean;
299
}) {
300
const cmp = getSorter(column_name);
301
if (is_descending) {
302
return (a, b) => cmp(b, a);
303
}
304
return cmp;
305
}
306
307
function getSorter(column_name) {
308
switch (column_name) {
309
case "email":
310
return sort_on_string_field("email_address", "last_name");
311
case "first_name":
312
return sort_on_string_field("first_name", "last_name");
313
case "last_active":
314
return sort_on_numerical_field("last_active", "last_name");
315
case "hosting":
316
return sort_on_string_field("hosting", "email_address");
317
case "last_name":
318
default:
319
return sort_on_string_field("last_name", "first_name");
320
}
321
}
322
323
export function assignment_identifier(
324
assignment_id: string,
325
student_id: string,
326
): string {
327
return assignment_id + student_id;
328
}
329
330
export function autograded_filename(filename: string): string {
331
const { name, ext } = separate_file_extension(filename);
332
return name + "_autograded." + ext;
333
}
334
335
interface ProjectStatus {
336
description: string;
337
icon: IconName;
338
state: string;
339
tip?: string;
340
}
341
342
export function projectStatus(
343
project_id: string | undefined,
344
redux,
345
intl: IntlShape,
346
): ProjectStatus {
347
if (!project_id) {
348
return { description: "(not created)", icon: "hourglass-half", state: "" };
349
}
350
const store = redux.getStore("projects");
351
const state = ` (${store.get_state(project_id)})`;
352
const kucalc = redux.getStore("customize").get("kucalc");
353
if (kucalc === KUCALC_COCALC_COM) {
354
return projectStatusCoCalcCom({ project_id, state, store, intl });
355
} else {
356
const tip = intl.formatMessage({
357
id: "course.util.project_status.ready",
358
defaultMessage: "Project exists and is ready.",
359
});
360
return {
361
icon: "exclamation-triangle",
362
description: intl.formatMessage(labels.ready),
363
tip,
364
state,
365
};
366
}
367
}
368
369
function projectStatusCoCalcCom({
370
project_id,
371
state,
372
store,
373
intl,
374
}: {
375
project_id: string;
376
state: string;
377
store: ProjectsStore;
378
intl: IntlShape;
379
}): ProjectStatus {
380
const upgrades = store.get_total_project_quotas(project_id);
381
if (upgrades == null) {
382
// user opening the course, but isn't a collaborator on
383
// this student project for some reason. This will get fixed
384
// when configure all projects runs.
385
const description = intl.formatMessage({
386
id: "course.util.status-cocalc-com.project_not_available",
387
defaultMessage: "(not available)",
388
});
389
return {
390
description,
391
icon: "question-circle",
392
state: "",
393
};
394
}
395
396
if (upgrades.member_host) {
397
return {
398
icon: "check",
399
description: "Members-only hosting",
400
tip: "Projects is on a members-only server, which is much more robust and has priority support.",
401
state,
402
};
403
}
404
const licenses = store.get_site_license_ids(project_id);
405
if (licenses.length > 0) {
406
const description = intl.formatMessage({
407
id: "course.util.status-cocalc-com.licensed.description",
408
defaultMessage: "Licensed",
409
});
410
const tip = intl.formatMessage({
411
id: "course.util.status-cocalc-com.licensed.tooltip",
412
defaultMessage:
413
"Project is properly licensed and should work well. Thank you!",
414
});
415
return { description, icon: "check", state, tip };
416
} else {
417
const description = intl.formatMessage({
418
id: "course.util.status-cocalc-com.free.description",
419
defaultMessage: "Free Trial",
420
});
421
const tip = intl.formatMessage({
422
id: "course.util.status-cocalc-com.free.tooltip",
423
defaultMessage: `Project is a trial project hosted on a free server,
424
so it may be overloaded and will be rebooted frequently.
425
Please upgrade in course configuration.`,
426
});
427
return {
428
description,
429
icon: "exclamation-triangle",
430
state,
431
tip,
432
};
433
}
434
}
435
436
// the list of assignments, in particular with peer grading, has a large number of buttons
437
// in a single row. We mitigate this by rendering the buttons smaller if the screen is narrower.
438
export function useButtonSize(): SizeType {
439
const [size, setSize] = useState<SizeType>("small");
440
const { width } = useWindowDimensions();
441
useEffect(() => {
442
const next = width < 1024 ? "small" : "middle";
443
if (next != size) {
444
setSize(next);
445
}
446
});
447
return size;
448
}
449
450