amis2/src/factory.tsx
2019-06-21 17:31:45 +08:00

855 lines
27 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

import React from "react";
import qs from 'qs';
import {
RendererStore,
IRendererStore,
IIRendererStore
} from "./store/index";
import {
getEnv
} from 'mobx-state-tree';
import {
Location, parsePath
} from 'history';
import {
wrapFetcher
} from './utils/api';
import {
createObject,
extendObject,
guid,
findIndex,
promisify,
anyChanged,
syncDataFromSuper,
isObjectShallowModified,
isVisible,
isEmpty,
autobind
} from './utils/helper';
import {
Api,
fetcherResult,
Payload,
SchemaNode,
Schema,
Action,
ExtractProps,
Omit,
PlainObject,
RendererData
} from './types';
import { observer } from "mobx-react";
import getExprProperties from './utils/filter-schema';
import hoistNonReactStatic = require('hoist-non-react-statics');
import omit = require('lodash/omit');
import difference = require('lodash/difference');
import isPlainObject = require('lodash/isPlainObject');
import Scoped from './Scoped';
import { getTheme, ThemeInstance, ClassNamesFn, ThemeContext } from "./theme";
import find = require("lodash/find");
import Alert from "./components/Alert2";
import { LazyComponent } from './components';
export interface TestFunc {
(path: string, schema?: object): boolean;
}
export interface RendererBasicConfig {
test: RegExp | TestFunc;
name?: string;
storeType?: string;
storeExtendsData?: boolean; // 是否需要继承上层数据。
weight?: number; // 权重,值越低越优先命中。
isolateScope?: boolean;
isFormItem?: boolean;
// [propName:string]:any;
}
export interface RendererEnv {
fetcher: (api:Api, data?:any, options?: object) => Promise<Payload>;
isCancel: (val:any) => boolean;
notify: (type: "error" | "success", msg: string) => void;
jumpTo: (to:string, action?: Action, ctx?: object) => void;
alert: (msg:string) => void;
confirm: (msg:string, title?: string) => Promise<boolean>;
updateLocation: (location:any, replace?:boolean) => void;
isCurrentUrl: (link:string) => boolean;
rendererResolver?: (path:string, schema:Schema, props:any) => null | RendererConfig;
copy?: (contents:string) => void;
getModalContainer?: () => HTMLElement;
theme: ThemeInstance;
affixOffsetTop: number;
affixOffsetBottom: number;
richTextToken: string;
loadRenderer: (schema:Schema, path:string, reRender:Function) => Promise<React.ReactType> | React.ReactType | JSX.Element | void;
[propName:string]: any;
};
export interface RendererProps {
render: (region: string, node:SchemaNode, props?:any) => JSX.Element;
env: RendererEnv;
classPrefix: string;
classnames: ClassNamesFn;
$path: string; // 当前组件所在的层级信息
store?: IIRendererStore;
data: {
[propName:string]: any;
};
defaultData?: object;
className?: string;
[propName:string]: any;
}
export interface renderChildProps extends Partial<RendererProps> {
env: RendererEnv;
};
export type RendererComponent = React.ComponentType<RendererProps> & {
propsList?: Array<string>;
}
export interface RendererConfig extends RendererBasicConfig {
component: RendererComponent;
Renderer?: RendererComponent; // 原始组件
}
export interface RenderSchemaFilter {
(schema:Schema, renderer: RendererConfig, props?:object): SchemaNode;
};
export interface RootRenderProps {
location?: Location;
theme?: string;
[propName:string]: any;
};
export interface RenderOptions {
session?: string,
fetcher?: (config: fetcherConfig) => Promise<fetcherResult>,
isCancel?: (value:any) => boolean;
notify?: (type: "error" | "success", msg: string) => void,
jumpTo?: (to:string) => void,
alert?: (msg:string) => void,
confirm?: (msg:string, title?: string) => boolean | Promise<boolean>;
rendererResolver?: (path:string, schema:Schema, props:any) => null | RendererConfig;
copy?: (contents:string) => void;
getModalContainer?: () => HTMLElement;
loadRenderer?: (schema:Schema, path: string, reRender:Function) => Promise<React.ReactType> | React.ReactType | JSX.Element | void;
affixOffsetTop?: number;
affixOffsetBottom?: number;
richTextToken?: string;
[propName: string]: any
}
export interface fetcherConfig {
url: string;
method: 'get' | 'post' | 'put' | 'patch' | 'delete';
data?: any;
config?: any;
};
export type ReactElement = React.ReactNode[] | JSX.Element | null | false;
const renderers:Array<RendererConfig> = [];
const rendererNames:Array<string> = [];
const schemaFilters:Array<RenderSchemaFilter> = [];
let anonymousIndex = 1;
export function addSchemaFilter(fn:RenderSchemaFilter) {
schemaFilters.push(fn);
}
export function filterSchema(schema:Schema, render:RendererConfig, props?:any) {
return schemaFilters.reduce((schema, filter) => filter(schema, render, props), schema) as Schema;
}
export function Renderer(config:RendererBasicConfig) {
return function<T extends RendererComponent>(component:T):T {
const renderer = registerRenderer({
...config,
component: component
});
return renderer.component as T;
}
}
export function registerRenderer(config:RendererConfig):RendererConfig {
if (!config.test) {
throw new TypeError("config.test is required");
} else if (!config.component) {
throw new TypeError("config.component is required");
}
config.weight = config.weight || 0;
config.Renderer = config.component;
config.name = config.name || `anonymous-${anonymousIndex++}`;
if (~rendererNames.indexOf(config.name)) {
throw new Error(`The renderer with name "${config.name}" has already exists, please try another name!`);
}
if (config.storeType && config.component) {
config.component = HocStoreFactory({
storeType: config.storeType,
extendsData: config.storeExtendsData
})(observer(config.component));
}
if (config.isolateScope) {
config.component = Scoped(config.component);
}
const idx = findIndex(renderers, item => (config.weight as number) < item.weight);
~idx ? renderers.splice(idx, 0, config) : renderers.push(config);
rendererNames.push(config.name);
return config;
}
export function unRegisterRenderer(config:RendererConfig | string) {
let idx = typeof config === 'string' ? findIndex(renderers, item => item.name === config) : renderers.indexOf(config);
~idx && renderers.splice(idx, 1);
// 清空渲染器定位缓存
cache = {};
}
export function renderChildren(prefix: string, node: SchemaNode, props:renderChildProps):ReactElement {
if (Array.isArray(node)) {
return node.map((node, index) => renderChild(`${prefix}/${index}`, node, {
...props,
key: `${props.key ? `${props.key}-` : ''}${index}`
}));
}
return renderChild(prefix, node, props);
}
export function renderChild(prefix:string, node:SchemaNode, props:renderChildProps):ReactElement {
if (Array.isArray(node)) {
return renderChildren(prefix, node, props);
}
const typeofnode = typeof node;
let schema:Schema = typeofnode === 'string' || typeofnode === 'number' ? {type: 'tpl', tpl: String(node)} : node as Schema;
const detectData = props[schema.detectField || 'data'];
const exprProps = detectData ? getExprProperties(schema, detectData) : null;
if (
exprProps
&& (
exprProps.hidden
|| exprProps.visible === false
|| schema.hidden
|| schema.visible === false
|| props.hidden
|| props.visible === false
)
) {
return null;
}
return (
<SchemaRenderer
{...props}
{...exprProps}
schema={schema}
$path={`${prefix ? `${prefix}/` : ''}${schema && schema.type || ''}`}
/>
);
};
export interface RootRendererProps {
schema: SchemaNode;
rootStore: IRendererStore;
env: RendererEnv;
theme: string;
pathPrefix?: string;
[propName:string]: any;
};
const RootStoreContext = React.createContext<IRendererStore>(undefined as any);
export class RootRenderer extends React.Component<RootRendererProps> {
state = {
error: null,
errorInfo: null
}
componentDidCatch(error:any, errorInfo:any) {
this.setState({
error: error,
errorInfo: errorInfo
});
}
@autobind
resolveDefinitions(name:string) {
const definitions = (this.props.schema as Schema).definitions;
if (!name || isEmpty(definitions)) {
return {};
}
return definitions && definitions[name];
}
render() {
const {error, errorInfo} = this.state;
if (errorInfo) {
return errorRenderer(error, errorInfo);
}
const {
schema,
rootStore,
env,
pathPrefix,
location,
data,
...rest
} = this.props;
const theme = env.theme;
const query = location && location.query
|| location && location.search && qs.parse(location.search.substring(1))
|| window.location.search && qs.parse(window.location.search.substring(1));
const finalData = query ? createObject({
...(data && data.__super ? data.__super: null),
...query,
query
}, data) : data;
return (
<RootStoreContext.Provider value={rootStore}>
<ThemeContext.Provider value={this.props.theme || 'default'}>
{renderChild(pathPrefix || '', isPlainObject(schema) ? {
type: 'page',
...(schema as Schema)
} : schema, {
...rest,
resolveDefinitions: this.resolveDefinitions,
location: location,
data: finalData,
env,
classnames: theme.classnames,
classPrefix: theme.classPrefix
}) as JSX.Element}
</ThemeContext.Provider>
</RootStoreContext.Provider>
);
}
}
export const ScopedRootRenderer = Scoped(RootRenderer);
interface SchemaRendererProps extends Partial<RendererProps> {
schema: Schema;
$path: string;
env: RendererEnv;
};
const defaultOmitList = [
'type', 'className', 'data', 'children',
'ref', 'visible', 'visibleOn', 'hidden',
'hiddenOn', 'disabled', 'disabledOn',
'children'
];
class SchemaRenderer extends React.Component<SchemaRendererProps, any> {
static displayName:string = 'Renderer';
renderer:RendererConfig | null;
ref: any;
constructor(props:SchemaRendererProps) {
super(props);
this.refFn = this.refFn.bind(this);
this.renderChild = this.renderChild.bind(this);
this.reRender = this.reRender.bind(this);
}
componentWillMount() {
this.resolveRenderer(this.props);
}
componentWillReceiveProps(nextProps:SchemaRendererProps) {
const props = this.props;
if (
props.schema.type !== nextProps.schema.type ||
props.schema.$$id !== nextProps.schema.$$id
) {
this.resolveRenderer(nextProps);
}
}
// 限制:只有 schema 除外的 props 变化,或者 schema 里面的某个成员值发生变化才更新。
shouldComponentUpdate(nextProps:SchemaRendererProps) {
const props = this.props;
const list:Array<string> = difference(Object.keys(nextProps), ['schema']);
if (
difference(Object.keys(props), ['schema']).length !== list.length
|| anyChanged(list, this.props, nextProps)
) {
return true;
} else {
const list:Array<string> = Object.keys(nextProps.schema);
if (
Object.keys(props.schema).length !== list.length
|| anyChanged(list, props.schema, nextProps.schema)
) {
return true;
}
}
return false;
}
resolveRenderer(props:SchemaRendererProps):any {
let schema = props.schema;
let path = props.$path;
const rendererResolver = props.env.rendererResolver || resolveRenderer;
if (schema.$ref) {
schema = {
...props.resolveDefinitions(schema.$ref),
...schema
};
delete schema.$ref;
path = path.replace(/(?!.*\/).*/, schema.type);
}
// value 会提前从 control 中获取到所有需要把control中的属性也补充完整
if (schema.control && schema.control.$ref) {
schema.control = {
...props.resolveDefinitions(schema.control.$ref),
...schema.control
}
delete schema.control.$ref;
}
this.renderer = rendererResolver(path, schema, props);
return schema;
}
getWrappedInstance() {
return this.ref;
}
refFn(ref:any) {
this.ref = ref;
}
renderChild(region: string, node?:SchemaNode, subProps: {
data?: object;
[propName: string]: any;
} = {}) {
let {
schema,
$path,
env,
...rest
} = this.props;
const omitList = defaultOmitList.concat();
if (this.renderer) {
const Component = this.renderer.component;
Component.propsList && omitList.push.apply(omitList, Component.propsList as Array<string>);
}
return renderChild(`${$path}${region ? `/${region}` : ''}`, node || '', {
...omit(rest, omitList),
...subProps,
data: subProps.data || rest.data,
env: env
});
}
reRender() {
this.resolveRenderer(this.props);
this.forceUpdate();
}
render():JSX.Element | null {
let {
$path,
schema,
...rest
} = this.props;
if (schema.$ref) {
schema = this.resolveRenderer(this.props);
}
const theme = this.props.env.theme;
if (Array.isArray(schema)) {
return renderChildren($path, schema, rest) as JSX.Element;
} else if (schema.children) {
return typeof schema.children === 'function' ? schema.children({
...rest,
$path: $path,
render: this.renderChild,
ref: this.refFn,
}, schema) : schema.children;
} else if (!this.renderer) {
return (
<LazyComponent
{...rest}
getComponent={async () => {
const result = await rest.env.loadRenderer(schema, $path, this.reRender);
if (result && typeof result === "function") {
return result;
} else if (result && React.isValidElement(result)) {
return () => result;
}
this.reRender();
return () => loadRenderer(schema, $path)
}}
$path={$path}
retry={this.reRender}
/>
);
}
const renderer = this.renderer as RendererConfig;
schema = filterSchema(schema, renderer, rest);
const {
data: defaultData,
...restSchema
} = schema;
const Component = renderer.component;
return (
<Component
{...theme.getRendererConfig(renderer.name)}
{...restSchema}
{...rest}
defaultData={defaultData}
$path={$path}
ref={this.refFn}
render={this.renderChild}
/>
);
}
}
export function HocStoreFactory(renderer:{
storeType: string;
extendsData?: boolean;
}):any {
return function <T extends React.ComponentType<RendererProps>>(Component:T) {
type Props = Omit<RendererProps, "store" | "data" | "dataUpdatedAt" | "scope"> & {
store?: IIRendererStore;
data?: RendererData;
scope?: RendererData;
};
@observer
class StoreFactory extends React.Component<Props> {
static displayName = `WithStore(${Component.displayName || Component.name})`;
static ComposedComponent = Component;
static contextType = RootStoreContext;
store:IIRendererStore;
context!: React.ContextType<typeof RootStoreContext>;
ref:any;
getWrappedInstance() {
return this.ref;
}
refFn(ref:any) {
this.ref = ref;
}
formatData(data:any):object {
if (Array.isArray(data)) {
return {
items: data
}
}
return data as object;
}
componentWillMount() {
const rootStore = this.context;
this.renderChild = this.renderChild.bind(this);
this.refFn = this.refFn.bind(this);
const store = this.store = rootStore.addStore({
id: guid(),
path: this.props.$path,
storeType: renderer.storeType,
parentId: this.props.store ? this.props.store.id : ''
});
if (renderer.extendsData === false) {
store.initData(createObject((this.props.data as any) ? (this.props.data as any).__super : null, {
...this.formatData(this.props.defaultData),
...this.formatData(this.props.data)
}));
} else if (this.props.scope || this.props.data && (this.props.data as any).__super) {
if (this.props.store && this.props.data === this.props.store.data) {
store.initData(createObject(this.props.store.data, {
...this.formatData(this.props.defaultData)
}))
} else {
store.initData(createObject((this.props.data as any).__super || this.props.scope, {
...this.formatData(this.props.defaultData),
...this.formatData(this.props.data)
}))
}
} else {
store.initData({
...this.formatData(this.props.defaultData),
...this.formatData(this.props.data)
});
}
}
componentWillReceiveProps(nextProps:RendererProps) {
const props = this.props;
const store = this.store;
if (renderer.extendsData === false) {
(
props.defaultData !== nextProps.defaultData
|| isObjectShallowModified(props.data, nextProps.data)
// CRUD 中 toolbar 里面的 data 是空对象,但是 __super 会不一样
|| nextProps.data && props.data && nextProps.data.__super !== props.data.__super
)
&& store.initData(extendObject(nextProps.data, {
...store.hasRemoteData ? store.data : null, // todo 只保留 remote 数据
...this.formatData(nextProps.defaultData),
...this.formatData(nextProps.data)
}));
} else if (isObjectShallowModified(props.data, nextProps.data)) {
if (nextProps.store && nextProps.store.data === nextProps.data) {
const newData = createObject(nextProps.store.data, syncDataFromSuper(store.data,
nextProps.store.data, props.scope, nextProps.dataUpdatedAt !== props.dataUpdatedAt));
// todo fix: dialog 种数据从孩子 form 同步过来后,会走这个逻辑让 form 更新 data会导致里面的 __prev 丢失。
store.initData(newData);
} else if ((nextProps.data as any).__super) {
store.initData(extendObject(nextProps.data));
} else {
store.initData(createObject(nextProps.scope, nextProps.data));
}
} else if ((!nextProps.store || nextProps.data !== nextProps.store.data) && nextProps.data && nextProps.data.__super) {
(!props.data || nextProps.data.__super !== props.data.__super)
&& store.initData(createObject(nextProps.data.__super, {
...nextProps.data,
...store.data
}));
} else if (nextProps.scope !== props.scope) {
store.initData(createObject(nextProps.scope, {
...nextProps.data,
...store.data
}));
}
}
componentWillUnmount() {
const rootStore = this.context as IRendererStore;
const store = this.store;
rootStore.removeStore(store);
delete this.store;
}
renderChild(region: string, node:SchemaNode, subProps: {
data?: object;
[propName: string]: any;
} = {}) {
let {
render,
} = this.props;
return render(region, node, {
data: this.store.data,
dataUpdatedAt: this.store.updatedAt,
...subProps,
scope: this.store.data,
store: this.store
});
}
render() {
const {
detectField,
...rest
} = this.props;
let exprProps:any = {};
if (!detectField || detectField === "data") {
exprProps = getExprProperties(rest, this.store.data);
if (exprProps.hidden || exprProps.visible === false) {
return null;
}
}
return (
<Component
{...rest as any /* todo */}
{...exprProps}
ref={this.refFn}
data={this.store.data}
dataUpdatedAt={this.store.updatedAt}
store={this.store}
scope={this.store.data}
render={this.renderChild}
/>
);
}
}
hoistNonReactStatic(StoreFactory, Component);
return StoreFactory;
}
}
function loadRenderer(schema:Schema, path:string) {
return (
<Alert level="danger">
<p>Error: </p>
<p>Path: {path}</p>
<pre><code>{JSON.stringify(schema, null, 2)}</code></pre>
</Alert>
);
}
function errorRenderer(error:any, errorInfo:any) {
return (
<Alert level="danger">
<p>{error && error.toString()}</p>
<pre><code>{errorInfo.componentStack}</code></pre>
</Alert>
)
}
const defaultOptions:RenderOptions = {
session: 'global',
affixOffsetTop: 50,
affixOffsetBottom: 0,
richTextToken: '',
loadRenderer,
fetcher() {
return Promise.reject('fetcher is required');
},
isCancel() {
console.error('Please implements this.');
return false;
},
alert(msg:string) {
alert(msg);
},
updateLocation() {
console.error('Please implements this.');
},
confirm(msg:string) {
return confirm(msg)
},
notify(msg) {
alert(msg);
},
jumpTo() {
console.error('Please implements this.');
},
isCurrentUrl() {
return false;
},
copy(contents:string) {
console.error('copy contents', contents)
},
rendererResolver: resolveRenderer
};
let stores:{
[propName:string]: IRendererStore
} = {};
export function render(schema:SchemaNode, props:RootRenderProps = {}, options:RenderOptions = {}, pathPrefix:string = ''):JSX.Element {
options = {
...defaultOptions,
...options
};
let store = stores[options.session || 'global'] || (stores[options.session || 'global'] = RendererStore.create({}, {
...options,
fetcher: options.fetcher ? wrapFetcher(options.fetcher) : defaultOptions.fetcher,
confirm: options.confirm ? promisify(options.confirm) : defaultOptions.confirm,
}));
(window as any).amisStore = store; // 为了方便 debug.
const env = getEnv(store);
const theme = props.theme || options.theme || 'default';
env.theme = getTheme(theme);
return (
<ScopedRootRenderer
{...props}
schema={schema}
pathPrefix={pathPrefix}
rootStore={store}
env={env}
theme={theme}
/>
);
}
export function clearStoresCache(sessions?:Array<string>) {
if (Array.isArray(sessions) && sessions.length) {
sessions.forEach(key => delete stores[key]);
} else {
stores = {};
}
}
let cache:{[propName: string]: RendererConfig} = {};
export function resolveRenderer(path:string, schema?:Schema, props?:any): null | RendererConfig {
if (cache[path]) {
return cache[path];
} else if (path && path.length > 1024) {
throw new Error('Path太长是不是死循环了');
}
let renderer:null | RendererConfig = null;
renderers.some(item => {
let matched = false;
if (typeof item.test === "function") {
matched = item.test(path, schema);
} else if (item.test instanceof RegExp) {
matched = item.test.test(path);
}
if (matched) {
renderer = item;
}
return matched;
});
// 只能缓存纯正则表达式的后者方法中没有用到第二个参数的,因为自定义 test 函数的有可能依赖 schema 的结果
if (renderer !== null &&
(
(renderer as RendererConfig).test instanceof RegExp
|| typeof (renderer as RendererConfig).test === 'function' && ((renderer as RendererConfig).test as Function).length < 2
)) {
cache[path] = renderer;
}
return renderer;
}
export function getRenderers() {
return renderers.concat();
}
export function getRendererByName(name:string) {
return find(renderers, item => item.name === name);
}