amis/src/factory.tsx
2019-04-30 12:24:01 +08:00

739 lines
23 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 * as React from "react";
import * as PropTypes from 'prop-types';
import {
RendererStore,
IRendererStore,
IIRendererStore
} from "./store/index";
import {
getEnv
} from 'mobx-state-tree';
import {
Location
} from 'history';
import {
wrapFetcher
} from './utils/api';
import {
createObject,
extendObject,
guid,
findIndex,
promisify,
anyChanged,
syncDataFromSuper,
isObjectShallowModified,
isVisible
} 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";
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;
[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;
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> {
render() {
const {
schema,
rootStore,
env,
pathPrefix,
...rest
} = this.props;
const theme = env.theme;
return (
<RootStoreContext.Provider value={rootStore}>
<ThemeContext.Provider value={this.props.theme || 'default'}>
{renderChild(pathPrefix || '', isPlainObject(schema) ? {
type: 'page',
...(schema as Schema)
} : schema, {
...rest,
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);
}
componentWillMount() {
this.resolveSchema(this.props);
}
componentWillReceiveProps(nextProps:SchemaRendererProps) {
const props = this.props;
if (
props.schema.type !== nextProps.schema.type ||
props.schema.$$id !== nextProps.schema.$$id
) {
this.resolveSchema(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;
}
resolveSchema(props:SchemaRendererProps):any {
const schema = props.schema;
const path = props.$path;
const rendererResolver = props.env.rendererResolver || resolveRenderer;
this.renderer = rendererResolver(path, schema, props);
}
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
});
}
render():JSX.Element | null {
let {
schema,
$path,
...rest
} = 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 (
<Alert level="danger">
<p>Error: </p>
<p>Path: {$path}</p>
<pre><code>{JSON.stringify(schema, null, 2)}</code></pre>
</Alert>
);
}
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))
&& 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.scope !== props.scope) {
store.initData(createObject(nextProps.scope, 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;
}
}
const defaultOptions:RenderOptions = {
session: 'global',
affixOffsetTop: 50,
affixOffsetBottom: 0,
richTextToken: '',
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);
}