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