import { define, observable } from '@formily/reactive'; import { APIClientOptions } from '@nocobase/sdk'; import { i18n as i18next } from 'i18next'; import get from 'lodash/get'; import merge from 'lodash/merge'; import set from 'lodash/set'; import React, { ComponentType, FC, ReactElement } from 'react'; import { createRoot } from 'react-dom/client'; import { I18nextProvider } from 'react-i18next'; import { Link, NavLink, Navigate } from 'react-router-dom'; import { APIClient, APIClientProvider } from '../api-client'; import { i18n } from '../i18n'; import type { Plugin } from './Plugin'; import { PluginManager, PluginType } from './PluginManager'; import { ComponentTypeAndString, RouterManager, RouterOptions } from './RouterManager'; import { WebSocketClient, WebSocketClientOptions } from './WebSocketClient'; import { AppComponent, BlankComponent, defaultAppComponents } from './components'; import { compose, normalizeContainer } from './utils'; import { defineGlobalDeps } from './utils/globalDeps'; import type { RequireJS } from './utils/requirejs'; import { getRequireJs } from './utils/requirejs'; declare global { interface Window { define: RequireJS['define']; } } export type DevDynamicImport = (packageName: string) => Promise<{ default: typeof Plugin }>; export type ComponentAndProps = [ComponentType, T]; export interface ApplicationOptions { apiClient?: APIClientOptions; ws?: WebSocketClientOptions | boolean; i18n?: i18next; providers?: (ComponentType | ComponentAndProps)[]; plugins?: PluginType[]; components?: Record; scopes?: Record; router?: RouterOptions; devDynamicImport?: DevDynamicImport; } export class Application { public providers: ComponentAndProps[] = []; public router: RouterManager; public scopes: Record = {}; public i18n: i18next; public ws: WebSocketClient; public apiClient: APIClient; public components: Record = { ...defaultAppComponents }; public pm: PluginManager; public devDynamicImport: DevDynamicImport; public requirejs: RequireJS; public notification; loading = true; maintained = false; maintaining = false; error = null; constructor(protected options: ApplicationOptions = {}) { this.initRequireJs(); define(this, { maintained: observable.ref, loading: observable.ref, maintaining: observable.ref, error: observable.ref, }); this.devDynamicImport = options.devDynamicImport; this.scopes = merge(this.scopes, options.scopes); this.components = merge(this.components, options.components); this.apiClient = new APIClient(options.apiClient); this.apiClient.app = this; this.i18n = options.i18n || i18n; this.router = new RouterManager({ ...options.router, renderComponent: this.renderComponent.bind(this), }); this.pm = new PluginManager(options.plugins, this); this.addDefaultProviders(); this.addReactRouterComponents(); this.addProviders(options.providers || []); this.ws = new WebSocketClient(options.ws); } private initRequireJs() { this.requirejs = getRequireJs(); defineGlobalDeps(this.requirejs); window.define = this.requirejs.define; } private addDefaultProviders() { this.use(APIClientProvider, { apiClient: this.apiClient }); this.use(I18nextProvider, { i18n: this.i18n }); } private addReactRouterComponents() { this.addComponents({ Link, Navigate: Navigate as ComponentType, NavLink, }); } getComposeProviders() { const Providers = compose(...this.providers)(BlankComponent); Providers.displayName = 'Providers'; return Providers; } use(component: ComponentType, props?: T) { return this.addProvider(component, props); } addProvider(component: ComponentType, props?: T) { return this.providers.push([component, props]); } addProviders(providers: (ComponentType | [ComponentType, any])[]) { providers.forEach((provider) => { if (Array.isArray(provider)) { this.addProvider(provider[0], provider[1]); } else { this.addProvider(provider); } }); } async load() { this.ws.on('message', (event) => { const data = JSON.parse(event.data); console.log(data.payload); if (data?.payload?.refresh) { window.location.reload(); return; } if (data.type === 'notification') { this.notification[data.payload?.type || 'info']({ message: data.payload?.message }); return; } const maintaining = data.type === 'maintaining' && data.payload.code !== 'APP_RUNNING'; if (maintaining) { this.maintaining = true; this.error = data.payload; } else { this.maintaining = false; this.maintained = true; this.error = null; } }); this.ws.on('serverDown', () => { this.maintaining = true; }); this.ws.connect(); try { this.loading = true; await this.pm.load(); } catch (error) { this.error = { code: 'LOAD_ERROR', message: error.message, }; console.error(error); } this.loading = false; } getComponent(Component: ComponentTypeAndString, isShowError = true): ComponentType | undefined { const showError = (msg: string) => isShowError && console.error(msg); if (!Component) { showError(`getComponent called with ${Component}`); return; } // ClassComponent or FunctionComponent if (typeof Component === 'function') return Component; // Component is a string, try to get it from this.components if (typeof Component === 'string') { const res = get(this.components, Component) as ComponentType; if (!res) { showError(`Component ${Component} not found`); return; } return res; } showError(`Component ${Component} should be a string or a React component`); return; } renderComponent(Component: ComponentTypeAndString, props?: T): ReactElement { return React.createElement(this.getComponent(Component), props); } addComponent(component: ComponentType, name?: string) { const componentName = name || component.displayName || component.name; if (!componentName) { console.error('Component must have a displayName or pass name as second argument'); return; } set(this.components, componentName, component); } addComponents(components: Record) { Object.keys(components).forEach((name) => { this.addComponent(components[name], name); }); } addScopes(scopes: Record) { this.scopes = merge(this.scopes, scopes); } getRootComponent() { const Root: FC = () => ; return Root; } mount(containerOrSelector: Element | ShadowRoot | string) { const container = normalizeContainer(containerOrSelector); if (!container) return; const App = this.getRootComponent(); const root = createRoot(container); root.render(); return root; } }