Path: blob/master/src/packages/frontend/app-framework/index.ts
1503 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 {158return useSelector<any, T>((_) => {159let projectStore = undefined;160if (project_id) {161projectStore = this.getStore(project_redux_name(project_id)) as any;162}163return selectFrom(projectStore);164});165}166167// getProject... is safe to call any time. All structures will be created168// if they don't exist169getProjectStore(project_id: string): ProjectStore {170if (!is_valid_uuid_string(project_id)) {171throw Error(`getProjectStore: INVALID project_id -- "${project_id}"`);172}173if (!this.hasProjectStore(project_id)) {174// Right now importing project_store breaks the share server,175// so we don't yet.176return require("../project_store").init(project_id, this);177} else {178return this.getStore(project_redux_name(project_id)) as any;179}180}181182// TODO -- Typing: Type project Actions183// T, C extends Actions<T>184getProjectActions(project_id: string): ProjectActions {185if (!is_valid_uuid_string(project_id)) {186throw Error(`getProjectActions: INVALID project_id -- "${project_id}"`);187}188if (!this.hasProjectStore(project_id)) {189require("../project_store").init(project_id, this);190}191return this.getActions(project_redux_name(project_id)) as any;192}193// TODO -- Typing: Type project Table194getProjectTable(project_id: string, name: string): any {195if (!is_valid_uuid_string(project_id)) {196throw Error(`getProjectTable: INVALID project_id -- "${project_id}"`);197}198if (!this.hasProjectStore(project_id)) {199require("../project_store").init(project_id, this);200}201return this.getTable(project_redux_name(project_id, name));202}203204removeProjectReferences(project_id: string): void {205if (!is_valid_uuid_string(project_id)) {206throw Error(207`getProjectReferences: INVALID project_id -- "${project_id}"`,208);209}210const name = project_redux_name(project_id);211const store = this.getStore(name);212store?.destroy?.();213this.removeActions(name);214this.removeStore(name);215}216217// getEditorActions but for whatever editor -- this is mainly meant to be used218// from the console when debugging, e.g., smc.redux.currentEditorActions()219public currentEditor = (): {220project_id?: string;221path?: string;222account_id?: string;223actions?: Actions<any>;224store?: Store<any>;225} => {226const project_id = this.getStore("page").get("active_top_tab");227const current: {228project_id?: string;229path?: string;230account_id?: string;231actions?: Actions<any>;232store?: Store<any>;233} = { account_id: this.getStore("account")?.get("account_id") };234if (!is_valid_uuid_string(project_id)) {235return current;236}237current.project_id = project_id;238const store = this.getProjectStore(project_id);239const tab = store.get("active_project_tab");240if (!tab.startsWith("editor-")) {241return current;242}243const path = tab.slice("editor-".length);244current.path = path;245current.actions = this.getEditorActions(project_id, path);246current.store = this.getEditorStore(project_id, path);247return current;248};249}250251const computed = (rtype) => {252const clone = rtype.bind({});253clone.is_computed = true;254return clone;255};256257const rtypes = require("@cocalc/util/opts").types;258259/*260Used by Provider to map app state to component props261262rclass263reduxProps:264store_name :265prop : type266267WARNING: If store not yet defined, then props will all be undefined for that store! There268is no warning/error in this case.269270*/271const connect_component = (spec) => {272const map_state_to_props = function (state) {273const props = {};274if (state == null) {275return props;276}277for (const store_name in spec) {278if (store_name === "undefined") {279// "undefined" gets turned into this string when making a common mistake280console.warn("spec = ", spec);281throw Error(282"WARNING: redux spec is invalid because it contains 'undefined' as a key. " +283JSON.stringify(spec),284);285}286const info = spec[store_name];287const store: Store<any> | undefined = redux.getStore(store_name);288for (const prop in info) {289var val;290const type = info[prop];291292if (type == null) {293throw Error(294`ERROR invalid redux spec: no type info set for prop '${prop}' in store '${store_name}', ` +295`where full spec has keys '${Object.keys(spec)}' ` +296`-- e.g. rtypes.bool vs. rtypes.boolean`,297);298}299300if (store == undefined) {301val = undefined;302} else {303val = store.get(prop);304}305306if (type.category === "IMMUTABLE") {307props[prop] = val;308} else {309props[prop] =310(val != null ? val.toJS : undefined) != null ? val.toJS() : val;311}312}313}314return props;315};316return connect(map_state_to_props);317};318319/*320321Takes an object to create a reactClass or a function which returns such an object.322323Objects should be shaped like a react class save for a few exceptions:324x.reduxProps =325redux_store_name :326fields : value_type327name : type328329x.actions must not be defined.330331*/332333// Uncomment (and also use below) for working on334// https://github.com/sagemathinc/cocalc/issues/4176335/*336function reduxPropsCheck(reduxProps: object) {337for (let store in reduxProps) {338const x = reduxProps[store];339if (x == null) continue;340for (let field in x) {341if (x[field] == rtypes.object) {342console.log(`WARNING: reduxProps object ${store}.${field}`);343}344}345}346}347*/348349function compute_cache_key(data: { [key: string]: any }): string {350return json_stable(keys(data).sort())!;351}352353rclass = function (x: any) {354let C;355if (typeof x === "function" && typeof x.reduxProps === "function") {356// using an ES6 class *and* reduxProps...357C = createReactClass({358render() {359if (this.cache0 == null) {360this.cache0 = {};361}362const reduxProps = x.reduxProps(this.props);363//reduxPropsCheck(reduxProps);364const key = compute_cache_key(reduxProps);365// console.log("ES6 rclass render", key);366if (this.cache0[key] == null) {367this.cache0[key] = connect_component(reduxProps)(x);368}369return React.createElement(370this.cache0[key],371this.props,372this.props.children,373);374},375});376return C;377} else if (typeof x === "function") {378// Creates a react class that wraps the eventual component.379// It calls the generator function with props as a parameter380// and caches the result based on reduxProps381const cached = createReactClass({382// This only caches per Component. No memory leak, but could be faster for multiple components with the same signature383render() {384if (this.cache == null) {385this.cache = {};386}387// OPTIMIZATION: Cache props before generating a new key.388// currently assumes making a new object is fast enough389const definition = x(this.props);390//reduxPropsCheck(definition.reduxProps);391const key = compute_cache_key(definition.reduxProps);392// console.log("function rclass render", key);393394if (definition.actions != null) {395throw Error(396"You may not define a method named actions in an rclass. This is used to expose redux actions",397);398}399400definition.actions = redux.getActions;401402if (this.cache[key] == null) {403this.cache[key] = rclass(definition);404} // wait.. is this even the slow part?405406return React.createElement(407this.cache[key],408this.props,409this.props.children,410);411},412});413414return cached;415} else {416if (x.reduxProps != null) {417// Inject the propTypes based on the ones injected by reduxProps.418const propTypes = x.propTypes != null ? x.propTypes : {};419for (const store_name in x.reduxProps) {420const info = x.reduxProps[store_name];421for (const prop in info) {422const type = info[prop];423if (type !== rtypes.immutable) {424propTypes[prop] = type;425} else {426propTypes[prop] = rtypes.object;427}428}429}430x.propTypes = propTypes;431//reduxPropsCheck(propTypes);432}433434if (x.actions != null && x.actions !== redux.getActions) {435throw Error(436"You may not define a method named actions in an rclass. This is used to expose redux actions",437);438}439440x.actions = redux.getActions;441442C = createReactClass(x);443if (x.reduxProps != null) {444// Make the ones comming from redux get automatically injected, as long445// as this component is in a heierarchy wrapped by <Redux>...</Redux>446C = connect_component(x.reduxProps)(C);447}448}449return C;450};451452const redux = new AppRedux();453454// Public interface455export function is_redux(obj) {456return obj instanceof AppRedux;457}458export function is_redux_actions(obj) {459return obj instanceof Actions;460}461462/*463The non-tsx version of this:464<Provider store={redux.reduxStore}>465{children}466</Provider>467*/468export function Redux({ children }) {469return React.createElement(Provider, {470store: redux.reduxStore,471children,472}) as any;473}474475export const Component = React.Component;476export type Rendered = React.ReactElement<any> | undefined;477export { rclass }; // use rclass to get access to reduxProps support478export { rtypes }; // has extra rtypes.immutable, needed for reduxProps to leave value as immutable479export { computed };480export { React };481export type CSS = React.CSSProperties;482export const { Fragment } = React;483export { redux }; // global redux singleton484export { Actions };485export { Table };486export { Store };487function UNSAFE_NONNULLABLE<T>(arg: T): NonNullable<T> {488return arg as any;489}490export { UNSAFE_NONNULLABLE };491492declare var cc;493if (DEBUG) {494if (typeof cc !== "undefined" && cc !== null) {495cc.redux = redux;496} // for convenience in the browser (mainly for debugging)497}498499/*500Given501spec =502foo :503bar : ...504stuff : ...505foo2 :506other : ...507508the redux_fields function returns ['bar', 'stuff', 'other'].509*/510export function redux_fields(spec) {511const v: any[] = [];512for (let _ in spec) {513const val = spec[_];514for (const key in val) {515_ = val[key];516v.push(key);517}518}519return v;520}521522// Export common React Hooks for convenience:523export * from "./hooks";524export * from "./redux-hooks";525526527