CoCalc Logo Icon
StoreFeaturesDocsShareSupportNewsAboutSign UpSign In
sagemathinc

Real-time collaboration for Jupyter Notebooks, Linux Terminals, LaTeX, VS Code, R IDE, and more,
all in one place.

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