Path: blob/master/src/packages/frontend/app-framework/index.ts
5759 views
/*1* This file is part of CoCalc: Copyright © 2020 Sagemath, Inc.2* License: MS-RSL – see LICENSE.md for details3*/45// Not sure where this should go...6declare global {7interface Window {8Primus: any;9}10}1112// Important: code below now assumes that a global variable called "DEBUG" is **defined**!13declare var DEBUG: boolean;14if (DEBUG == null) {15var DEBUG = false;16}1718let rclass: <P extends object>(19Component: React.ComponentType<P>,20) => React.ComponentType<P>;2122import React from "react";23import createReactClass from "create-react-class";24import { Provider, connect, useSelector } from "react-redux";25import json_stable from "json-stable-stringify";2627import { Store } from "@cocalc/util/redux/Store";28import { Actions } from "@cocalc/util/redux/Actions";29import { AppRedux as AppReduxBase } from "@cocalc/util/redux/AppRedux";30import { Table, TableConstructor } from "./Table";3132// Relative import is temporary, until I figure this out -- needed for *project*33import { bind_methods, keys, is_valid_uuid_string } from "@cocalc/util/misc";34export { TypedMap, createTypedMap } from "@cocalc/util/redux/TypedMap";35import type { ClassMap } from "@cocalc/util/redux/types";36import { redux_name, project_redux_name } from "@cocalc/util/redux/name";37export { redux_name, project_redux_name };38import { NAME_TYPE as ComputeImageStoreType } from "../custom-software/util";39import { NEWS } from "@cocalc/frontend/notifications/news/init";4041import * as types from "./actions-and-stores";42import type { ProjectStore } from "../project_store";43import type { ProjectActions } from "../project_actions";44export type { ProjectStore, ProjectActions };4546export class AppRedux extends AppReduxBase {47private _tables: ClassMap<any, Table>;4849constructor() {50super();51bind_methods(this);52this._tables = {};53}5455getActions(name: "account"): types.AccountActions;56getActions(name: "projects"): types.ProjectsActions;57getActions(name: "billing"): types.BillingActions;58getActions(name: "page"): types.PageActions;59getActions(name: "users"): types.UsersActions;60getActions(name: "admin-users"): types.AdminUsersActions;61getActions(name: "admin-site-licenses"): types.SiteLicensesActions;62getActions(name: "mentions"): types.MentionsActions;63getActions(name: "messages"): types.MessagesActions;64getActions(name: "file_use"): types.FileUseActions;65getActions(name: typeof NEWS): types.NewsActions;66getActions(name: { project_id: string }): ProjectActions;67getActions<T, C extends Actions<T>>(name: string): C;68getActions<T, C extends Actions<T>>(69name: string | { project_id: string },70): C | ProjectActions | undefined {71if (typeof name === "string") {72if (!this.hasActions(name)) {73return undefined;74} else {75return this._actions[name];76}77} else {78if (name.project_id == null) {79throw Error("Object must have project_id attribute");80}81return this.getProjectActions(name.project_id);82}83}8485getStore(name: "account"): types.AccountStore;86getStore(name: "projects"): types.ProjectsStore;87getStore(name: "billing"): types.BillingStore;88getStore(name: "page"): types.PageStore;89getStore(name: "admin-users"): types.AdminUsersStore;90getStore(name: "admin-site-licenses"): types.SiteLicensesStore;91getStore(name: "mentions"): types.MentionsStore;92getStore(name: "messages"): types.MessagesStore;93getStore(name: "file_use"): types.FileUseStore;94getStore(name: "customize"): types.CustomizeStore;95getStore(name: "users"): types.UsersStore;96getStore(name: ComputeImageStoreType): types.ComputeImagesStore;97getStore(name: typeof NEWS): types.NewsStore;98getStore<State extends Record<string, any>>(name: string): Store<State>;99getStore<State extends Record<string, any>, C extends Store<State>>(100nam: string,101): C | undefined;102getStore(name) {103return super.getStore(name);104}105106getProjectsStore(): types.ProjectsStore {107return this.getStore("projects");108}109110createTable<T extends Table>(111name: string,112table_class: TableConstructor<T>,113): T {114const tables = this._tables;115if (tables[name] != null) {116throw Error(`createTable: table "${name}" already exists`);117}118const table = new table_class(name, this);119return (tables[name] = table);120}121122// Set the table; we assume that the table being overwritten123// has been cleaned up properly somehow...124setTable(name: string, table: Table): void {125this._tables[name] = table;126}127128removeTable(name: string): void {129if (this._tables[name] != null) {130if (this._tables[name]._table != null) {131this._tables[name]._table.close();132}133delete this._tables[name];134}135}136137getTable<T extends Table>(name: string): T {138if (this._tables[name] == null) {139throw Error(`getTable: table "${name}" not registered`);140}141return this._tables[name];142}143144/**145* A React Hook to connect a function component to a project store.146* Opposed to `getProjectStore`, the project store will not initialize147* if it's not defined already.148*149* @param selectFrom selector to run on the store.150* The result will be compared to the previous result to determine151* if the component should rerender152* @param project_id id of the project to connect to153*/154useProjectStore<T>(155selectFrom: (store?: ProjectStore) => T,156project_id?: string,157): T {158// eslint-disable-next-line react-hooks/rules-of-hooks159return useSelector<any, T>((_) => {160let projectStore = undefined;161if (project_id) {162projectStore = this.getStore(project_redux_name(project_id)) as any;163}164return selectFrom(projectStore);165});166}167168// getProject... is safe to call any time. All structures will be created169// if they don't exist170getProjectStore(project_id: string): ProjectStore {171if (!is_valid_uuid_string(project_id)) {172throw Error(`getProjectStore: INVALID project_id -- "${project_id}"`);173}174if (!this.hasProjectStore(project_id)) {175// Right now importing project_store breaks the share server,176// so we don't yet.177return require("../project_store").init(project_id, this);178} else {179return this.getStore(project_redux_name(project_id)) as any;180}181}182183// TODO -- Typing: Type project Actions184// T, C extends Actions<T>185getProjectActions(project_id: string): ProjectActions {186if (!is_valid_uuid_string(project_id)) {187throw Error(`getProjectActions: INVALID project_id -- "${project_id}"`);188}189if (!this.hasProjectStore(project_id)) {190require("../project_store").init(project_id, this);191}192return this.getActions(project_redux_name(project_id)) as any;193}194// TODO -- Typing: Type project Table195getProjectTable(project_id: string, name: string): any {196if (!is_valid_uuid_string(project_id)) {197throw Error(`getProjectTable: INVALID project_id -- "${project_id}"`);198}199if (!this.hasProjectStore(project_id)) {200require("../project_store").init(project_id, this);201}202return this.getTable(project_redux_name(project_id, name));203}204205removeProjectReferences(project_id: string): void {206if (!is_valid_uuid_string(project_id)) {207throw Error(208`getProjectReferences: INVALID project_id -- "${project_id}"`,209);210}211const name = project_redux_name(project_id);212const store = this.getStore(name);213store?.destroy?.();214this.removeActions(name);215this.removeStore(name);216}217218// getEditorActions but for whatever editor -- this is mainly meant to be used219// from the console when debugging, e.g., smc.redux.currentEditorActions()220public currentEditor = (): {221project_id?: string;222path?: string;223account_id?: string;224actions?: Actions<any>;225store?: Store<any>;226} => {227const project_id = this.getStore("page").get("active_top_tab");228const current: {229project_id?: string;230path?: string;231account_id?: string;232actions?: Actions<any>;233store?: Store<any>;234} = { account_id: this.getStore("account")?.get("account_id") };235if (!is_valid_uuid_string(project_id)) {236return current;237}238current.project_id = project_id;239const store = this.getProjectStore(project_id);240const tab = store.get("active_project_tab");241if (!tab.startsWith("editor-")) {242return current;243}244const path = tab.slice("editor-".length);245current.path = path;246current.actions = this.getEditorActions(project_id, path);247current.store = this.getEditorStore(project_id, path);248return current;249};250}251252const computed = (rtype) => {253const clone = rtype.bind({});254clone.is_computed = true;255return clone;256};257258const rtypes = require("@cocalc/util/opts").types;259260/*261Used by Provider to map app state to component props262263rclass264reduxProps:265store_name :266prop : type267268WARNING: If store not yet defined, then props will all be undefined for that store! There269is no warning/error in this case.270271*/272const connect_component = (spec) => {273const map_state_to_props = function (state) {274const props = {};275if (state == null) {276return props;277}278for (const store_name in spec) {279if (store_name === "undefined") {280// "undefined" gets turned into this string when making a common mistake281console.warn("spec = ", spec);282throw Error(283"WARNING: redux spec is invalid because it contains 'undefined' as a key. " +284JSON.stringify(spec),285);286}287const info = spec[store_name];288const store: Store<any> | undefined = redux.getStore(store_name);289for (const prop in info) {290var val;291const type = info[prop];292293if (type == null) {294throw Error(295`ERROR invalid redux spec: no type info set for prop '${prop}' in store '${store_name}', ` +296`where full spec has keys '${Object.keys(spec)}' ` +297`-- e.g. rtypes.bool vs. rtypes.boolean`,298);299}300301if (store == undefined) {302val = undefined;303} else {304val = store.get(prop);305}306307if (type.category === "IMMUTABLE") {308props[prop] = val;309} else {310props[prop] =311(val != null ? val.toJS : undefined) != null ? val.toJS() : val;312}313}314}315return props;316};317return connect(map_state_to_props);318};319320/*321322Takes an object to create a reactClass or a function which returns such an object.323324Objects should be shaped like a react class save for a few exceptions:325x.reduxProps =326redux_store_name :327fields : value_type328name : type329330x.actions must not be defined.331332*/333334// Uncomment (and also use below) for working on335// https://github.com/sagemathinc/cocalc/issues/4176336/*337function reduxPropsCheck(reduxProps: object) {338for (let store in reduxProps) {339const x = reduxProps[store];340if (x == null) continue;341for (let field in x) {342if (x[field] == rtypes.object) {343console.log(`WARNING: reduxProps object ${store}.${field}`);344}345}346}347}348*/349350function compute_cache_key(data: { [key: string]: any }): string {351return json_stable(keys(data).sort())!;352}353354rclass = function (x: any) {355let C;356if (typeof x === "function" && typeof x.reduxProps === "function") {357// using an ES6 class *and* reduxProps...358C = createReactClass({359render() {360if (this.cache0 == null) {361this.cache0 = {};362}363const reduxProps = x.reduxProps(this.props);364//reduxPropsCheck(reduxProps);365const key = compute_cache_key(reduxProps);366// console.log("ES6 rclass render", key);367if (this.cache0[key] == null) {368this.cache0[key] = connect_component(reduxProps)(x);369}370return React.createElement(371this.cache0[key],372this.props,373this.props.children,374);375},376});377return C;378} else if (typeof x === "function") {379// Creates a react class that wraps the eventual component.380// It calls the generator function with props as a parameter381// and caches the result based on reduxProps382const cached = createReactClass({383// This only caches per Component. No memory leak, but could be faster for multiple components with the same signature384render() {385if (this.cache == null) {386this.cache = {};387}388// OPTIMIZATION: Cache props before generating a new key.389// currently assumes making a new object is fast enough390const definition = x(this.props);391//reduxPropsCheck(definition.reduxProps);392const key = compute_cache_key(definition.reduxProps);393// console.log("function rclass render", key);394395if (definition.actions != null) {396throw Error(397"You may not define a method named actions in an rclass. This is used to expose redux actions",398);399}400401definition.actions = redux.getActions;402403if (this.cache[key] == null) {404this.cache[key] = rclass(definition);405} // wait.. is this even the slow part?406407return React.createElement(408this.cache[key],409this.props,410this.props.children,411);412},413});414415return cached;416} else {417if (x.reduxProps != null) {418// Inject the propTypes based on the ones injected by reduxProps.419const propTypes = x.propTypes != null ? x.propTypes : {};420for (const store_name in x.reduxProps) {421const info = x.reduxProps[store_name];422for (const prop in info) {423const type = info[prop];424if (type !== rtypes.immutable) {425propTypes[prop] = type;426} else {427propTypes[prop] = rtypes.object;428}429}430}431x.propTypes = propTypes;432//reduxPropsCheck(propTypes);433}434435if (x.actions != null && x.actions !== redux.getActions) {436throw Error(437"You may not define a method named actions in an rclass. This is used to expose redux actions",438);439}440441x.actions = redux.getActions;442443C = createReactClass(x);444if (x.reduxProps != null) {445// Make the ones comming from redux get automatically injected, as long446// as this component is in a heierarchy wrapped by <Redux>...</Redux>447C = connect_component(x.reduxProps)(C);448}449}450return C;451};452453const redux = new AppRedux();454455// Public interface456export function is_redux(obj) {457return obj instanceof AppRedux;458}459export function is_redux_actions(obj) {460return obj instanceof Actions;461}462463/*464The non-tsx version of this:465<Provider store={redux.reduxStore}>466{children}467</Provider>468*/469export function Redux({ children }) {470return React.createElement(Provider, {471store: redux.reduxStore,472children,473}) as any;474}475476export const Component = React.Component;477export type Rendered = React.ReactElement<any> | undefined;478export { rclass }; // use rclass to get access to reduxProps support479export { rtypes }; // has extra rtypes.immutable, needed for reduxProps to leave value as immutable480export { computed };481export { React };482export type CSS = React.CSSProperties;483export const { Fragment } = React;484export { redux }; // global redux singleton485export { Actions };486export { Table };487export { Store };488function UNSAFE_NONNULLABLE<T>(arg: T): NonNullable<T> {489return arg as any;490}491export { UNSAFE_NONNULLABLE };492493declare var cc;494if (DEBUG) {495if (typeof cc !== "undefined" && cc !== null) {496cc.redux = redux;497} // for convenience in the browser (mainly for debugging)498}499500/*501Given502spec =503foo :504bar : ...505stuff : ...506foo2 :507other : ...508509the redux_fields function returns ['bar', 'stuff', 'other'].510*/511export function redux_fields(spec) {512const v: any[] = [];513for (let _ in spec) {514const val = spec[_];515for (const key in val) {516_ = val[key];517v.push(key);518}519}520return v;521}522523// Export common React Hooks for convenience:524export * from "./hooks";525export * from "./redux-hooks";526527528