Book a Demo!
CoCalc Logo Icon
StoreFeaturesDocsShareSupportNewsAboutPoliciesSign UpSign In
sagemathinc
GitHub Repository: sagemathinc/cocalc
Path: blob/master/src/packages/frontend/course/actions.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
6
// CoCalc libraries
7
import { SyncDB } from "@cocalc/sync/editor/db/sync";
8
import { SyncDBRecord } from "./types";
9
// Course Library
10
import {
11
CourseState,
12
CourseStore,
13
AssignmentRecord,
14
StudentRecord,
15
HandoutRecord,
16
} from "./store";
17
import { SharedProjectActions } from "./shared-project/actions";
18
import { ActivityActions } from "./activity/actions";
19
import { StudentsActions } from "./students/actions";
20
import { ComputeActions } from "./compute/actions";
21
import { StudentProjectsActions } from "./student-projects/actions";
22
import { AssignmentsActions } from "./assignments/actions";
23
import { HandoutsActions } from "./handouts/actions";
24
import { ConfigurationActions } from "./configuration/actions";
25
import { ExportActions } from "./export/actions";
26
import { ProjectsStore } from "../projects/store";
27
import { bind_methods } from "@cocalc/util/misc";
28
// React libraries
29
import { Actions, TypedMap } from "../app-framework";
30
import { Map as iMap } from "immutable";
31
32
export const primary_key = {
33
students: "student_id",
34
assignments: "assignment_id",
35
handouts: "handout_id",
36
};
37
38
// Requires a syncdb to be set later
39
// Manages local and sync changes
40
export class CourseActions extends Actions<CourseState> {
41
public syncdb: SyncDB;
42
private last_collaborator_state: any;
43
private activity: ActivityActions;
44
public students: StudentsActions;
45
public compute: ComputeActions;
46
public student_projects: StudentProjectsActions;
47
public shared_project: SharedProjectActions;
48
public assignments: AssignmentsActions;
49
public handouts: HandoutsActions;
50
public configuration: ConfigurationActions;
51
public export: ExportActions;
52
private state: "init" | "ready" | "closed" = "init";
53
54
constructor(name, redux) {
55
super(name, redux);
56
if (this.name == null || this.redux == null) {
57
throw Error("BUG: name and redux must be defined");
58
}
59
60
this.shared_project = bind_methods(new SharedProjectActions(this));
61
this.activity = bind_methods(new ActivityActions(this));
62
this.students = bind_methods(new StudentsActions(this));
63
this.compute = new ComputeActions(this);
64
this.student_projects = bind_methods(new StudentProjectsActions(this));
65
this.assignments = bind_methods(new AssignmentsActions(this));
66
this.handouts = bind_methods(new HandoutsActions(this));
67
this.configuration = bind_methods(new ConfigurationActions(this));
68
this.export = bind_methods(new ExportActions(this));
69
}
70
71
get_store = (): CourseStore => {
72
const store = this.redux.getStore<CourseState, CourseStore>(this.name);
73
if (store == null) throw Error("store is null");
74
if (!this.store_is_initialized())
75
throw Error("course store must be initialized");
76
this.state = "ready"; // this is pretty dumb for now.
77
return store;
78
};
79
80
is_closed = (): boolean => {
81
if (this.state == "closed") return true;
82
const store = this.redux.getStore<CourseState, CourseStore>(this.name);
83
if (store == null) {
84
this.state = "closed";
85
return true;
86
}
87
return false;
88
};
89
90
private is_loaded = (): boolean => {
91
if (this.syncdb == null) {
92
this.set_error("attempt to set syncdb before loading");
93
return false;
94
}
95
return true;
96
};
97
98
private store_is_initialized = (): boolean => {
99
const store = this.redux.getStore<CourseState, CourseStore>(this.name);
100
if (store == null) {
101
return false;
102
}
103
if (
104
!(
105
store.get("students") != null &&
106
store.get("assignments") != null &&
107
store.get("settings") != null &&
108
store.get("handouts") != null
109
)
110
) {
111
return false;
112
}
113
return true;
114
};
115
116
// Set one object in the syncdb
117
set = (
118
obj: SyncDBRecord,
119
commit: boolean = true,
120
emitChangeImmediately: boolean = false,
121
): void => {
122
if (
123
!this.is_loaded() ||
124
(this.syncdb != null ? this.syncdb.get_state() === "closed" : undefined)
125
) {
126
return;
127
}
128
this.syncdb.set(obj);
129
if (commit) {
130
this.syncdb.commit(emitChangeImmediately);
131
}
132
};
133
134
delete = (obj: SyncDBRecord, commit: boolean = true): void => {
135
if (
136
!this.is_loaded() ||
137
(this.syncdb != null ? this.syncdb.get_state() === "closed" : undefined)
138
) {
139
return;
140
}
141
// put in similar checks for other tables?
142
if (obj.table == "students" && obj.student_id == null) {
143
console.warn("course: deleting student without primary key", obj);
144
}
145
this.syncdb.delete(obj);
146
if (commit) {
147
this.syncdb.commit();
148
}
149
};
150
151
// Get one object from this.syncdb as a Javascript object (or undefined)
152
get_one = (obj: SyncDBRecord): SyncDBRecord | undefined => {
153
if (
154
this.syncdb != null ? this.syncdb.get_state() === "closed" : undefined
155
) {
156
return;
157
}
158
const x: any = this.syncdb.get_one(obj);
159
if (x == null) return;
160
return x.toJS();
161
};
162
163
save = async (): Promise<void> => {
164
const store = this.get_store();
165
if (store == null) {
166
return;
167
} // e.g., if the course store object already gone due to closing course.
168
if (store.get("saving")) {
169
return; // already saving
170
}
171
const id = this.set_activity({ desc: "Saving..." });
172
this.setState({ saving: true });
173
try {
174
await this.syncdb.save_to_disk();
175
this.setState({ show_save_button: false });
176
} catch (err) {
177
this.set_error(`Error saving -- ${err}`);
178
this.setState({ show_save_button: true });
179
return;
180
} finally {
181
this.clear_activity(id);
182
this.setState({ saving: false });
183
this.update_unsaved_changes();
184
setTimeout(this.update_unsaved_changes.bind(this), 1000);
185
}
186
};
187
188
syncdb_change = (changes: TypedMap<SyncDBRecord>[]): void => {
189
let t;
190
const store = this.get_store();
191
if (store == null) {
192
return;
193
}
194
const cur = (t = store.getState());
195
changes.map((obj) => {
196
const table = obj.get("table");
197
if (table == null) {
198
// no idea what to do with something that doesn't have table defined
199
return;
200
}
201
const x = this.syncdb.get_one(obj);
202
const key = primary_key[table];
203
if (x == null) {
204
// delete
205
if (key != null) {
206
t = t.set(table, t.get(table).delete(obj.get(key)));
207
}
208
} else {
209
// edit or insert
210
if (key != null) {
211
t = t.set(table, t.get(table).set(x.get(key), x));
212
} else if (table === "settings") {
213
t = t.set(table, t.get(table).merge(x.delete("table")));
214
} else {
215
// no idea what to do with this
216
console.warn(`unknown table '${table}'`);
217
}
218
}
219
}); // ensure map doesn't terminate
220
221
if (!cur.equals(t)) {
222
// something definitely changed
223
this.setState(t);
224
}
225
this.update_unsaved_changes();
226
};
227
228
private update_unsaved_changes = (): void => {
229
if (this.syncdb == null) {
230
return;
231
}
232
const unsaved = this.syncdb.has_unsaved_changes();
233
this.setState({ unsaved });
234
};
235
236
// important that this be bound...
237
handle_projects_store_update = (projects_store: ProjectsStore): void => {
238
const store = this.redux.getStore<CourseState, CourseStore>(this.name);
239
if (store == null) return; // not needed yet.
240
let users = projects_store.getIn([
241
"project_map",
242
store.get("course_project_id"),
243
"users",
244
]);
245
if (users == null) return;
246
users = users.keySeq();
247
if (this.last_collaborator_state == null) {
248
this.last_collaborator_state = users;
249
return;
250
}
251
if (!this.last_collaborator_state.equals(users)) {
252
this.student_projects.configure_all_projects();
253
}
254
this.last_collaborator_state = users;
255
};
256
257
// Set the error. Use error="" to explicitly clear the existing set error.
258
// If there is an error already set, then the new error is just
259
// appended to the existing one.
260
set_error = (error: string): void => {
261
if (error != "") {
262
const store = this.get_store();
263
if (store == null) return;
264
if (store.get("error")) {
265
error = `${store.get("error")} \n${error}`;
266
}
267
error = error.trim();
268
}
269
this.setState({ error });
270
};
271
272
// ACTIVITY ACTIONS
273
set_activity = (
274
opts: { id: number; desc?: string } | { id?: number; desc: string },
275
): number => {
276
return this.activity.set_activity(opts);
277
};
278
279
clear_activity = (id?: number): void => {
280
this.activity.clear_activity(id);
281
};
282
283
// CONFIGURATION ACTIONS
284
// These hang off of this.configuration
285
286
// SHARED PROJECT ACTIONS
287
// These hang off of this.shared_project
288
289
// STUDENTS ACTIONS
290
// These hang off of this.students
291
292
// STUDENT PROJECTS ACTIONS
293
// These all hang off of this.student_projects now.
294
295
// ASSIGNMENT ACTIONS
296
// These all hang off of this.assignments now.
297
298
// HANDOUT ACTIONS
299
// These all hang off of this.handouts now.
300
301
// UTILITY FUNCTIONS
302
303
/* Utility function that makes getting student/assignment/handout
304
object associated to an id cleaner, since we do this a LOT in
305
our code, and there was a lot of code duplication as a result.
306
If something goes wrong and the finish function is defined, then
307
it is called with a string describing the error.
308
*/
309
resolve = (opts: {
310
assignment_id?: string;
311
student_id?: string;
312
handout_id?: string;
313
finish?: Function;
314
}) => {
315
const r: {
316
student?: StudentRecord;
317
assignment?: AssignmentRecord;
318
handout?: HandoutRecord;
319
store: CourseStore;
320
} = { store: this.get_store() };
321
322
if (opts.student_id) {
323
const student = this.syncdb?.get_one({
324
table: "students",
325
student_id: opts.student_id,
326
}) as StudentRecord | undefined;
327
if (student == null) {
328
if (opts.finish != null) {
329
console.trace();
330
opts.finish("no student " + opts.student_id);
331
return r;
332
}
333
} else {
334
r.student = student;
335
}
336
}
337
if (opts.assignment_id) {
338
const assignment = this.syncdb?.get_one({
339
table: "assignments",
340
assignment_id: opts.assignment_id,
341
}) as AssignmentRecord | undefined;
342
if (assignment == null) {
343
if (opts.finish != null) {
344
opts.finish("no assignment " + opts.assignment_id);
345
return r;
346
}
347
} else {
348
r.assignment = assignment;
349
}
350
}
351
if (opts.handout_id) {
352
const handout = this.syncdb?.get_one({
353
table: "handouts",
354
handout_id: opts.handout_id,
355
}) as HandoutRecord | undefined;
356
if (handout == null) {
357
if (opts.finish != null) {
358
opts.finish("no handout " + opts.handout_id);
359
return r;
360
}
361
} else {
362
r.handout = handout;
363
}
364
}
365
return r;
366
};
367
368
// Takes an item_name and the id of the time
369
// item_name should be one of
370
// ['student', 'assignment', 'peer_config', handout', 'skip_grading']
371
toggle_item_expansion = (
372
item_name:
373
| "student"
374
| "assignment"
375
| "peer_config"
376
| "handout"
377
| "skip_grading",
378
item_id,
379
): void => {
380
let adjusted;
381
const store = this.get_store();
382
if (store == null) {
383
return;
384
}
385
const field_name: any = `expanded_${item_name}s`;
386
const expanded_items = store.get(field_name);
387
if (expanded_items.has(item_id)) {
388
adjusted = expanded_items.delete(item_id);
389
} else {
390
adjusted = expanded_items.add(item_id);
391
if (item_name == "assignment") {
392
// for assignments, whenever show more details also update the directory listing,
393
// since various things that get rendered in the expanded view depend on an updated listing.
394
this.assignments.update_listing(item_id);
395
}
396
}
397
this.setState({ [field_name]: adjusted });
398
};
399
400
setPageFilter = (page: string, filter: string) => {
401
const store = this.get_store();
402
if (!store) return;
403
let pageFilter = store.get("pageFilter");
404
if (pageFilter == null) {
405
if (filter) {
406
pageFilter = iMap({ [page]: filter });
407
this.setState({
408
pageFilter,
409
});
410
}
411
return;
412
}
413
pageFilter = pageFilter.set(page, filter);
414
this.setState({ pageFilter });
415
};
416
}
417
418