amis/src/factory.tsx
2020-07-27 11:36:03 +08:00

1102 lines
28 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, destroy} from 'mobx-state-tree';
import {Location} from 'history';
import {wrapFetcher} from './utils/api';
import {
createObject,
extendObject,
guid,
findIndex,
promisify,
anyChanged,
syncDataFromSuper,
isObjectShallowModified,
isEmpty,
autobind,
chainEvents
} from './utils/helper';
import {
Api,
fetcherResult,
Payload,
SchemaNode,
Schema,
Action,
RendererData
} from './types';
import {observer} from 'mobx-react';
import getExprProperties from './utils/filter-schema';
import hoistNonReactStatic from 'hoist-non-react-statics';
import omit from 'lodash/omit';
import difference from 'lodash/difference';
import isPlainObject from 'lodash/isPlainObject';
import Scoped from './Scoped';
import {
getTheme,
ThemeInstance,
ClassNamesFn,
ThemeContext,
ThemeProps
} from './theme';
import find from 'lodash/find';
import Alert from './components/Alert2';
import {LazyComponent} from './components';
import ImageGallery from './components/ImageGallery';
import {
TranslateFn,
getDefaultLocale,
makeTranslator,
LocaleContext,
LocaleProps
} from './locale';
export interface TestFunc {
(
path: string,
schema?: Schema,
resolveRenderer?: (
path: string,
schema?: Schema,
props?: any
) => null | RendererConfig
): 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,
conf?: {
closeButton?: boolean;
timeout?: number;
}
) => 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 extends ThemeProps, LocaleProps {
render: (region: string, node: SchemaNode, props?: any) => JSX.Element;
env: RendererEnv;
$path: string; // 当前组件所在的层级信息
store?: IIRendererStore;
syncSuperStore?: boolean;
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, action?: Action, ctx?: object) => 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 =
schema.detectField === '&' ? props : 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;
}
const transform = props.propsTransform;
if (transform) {
delete props.propsTransform;
props = transform(props);
}
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;
locale?: string;
translate?: TranslateFn;
[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) {
console.error(error);
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,
locale,
translate,
...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'}>
<LocaleContext.Provider value={this.props.locale!}>
<ImageGallery modalContainer={env.getModalContainer}>
{
renderChild(
pathPrefix || '',
isPlainObject(schema)
? {
type: 'page',
...(schema as any)
}
: schema,
{
...rest,
resolveDefinitions: this.resolveDefinitions,
location: location,
data: finalData,
env,
classnames: theme.classnames,
classPrefix: theme.classPrefix,
locale,
translate
}
) as JSX.Element
}
</ImageGallery>
</LocaleContext.Provider>
</ThemeContext.Provider>
</RootStoreContext.Provider>
);
}
}
export const ScopedRootRenderer = Scoped(RootRenderer);
interface SchemaRendererProps extends Partial<RendererProps> {
schema: Schema;
$path: string;
env: RendererEnv;
}
const defaultOmitList = [
'type',
'name',
'$ref',
'className',
'data',
'children',
'ref',
'visible',
'visibleOn',
'hidden',
'hiddenOn',
'disabled',
'disabledOn',
'component',
'detectField',
'required',
'requiredOn',
'syncSuperStore'
];
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',
'scope'
]);
if (
difference(Object.keys(props), ['schema', 'scope']).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 React.isValidElement(schema.children)
? schema.children
: (schema.children as Function)({
...rest,
$path: $path,
render: this.renderChild,
forwardedRef: this.refFn
});
} else if (typeof schema.component === 'function') {
const isSFC = !(schema.component.prototype instanceof React.Component);
return React.createElement(schema.component as any, {
...rest,
...schema,
$path: $path,
ref: isSFC ? undefined : this.refFn,
forwardedRef: isSFC ? this.refFn : undefined,
render: this.renderChild
});
} 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}
{...chainEvents(rest, restSchema)}
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 : ''
} as any));
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) {
if (
props.defaultData !== nextProps.defaultData ||
isObjectShallowModified(props.data, nextProps.data) ||
//
// 特殊处理 CRUD。
// 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) {
store.initData(
createObject(
nextProps.store.data,
nextProps.syncSuperStore === false
? {
...store.data
}
: syncDataFromSuper(
store.data,
nextProps.store.data,
props.scope,
store,
nextProps.syncSuperStore === true
)
)
);
} else if (nextProps.data && (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
) {
// 这个用法很少,当 data.__super 值发生变化时,更新 store.data
(!props.data ||
isObjectShallowModified(
nextProps.data.__super,
props.data.__super,
false
)) &&
// nextProps.data.__super !== props.data.__super) &&
store.initData(
createObject(nextProps.data.__super, {
...nextProps.data,
...store.data
})
);
} else if (
nextProps.scope &&
nextProps.data === nextProps.store!.data &&
props.data !== nextProps.data
) {
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. see https://baidu.github.io/amis/docs/getting-started#%E5%A6%82%E4%BD%95%E4%BD%BF%E7%94%A8'
);
return false;
},
alert(msg: string) {
alert(msg);
},
updateLocation() {
console.error(
'Please implements this. see https://baidu.github.io/amis/docs/getting-started#%E5%A6%82%E4%BD%95%E4%BD%BF%E7%94%A8'
);
},
confirm(msg: string) {
return confirm(msg);
},
notify(msg) {
alert(msg);
},
jumpTo() {
console.error(
'Please implements this. see https://baidu.github.io/amis/docs/getting-started#%E5%A6%82%E4%BD%95%E4%BD%BF%E7%94%A8'
);
},
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 {
const locale = props.locale || getDefaultLocale();
const translate = props.translate || makeTranslator(locale);
let store = stores[options.session || 'global'];
if (!store) {
options = {
...defaultOptions,
...options,
fetcher: options.fetcher
? wrapFetcher(options.fetcher)
: defaultOptions.fetcher,
confirm: options.confirm
? promisify(options.confirm)
: defaultOptions.confirm,
locale,
translate
} as any;
store = RendererStore.create({}, options);
stores[options.session || 'global'] = store;
}
(window as any).amisStore = store; // 为了方便 debug.
const env = getEnv(store);
const theme = props.theme || options.theme || 'default';
env.theme = getTheme(theme);
if (props.locale !== undefined) {
env.translate = translate;
env.locale = locale;
}
return (
<ScopedRootRenderer
{...props}
schema={schema}
pathPrefix={pathPrefix}
rootStore={store}
env={env}
theme={theme}
locale={locale}
translate={translate}
/>
);
}
// 默认 env 会被缓存,所以新传入的 env 不会替换旧的。
// 除非先删了旧的,新的才会生效。
export function clearStoresCache(
sessions: Array<string> | string = Object.keys(stores)
) {
if (!Array.isArray(sessions)) {
sessions = [sessions];
}
sessions.forEach(key => {
const store = stores[key];
delete stores[key];
store && destroy(store);
});
}
// 当然也可以直接这样更新。
// 主要是有时候第一创建的时候并没有准备多少接口,
// 可以后续补充点,比如 amis 自己实现的prompt 里面的表单。
export function updateEnv(options: Partial<RenderOptions>, session = 'global') {
options = {
...options
};
if (options.fetcher) {
options.fetcher = wrapFetcher(options.fetcher) as any;
}
if (options.confirm) {
options.confirm = promisify(options.confirm);
}
let store = stores[options.session || session];
if (!store) {
store = RendererStore.create({}, options);
stores[options.session || session] = store;
} else {
const env = getEnv(store);
Object.assign(env, options);
}
}
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, resolveRenderer);
} 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);
}