Path: blob/master/src/packages/frontend/course/actions.ts
1503 views
/*1* This file is part of CoCalc: Copyright © 2020 Sagemath, Inc.2* License: MS-RSL – see LICENSE.md for details3*/45// CoCalc libraries6import { SyncDB } from "@cocalc/sync/editor/db/sync";7import { SyncDBRecord } from "./types";8// Course Library9import {10CourseState,11CourseStore,12AssignmentRecord,13StudentRecord,14HandoutRecord,15} from "./store";16import { SharedProjectActions } from "./shared-project/actions";17import { ActivityActions } from "./activity/actions";18import { StudentsActions } from "./students/actions";19import { ComputeActions } from "./compute/actions";20import { StudentProjectsActions } from "./student-projects/actions";21import { AssignmentsActions } from "./assignments/actions";22import { HandoutsActions } from "./handouts/actions";23import { ConfigurationActions } from "./configuration/actions";24import { ExportActions } from "./export/actions";25import { ProjectsStore } from "../projects/store";26import { bind_methods } from "@cocalc/util/misc";27// React libraries28import { Actions, TypedMap } from "../app-framework";29import { Map as iMap } from "immutable";3031export const primary_key = {32students: "student_id",33assignments: "assignment_id",34handouts: "handout_id",35};3637// Requires a syncdb to be set later38// Manages local and sync changes39export class CourseActions extends Actions<CourseState> {40public syncdb: SyncDB;41private last_collaborator_state: any;42private activity: ActivityActions;43public students: StudentsActions;44public compute: ComputeActions;45public student_projects: StudentProjectsActions;46public shared_project: SharedProjectActions;47public assignments: AssignmentsActions;48public handouts: HandoutsActions;49public configuration: ConfigurationActions;50public export: ExportActions;51private state: "init" | "ready" | "closed" = "init";5253constructor(name, redux) {54super(name, redux);55if (this.name == null || this.redux == null) {56throw Error("BUG: name and redux must be defined");57}5859this.shared_project = bind_methods(new SharedProjectActions(this));60this.activity = bind_methods(new ActivityActions(this));61this.students = bind_methods(new StudentsActions(this));62this.compute = new ComputeActions(this);63this.student_projects = bind_methods(new StudentProjectsActions(this));64this.assignments = bind_methods(new AssignmentsActions(this));65this.handouts = bind_methods(new HandoutsActions(this));66this.configuration = bind_methods(new ConfigurationActions(this));67this.export = bind_methods(new ExportActions(this));68}6970get_store = (): CourseStore => {71const store = this.redux.getStore<CourseState, CourseStore>(this.name);72if (store == null) throw Error("store is null");73if (!this.store_is_initialized())74throw Error("course store must be initialized");75this.state = "ready"; // this is pretty dumb for now.76return store;77};7879is_closed = (): boolean => {80if (this.state == "closed") return true;81const store = this.redux.getStore<CourseState, CourseStore>(this.name);82if (store == null) {83this.state = "closed";84return true;85}86return false;87};8889private is_loaded = (): boolean => {90if (this.syncdb == null) {91this.set_error("attempt to set syncdb before loading");92return false;93}94return true;95};9697private store_is_initialized = (): boolean => {98const store = this.redux.getStore<CourseState, CourseStore>(this.name);99if (store == null) {100return false;101}102if (103!(104store.get("students") != null &&105store.get("assignments") != null &&106store.get("settings") != null &&107store.get("handouts") != null108)109) {110return false;111}112return true;113};114115// Set one object in the syncdb116set = (117obj: SyncDBRecord,118commit: boolean = true,119emitChangeImmediately: boolean = false,120): void => {121if (122!this.is_loaded() ||123(this.syncdb != null ? this.syncdb.get_state() === "closed" : undefined)124) {125return;126}127this.syncdb.set(obj);128if (commit) {129this.syncdb.commit(emitChangeImmediately);130}131};132133delete = (obj: SyncDBRecord, commit: boolean = true): void => {134if (135!this.is_loaded() ||136(this.syncdb != null ? this.syncdb.get_state() === "closed" : undefined)137) {138return;139}140// put in similar checks for other tables?141if (obj.table == "students" && obj.student_id == null) {142console.warn("course: deleting student without primary key", obj);143}144this.syncdb.delete(obj);145if (commit) {146this.syncdb.commit();147}148};149150// Get one object from this.syncdb as a Javascript object (or undefined)151get_one = (obj: SyncDBRecord): SyncDBRecord | undefined => {152if (153this.syncdb != null ? this.syncdb.get_state() === "closed" : undefined154) {155return;156}157const x: any = this.syncdb.get_one(obj);158if (x == null) return;159return x.toJS();160};161162save = async (): Promise<void> => {163const store = this.get_store();164if (store == null) {165return;166} // e.g., if the course store object already gone due to closing course.167if (store.get("saving")) {168return; // already saving169}170const id = this.set_activity({ desc: "Saving..." });171this.setState({ saving: true });172try {173await this.syncdb.save_to_disk();174this.setState({ show_save_button: false });175} catch (err) {176this.set_error(`Error saving -- ${err}`);177this.setState({ show_save_button: true });178return;179} finally {180this.clear_activity(id);181this.setState({ saving: false });182this.update_unsaved_changes();183setTimeout(this.update_unsaved_changes.bind(this), 1000);184}185};186187syncdb_change = (changes: TypedMap<SyncDBRecord>[]): void => {188let t;189const store = this.get_store();190if (store == null) {191return;192}193const cur = (t = store.getState());194changes.map((obj) => {195const table = obj.get("table");196if (table == null) {197// no idea what to do with something that doesn't have table defined198return;199}200const x = this.syncdb.get_one(obj);201const key = primary_key[table];202if (x == null) {203// delete204if (key != null) {205t = t.set(table, t.get(table).delete(obj.get(key)));206}207} else {208// edit or insert209if (key != null) {210t = t.set(table, t.get(table).set(x.get(key), x));211} else if (table === "settings") {212t = t.set(table, t.get(table).merge(x.delete("table")));213} else {214// no idea what to do with this215console.warn(`unknown table '${table}'`);216}217}218}); // ensure map doesn't terminate219220if (!cur.equals(t)) {221// something definitely changed222this.setState(t);223}224this.update_unsaved_changes();225};226227private update_unsaved_changes = (): void => {228if (this.syncdb == null) {229return;230}231const unsaved = this.syncdb.has_unsaved_changes();232this.setState({ unsaved });233};234235// important that this be bound...236handle_projects_store_update = (projects_store: ProjectsStore): void => {237const store = this.redux.getStore<CourseState, CourseStore>(this.name);238if (store == null) return; // not needed yet.239let users = projects_store.getIn([240"project_map",241store.get("course_project_id"),242"users",243]);244if (users == null) return;245users = users.keySeq();246if (this.last_collaborator_state == null) {247this.last_collaborator_state = users;248return;249}250if (!this.last_collaborator_state.equals(users)) {251this.student_projects.configure_all_projects();252}253this.last_collaborator_state = users;254};255256// Set the error. Use error="" to explicitly clear the existing set error.257// If there is an error already set, then the new error is just258// appended to the existing one.259set_error = (error: string): void => {260if (error != "") {261const store = this.get_store();262if (store == null) return;263if (store.get("error")) {264error = `${store.get("error")} \n${error}`;265}266error = error.trim();267}268this.setState({ error });269};270271// ACTIVITY ACTIONS272set_activity = (273opts: { id: number; desc?: string } | { id?: number; desc: string },274): number => {275return this.activity.set_activity(opts);276};277278clear_activity = (id?: number): void => {279this.activity.clear_activity(id);280};281282// CONFIGURATION ACTIONS283// These hang off of this.configuration284285// SHARED PROJECT ACTIONS286// These hang off of this.shared_project287288// STUDENTS ACTIONS289// These hang off of this.students290291// STUDENT PROJECTS ACTIONS292// These all hang off of this.student_projects now.293294// ASSIGNMENT ACTIONS295// These all hang off of this.assignments now.296297// HANDOUT ACTIONS298// These all hang off of this.handouts now.299300// UTILITY FUNCTIONS301302/* Utility function that makes getting student/assignment/handout303object associated to an id cleaner, since we do this a LOT in304our code, and there was a lot of code duplication as a result.305If something goes wrong and the finish function is defined, then306it is called with a string describing the error.307*/308resolve = (opts: {309assignment_id?: string;310student_id?: string;311handout_id?: string;312finish?: Function;313}) => {314const r: {315student?: StudentRecord;316assignment?: AssignmentRecord;317handout?: HandoutRecord;318store: CourseStore;319} = { store: this.get_store() };320321if (opts.student_id) {322const student = this.syncdb?.get_one({323table: "students",324student_id: opts.student_id,325}) as StudentRecord | undefined;326if (student == null) {327if (opts.finish != null) {328console.trace();329opts.finish("no student " + opts.student_id);330return r;331}332} else {333r.student = student;334}335}336if (opts.assignment_id) {337const assignment = this.syncdb?.get_one({338table: "assignments",339assignment_id: opts.assignment_id,340}) as AssignmentRecord | undefined;341if (assignment == null) {342if (opts.finish != null) {343opts.finish("no assignment " + opts.assignment_id);344return r;345}346} else {347r.assignment = assignment;348}349}350if (opts.handout_id) {351const handout = this.syncdb?.get_one({352table: "handouts",353handout_id: opts.handout_id,354}) as HandoutRecord | undefined;355if (handout == null) {356if (opts.finish != null) {357opts.finish("no handout " + opts.handout_id);358return r;359}360} else {361r.handout = handout;362}363}364return r;365};366367// Takes an item_name and the id of the time368// item_name should be one of369// ['student', 'assignment', 'peer_config', handout', 'skip_grading']370toggle_item_expansion = (371item_name:372| "student"373| "assignment"374| "peer_config"375| "handout"376| "skip_grading",377item_id,378): void => {379let adjusted;380const store = this.get_store();381if (store == null) {382return;383}384const field_name: any = `expanded_${item_name}s`;385const expanded_items = store.get(field_name);386if (expanded_items.has(item_id)) {387adjusted = expanded_items.delete(item_id);388} else {389adjusted = expanded_items.add(item_id);390if (item_name == "assignment") {391// for assignments, whenever show more details also update the directory listing,392// since various things that get rendered in the expanded view depend on an updated listing.393this.assignments.update_listing(item_id);394}395}396this.setState({ [field_name]: adjusted });397};398399setPageFilter = (page: string, filter: string) => {400const store = this.get_store();401if (!store) return;402let pageFilter = store.get("pageFilter");403if (pageFilter == null) {404if (filter) {405pageFilter = iMap({ [page]: filter });406this.setState({407pageFilter,408});409}410return;411}412pageFilter = pageFilter.set(page, filter);413this.setState({ pageFilter });414};415}416417418