Root renderer (#1425)

* 拆解 Factory

* 直接渲染按钮也能响应动作
This commit is contained in:
liaoxuezhi 2021-01-22 15:49:10 +08:00 committed by GitHub
parent be33bf98d2
commit 2df39a9c68
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
13 changed files with 1099 additions and 833 deletions

View File

@ -153,7 +153,8 @@ fis.match('{*.ts,*.jsx,*.tsx,/src/**.js,/src/**.ts}', {
importHelpers: true,
esModuleInterop: true,
experimentalDecorators: true,
sourceMap: true
sourceMap: true,
target: 4
}),
function (content) {

171
src/Root.tsx Normal file
View File

@ -0,0 +1,171 @@
import isPlainObject from 'lodash/isPlainObject';
import qs from 'qs';
import React from 'react';
import Alert from './components/Alert2';
import ImageGallery from './components/ImageGallery';
import {envOverwrite} from './envOverwrite';
import {RendererEnv, RendererProps} from './factory';
import {LocaleContext, TranslateFn} from './locale';
import {RootRenderer} from './RootRenderer';
import {SchemaRenderer} from './SchemaRenderer';
import Scoped from './Scoped';
import {IRendererStore} from './store';
import {ThemeContext} from './theme';
import {Schema, SchemaNode} from './types';
import getExprProperties from './utils/filter-schema';
import {autobind, createObject, isEmpty} from './utils/helper';
import {RootStoreContext} from './WithRootStore';
export interface RootRenderProps {
location?: Location;
theme?: string;
[propName: string]: any;
}
export interface RootProps {
schema: SchemaNode;
rootStore: IRendererStore;
env: RendererEnv;
theme: string;
pathPrefix?: string;
locale?: string;
translate?: TranslateFn;
[propName: string]: any;
}
export class Root extends React.Component<RootProps> {
@autobind
resolveDefinitions(name: string) {
const definitions = (this.props.schema as Schema).definitions;
if (!name || isEmpty(definitions)) {
return {};
}
return definitions && definitions[name];
}
render() {
const {
schema,
rootStore,
env,
pathPrefix,
location,
data,
locale,
translate,
...rest
} = this.props;
const theme = env.theme;
// 根据环境覆盖 schema这个要在最前面做不然就无法覆盖 validations
envOverwrite(schema, locale);
return (
<RootStoreContext.Provider value={rootStore}>
<ThemeContext.Provider value={this.props.theme || 'default'}>
<LocaleContext.Provider value={this.props.locale!}>
<ImageGallery modalContainer={env.getModalContainer}>
<RootRenderer
pathPrefix={pathPrefix || ''}
schema={
isPlainObject(schema)
? {
type: 'page',
...(schema as any)
}
: schema
}
{...rest}
rootStore={rootStore}
resolveDefinitions={this.resolveDefinitions}
location={location}
data={data}
env={env}
classnames={theme.classnames}
classPrefix={theme.classPrefix}
locale={locale}
translate={translate}
/>
</ImageGallery>
</LocaleContext.Provider>
</ThemeContext.Provider>
</RootStoreContext.Provider>
);
}
}
export interface renderChildProps extends Partial<RendererProps> {
env: RendererEnv;
}
export type ReactElement = React.ReactNode[] | JSX.Element | null | false;
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 &&
(schema.detectField === '&' ? props : props[schema.detectField || 'data']);
const exprProps = detectData
? getExprProperties(schema, detectData, undefined, props)
: 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) {
// @ts-ignore
delete props.propsTransform;
props = transform(props);
}
return (
<SchemaRenderer
{...props}
{...exprProps}
schema={schema}
$path={`${prefix ? `${prefix}/` : ''}${(schema && schema.type) || ''}`}
/>
);
}
export default Scoped(Root);

276
src/RootRenderer.tsx Normal file
View File

@ -0,0 +1,276 @@
import {observer} from 'mobx-react';
import React from 'react';
import Alert from './components/Alert2';
import Spinner from './components/Spinner';
import {renderChild, RootProps} from './Root';
import {IScopedContext, ScopedContext} from './Scoped';
import {IRootStore, RootStore} from './store/root';
import {Action} from './types';
import {bulkBindFunctions, guid, isVisible} from './utils/helper';
import {filter} from './utils/tpl';
export interface RootRendererProps extends RootProps {
location?: any;
}
@observer
export class RootRenderer extends React.Component<RootRendererProps> {
store: IRootStore;
static contextType = ScopedContext;
constructor(props: RootRendererProps) {
super(props);
this.store = props.rootStore.addStore({
id: guid(),
path: this.props.$path,
storeType: RootStore.name,
parentId: ''
}) as IRootStore;
this.store.initData(props.data);
this.store.updateLocation(props.location);
bulkBindFunctions<RootRenderer /*为毛 this 的类型自动识别不出来*/>(this, [
'handleAction',
'handleDialogConfirm',
'handleDialogClose',
'handleDrawerConfirm',
'handleDrawerClose'
]);
}
componentDidUpdate(prevProps: RootRendererProps) {
const props = this.props;
if (props.data !== prevProps.data) {
this.store.updateData(props.data);
}
if (props.location !== prevProps.location) {
this.store.updateLocation(props.location);
}
}
componentDidCatch(error: any, errorInfo: any) {
this.store.setRuntimeError(error, errorInfo);
}
componentWillUnmount() {
this.props.rootStore.removeStore(this.store);
}
handleAction(
e: React.UIEvent<any> | void,
action: Action,
ctx: object,
throwErrors: boolean = false,
delegate?: IScopedContext
) {
const {env, messages, onAction} = this.props;
const store = this.store;
if (
onAction?.(e, action, ctx, throwErrors, delegate || this.context) ===
false
) {
return;
}
if (
action.actionType === 'url' ||
action.actionType === 'link' ||
action.actionType === 'jump'
) {
if (!env || !env.jumpTo) {
throw new Error('env.jumpTo is required!');
}
env.jumpTo(
filter(
(action.to || action.url || action.link) as string,
ctx,
'| raw'
),
action,
ctx
);
} else if (action.actionType === 'dialog') {
store.setCurrentAction(action);
store.openDialog(ctx);
} else if (action.actionType === 'drawer') {
store.setCurrentAction(action);
store.openDrawer(ctx);
} else if (action.actionType === 'ajax') {
store.setCurrentAction(action);
store
.saveRemote(action.api as string, ctx, {
successMessage:
(action.messages && action.messages.success) ||
(messages && messages.saveSuccess),
errorMessage:
(action.messages && action.messages.failed) ||
(messages && messages.saveSuccess)
})
.then(async () => {
if (action.feedback && isVisible(action.feedback, store.data)) {
await this.openFeedback(action.feedback, store.data);
}
const redirect =
action.redirect && filter(action.redirect, store.data);
redirect && env.jumpTo(redirect, action);
action.reload &&
this.reloadTarget(
delegate || this.context,
action.reload,
store.data
);
})
.catch(() => {});
} else if (
action.actionType === 'copy' &&
(action.content || action.copy)
) {
env.copy && env.copy(filter(action.content || action.copy, ctx, '| raw'));
}
}
handleDialogConfirm(values: object[], action: Action, ...args: Array<any>) {
const store = this.store;
if (action.mergeData && values.length === 1 && values[0]) {
store.updateData(values[0]);
}
const dialog = store.action.dialog as any;
if (
dialog &&
dialog.onConfirm &&
dialog.onConfirm(values, action, ...args) === false
) {
return;
}
store.closeDialog();
}
handleDialogClose() {
const store = this.store;
store.closeDialog();
}
handleDrawerConfirm(values: object[], action: Action, ...args: Array<any>) {
const store = this.store;
if (action.mergeData && values.length === 1 && values[0]) {
store.updateData(values[0]);
}
const dialog = store.action.dialog as any;
if (
dialog &&
dialog.onConfirm &&
dialog.onConfirm(values, action, ...args) === false
) {
return;
}
store.closeDrawer();
}
handleDrawerClose() {
const store = this.store;
store.closeDrawer();
}
openFeedback(dialog: any, ctx: any) {
return new Promise(resolve => {
const store = this.store;
store.setCurrentAction({
type: 'button',
actionType: 'dialog',
dialog: dialog
});
store.openDialog(ctx, undefined, confirmed => {
resolve(confirmed);
});
});
}
reloadTarget(scoped: IScopedContext, target: string, data?: any) {
scoped.reload(target, data);
}
render() {
const {pathPrefix, schema, ...rest} = this.props;
const store = this.store;
if (store.runtimeError) {
return (
<Alert level="danger">
<h3>{this.store.runtimeError?.toString()}</h3>
<pre>
<code>{this.store.runtimeErrorStack.componentStack}</code>
</pre>
</Alert>
);
}
return (
<>
{
renderChild(pathPrefix!, schema, {
...rest,
data: this.store.downStream,
onAction: this.handleAction
}) as JSX.Element
}
<Spinner size="lg" overlay key="info" show={store.loading} />
{store.error ? (
<Alert level="danger" showCloseButton onClose={store.clearMessage}>
{store.msg}
</Alert>
) : null}
{renderChild(
'dialog',
{
...((store.action as Action) &&
((store.action as Action).dialog as object)),
type: 'dialog'
},
{
...rest,
key: 'dialog',
data: store.dialogData,
onConfirm: this.handleDialogConfirm,
onClose: this.handleDialogClose,
show: store.dialogOpen,
onAction: this.handleAction
}
)}
{renderChild(
'drawer',
{
...((store.action as Action) &&
((store.action as Action).drawer as object)),
type: 'drawer'
},
{
...rest,
key: 'drawer',
data: store.drawerData,
onConfirm: this.handleDrawerConfirm,
onClose: this.handleDrawerClose,
show: store.drawerOpen,
onAction: this.handleAction
}
)}
</>
);
}
}

249
src/SchemaRenderer.tsx Normal file
View File

@ -0,0 +1,249 @@
import difference from 'lodash/difference';
import omit from 'lodash/omit';
import React from 'react';
import LazyComponent from './components/LazyComponent';
import {
filterSchema,
loadRenderer,
RendererConfig,
RendererEnv,
RendererProps,
resolveRenderer
} from './factory';
import {renderChild, renderChildren} from './Root';
import {Schema, SchemaNode} from './types';
import {anyChanged, chainEvents} from './utils/helper';
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'
];
export class SchemaRenderer extends React.Component<SchemaRendererProps, any> {
static displayName: string = 'Renderer';
renderer: RendererConfig | null;
ref: any;
schema: any;
path: string;
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 &&
nextProps.schema &&
(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, skipResolve = false): any {
let schema = props.schema;
let path = props.$path;
if (schema && schema.$ref) {
schema = {
...props.resolveDefinitions(schema.$ref),
...schema
};
path = path.replace(/(?!.*\/).*/, schema.type);
}
if (!skipResolve) {
const rendererResolver = props.env.rendererResolver || resolveRenderer;
this.renderer = rendererResolver(path, schema, props);
}
return {path, 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;
if (schema && schema.$ref) {
const result = this.resolveRenderer(this.props, true);
schema = result.schema;
$path = result.path;
}
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 === null) {
return null;
}
if (schema.$ref) {
const result = this.resolveRenderer(this.props, true);
schema = result.schema;
$path = result.path;
}
const theme = this.props.env.theme;
if (Array.isArray(schema)) {
return renderChildren($path, schema as any, 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 (Object.keys(schema).length === 0) {
return null;
} 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}
/>
);
}
}

54
src/WithRootStore.tsx Normal file
View File

@ -0,0 +1,54 @@
import React from 'react';
import {IRendererStore} from './store';
import hoistNonReactStatic from 'hoist-non-react-statics';
export const RootStoreContext = React.createContext<IRendererStore>(
undefined as any
);
export function withRootStore<
T extends React.ComponentType<
React.ComponentProps<T> & {
rootStore: IRendererStore;
}
>
>(ComposedComponent: T) {
type OuterProps = JSX.LibraryManagedAttributes<
T,
Omit<React.ComponentProps<T>, 'rootStore'>
>;
const result = hoistNonReactStatic(
class extends React.Component<OuterProps> {
static displayName = `WithRootStore(${
ComposedComponent.displayName || ComposedComponent.name
})`;
static contextType = RootStoreContext;
static ComposedComponent = ComposedComponent;
render() {
const rootStore = this.context;
const injectedProps: {
rootStore: IRendererStore;
} = {
rootStore
};
return (
<ComposedComponent
{...(this.props as JSX.LibraryManagedAttributes<
T,
React.ComponentProps<T>
>)}
{...injectedProps}
/>
);
}
},
ComposedComponent
);
return result as typeof result & {
ComposedComponent: T;
};
}

276
src/WithStore.tsx Normal file
View File

@ -0,0 +1,276 @@
import hoistNonReactStatic from 'hoist-non-react-statics';
import {observer} from 'mobx-react';
import React from 'react';
import {RendererProps} from './factory';
import {IIRendererStore, IRendererStore} from './store';
import {RendererData, SchemaNode} from './types';
import getExprProperties from './utils/filter-schema';
import {
createObject,
extendObject,
guid,
isObjectShallowModified,
syncDataFromSuper
} from './utils/helper';
import {RootStoreContext} from './WithRootStore';
export function HocStoreFactory(renderer: {
storeType: string;
extendsData?: boolean;
shouldSyncSuperStore?: (
store: any,
props: any,
prevProps: any
) => boolean | undefined;
}): 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 = rootStore.addStore({
id: guid(),
path: this.props.$path,
storeType: renderer.storeType,
parentId: this.props.store ? this.props.store.id : ''
}) as IIRendererStore;
this.store = store;
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;
const shouldSync = renderer.shouldSyncSuperStore?.(
store,
nextProps,
props
);
if (shouldSync === false) {
return;
}
if (renderer.extendsData === false) {
if (
shouldSync === true ||
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 (
shouldSync === true ||
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 (
(shouldSync === true ||
!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
}),
store.storeType === 'FormStore' &&
props.store?.storeType === 'CRUDStore'
);
} else if (
nextProps.scope &&
nextProps.data === nextProps.store!.data &&
(shouldSync === true || 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);
// @ts-ignore
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, undefined, rest);
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;
};
}

View File

@ -2,61 +2,20 @@ 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 {normalizeLink} from './utils/normalizeLink';
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 {findIndex, promisify} from './utils/helper';
import {Api, fetcherResult, Payload, SchemaNode, Schema, Action} 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 {getTheme, ThemeInstance, ThemeProps} from './theme';
import find from 'lodash/find';
import Alert from './components/Alert2';
import {toast} from './components/Toast';
import {alert, confirm, setRenderSchemaFn} from './components/Alert';
import {LazyComponent} from './components';
import ImageGallery from './components/ImageGallery';
import {
TranslateFn,
getDefaultLocale,
makeTranslator,
LocaleContext,
LocaleProps
} from './locale';
import {SchemaCollection, SchemaObject, SchemaTpl} from './Schema';
import {result} from 'lodash';
import {envOverwrite} from './envOverwrite';
import {getDefaultLocale, makeTranslator, LocaleProps} from './locale';
import ScopedRootRenderer, {RootRenderProps} from './Root';
import {HocStoreFactory} from './WithStore';
export interface TestFunc {
(
@ -148,10 +107,6 @@ export interface RendererProps extends ThemeProps, LocaleProps {
[propName: string]: any;
}
export interface renderChildProps extends Partial<RendererProps> {
env: RendererEnv;
}
export type RendererComponent = React.ComponentType<RendererProps> & {
propsList?: Array<any>;
};
@ -165,12 +120,6 @@ export interface RenderSchemaFilter {
(schema: Schema, renderer: RendererConfig, props?: any): Schema;
}
export interface RootRenderProps {
location?: Location;
theme?: string;
[propName: string]: any;
}
export interface RenderOptions {
session?: string;
fetcher?: (config: fetcherConfig) => Promise<fetcherResult>;
@ -204,8 +153,6 @@ export interface fetcherConfig {
config?: any;
}
export type ReactElement = React.ReactNode[] | JSX.Element | null | false;
const renderers: Array<RendererConfig> = [];
const rendererNames: Array<string> = [];
const schemaFilters: Array<RenderSchemaFilter> = [];
@ -291,682 +238,7 @@ export function unRegisterRenderer(config: RendererConfig | string) {
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 &&
(schema.detectField === '&' ? props : props[schema.detectField || 'data']);
const exprProps = detectData
? getExprProperties(schema, detectData, undefined, props)
: 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) {
// @ts-ignore
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;
}
export 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: query
},
data
)
: data;
// 根据环境覆盖 schema这个要在最前面做不然就无法覆盖 validations
envOverwrite(schema, locale);
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;
schema: any;
path: string;
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 &&
nextProps.schema &&
(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, skipResolve = false): any {
let schema = props.schema;
let path = props.$path;
if (schema && schema.$ref) {
schema = {
...props.resolveDefinitions(schema.$ref),
...schema
};
path = path.replace(/(?!.*\/).*/, schema.type);
}
if (!skipResolve) {
const rendererResolver = props.env.rendererResolver || resolveRenderer;
this.renderer = rendererResolver(path, schema, props);
}
return {path, 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;
if (schema && schema.$ref) {
const result = this.resolveRenderer(this.props, true);
schema = result.schema;
$path = result.path;
}
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 === null) {
return null;
}
if (schema.$ref) {
const result = this.resolveRenderer(this.props, true);
schema = result.schema;
$path = result.path;
}
const theme = this.props.env.theme;
if (Array.isArray(schema)) {
return renderChildren($path, schema as any, 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 (Object.keys(schema).length === 0) {
return null;
} 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;
shouldSyncSuperStore?: (
store: any,
props: any,
prevProps: any
) => boolean | undefined;
}): 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 = rootStore.addStore({
id: guid(),
path: this.props.$path,
storeType: renderer.storeType,
parentId: this.props.store ? this.props.store.id : ''
}) as IIRendererStore;
this.store = store;
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;
const shouldSync = renderer.shouldSyncSuperStore?.(
store,
nextProps,
props
);
if (shouldSync === false) {
return;
}
if (renderer.extendsData === false) {
if (
shouldSync === true ||
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 (
shouldSync === true ||
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 (
(shouldSync === true ||
!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
}),
store.storeType === 'FormStore' &&
props.store?.storeType === 'CRUDStore'
);
} else if (
nextProps.scope &&
nextProps.data === nextProps.store!.data &&
(shouldSync === true || 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);
// @ts-ignore
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, undefined, rest);
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) {
export function loadRenderer(schema: Schema, path: string) {
return (
<Alert level="danger">
<p>Error: </p>
@ -978,17 +250,6 @@ function loadRenderer(schema: Schema, path: string) {
);
}
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,
@ -1143,7 +404,7 @@ export function clearStoresCache(
}
// 当然也可以直接这样更新。
// 主要是有时候第一创建的时候并没有准备多少接口,
// 主要是有时候第一创建的时候并没有准备多少接口,
// 可以后续补充点,比如 amis 自己实现的prompt 里面的表单。
export function updateEnv(options: Partial<RenderOptions>, session = 'global') {
options = {
@ -1171,8 +432,7 @@ export function updateEnv(options: Partial<RenderOptions>, session = 'global') {
let cache: {[propName: string]: RendererConfig} = {};
export function resolveRenderer(
path: string,
schema?: Schema,
props?: any
schema?: Schema
): null | RendererConfig {
if (cache[path]) {
return cache[path];
@ -1185,6 +445,7 @@ export function resolveRenderer(
renderers.some(item => {
let matched = false;
// 不应该搞得这么复杂的,让每个渲染器唯一 id自己不晕别人用起来也不晕。
if (typeof item.test === 'function') {
matched = item.test(path, schema, resolveRenderer);
} else if (item.test instanceof RegExp) {
@ -1198,7 +459,8 @@ export function resolveRenderer(
return matched;
});
// 只能缓存纯正则表达式的后者方法中没有用到第二个参数的,因为自定义 test 函数的有可能依赖 schema 的结果
// 只能缓存纯正则表达式的后者方法中没有用到第二个参数的,
// 因为自定义 test 函数的有可能依赖 schema 的结果
if (
renderer !== null &&
((renderer as RendererConfig).test instanceof RegExp ||
@ -1219,53 +481,6 @@ export function getRendererByName(name: string) {
return find(renderers, item => item.name === name);
}
export function withRootStore<
T extends React.ComponentType<
React.ComponentProps<T> & {
rootStore: IRendererStore;
}
>
>(ComposedComponent: T) {
type OuterProps = JSX.LibraryManagedAttributes<
T,
Omit<React.ComponentProps<T>, 'rootStore'>
>;
const result = hoistNonReactStatic(
class extends React.Component<OuterProps> {
static displayName = `WithRootStore(${
ComposedComponent.displayName || ComposedComponent.name
})`;
static contextType = RootStoreContext;
static ComposedComponent = ComposedComponent;
render() {
const rootStore = this.context;
const injectedProps: {
rootStore: IRendererStore;
} = {
rootStore
};
return (
<ComposedComponent
{...(this.props as JSX.LibraryManagedAttributes<
T,
React.ComponentProps<T>
>)}
{...injectedProps}
/>
);
}
},
ComposedComponent
);
return result as typeof result & {
ComposedComponent: T;
};
}
setRenderSchemaFn((controls, value, callback, scopeRef, theme) => {
return render(
{

View File

@ -2,12 +2,7 @@ import React from 'react';
import {IFormStore, IFormItemStore} from '../../store/form';
import debouce from 'lodash/debounce';
import {
RendererProps,
Renderer,
RootStoreContext,
withRootStore
} from '../../factory';
import {RendererProps, Renderer} from '../../factory';
import {ComboStore, IComboStore, IUniqueGroup} from '../../store/combo';
import {
anyChanged,
@ -23,6 +18,7 @@ import {reaction} from 'mobx';
import {FormItemStore} from '../../store/formItem';
import {isAlive} from 'mobx-state-tree';
import {observer} from 'mobx-react';
import {withRootStore} from '../../WithRootStore';
export interface ControlProps extends RendererProps {
control: {

View File

@ -7,8 +7,7 @@ import {
RendererProps,
registerRenderer,
TestFunc,
RendererConfig,
HocStoreFactory
RendererConfig
} from '../../factory';
import {anyChanged, ucFirst, getWidthRate, autobind} from '../../utils/helper';
import {observer} from 'mobx-react';
@ -79,6 +78,7 @@ import {UUIDControlSchema} from './UUID';
import {PlainSchema} from '../Plain';
import {TplSchema} from '../Tpl';
import {DividerSchema} from '../Divider';
import {HocStoreFactory} from '../../WithStore';
export type FormControlType =
| 'array'

View File

@ -30,6 +30,7 @@ import {
SchemaMessage
} from '../Schema';
import {SchemaRemark} from './Remark';
import {onAction} from 'mobx-state-tree';
/**
* amis Page https://baidu.gitee.io/amis/docs/components/page
@ -255,27 +256,9 @@ export default class Page extends React.Component<PageProps> {
throwErrors: boolean = false,
delegate?: IScopedContext
) {
const {env, store, messages} = this.props;
const {env, store, messages, onAction} = this.props;
if (
action.actionType === 'url' ||
action.actionType === 'link' ||
action.actionType === 'jump'
) {
if (!env || !env.jumpTo) {
throw new Error('env.jumpTo is required!');
}
env.jumpTo(
filter(
(action.to || action.url || action.link) as string,
ctx,
'| raw'
),
action,
ctx
);
} else if (action.actionType === 'dialog') {
if (action.actionType === 'dialog') {
store.setCurrentAction(action);
store.openDialog(ctx);
} else if (action.actionType === 'drawer') {
@ -303,11 +286,8 @@ export default class Page extends React.Component<PageProps> {
action.reload && this.reloadTarget(action.reload, store.data);
})
.catch(() => {});
} else if (
action.actionType === 'copy' &&
(action.content || action.copy)
) {
env.copy && env.copy(filter(action.content || action.copy, ctx, '| raw'));
} else {
onAction(e, action, ctx, throwErrors, delegate || this.context);
}
}

View File

@ -497,12 +497,11 @@ export const FormItemStore = StoreNode.named('FormItemStore')
} catch (e) {
const env = getEnv(self);
self.loading = false;
if (!isAlive(self) || self.disposed) {
return;
}
self.loading = false;
if (env.isCancel(e)) {
return;
}

View File

@ -22,6 +22,7 @@ import {FormItemStore} from './formItem';
import {addStore, getStoreById, getStores, removeStore} from './manager';
import {PaginationStore} from './pagination';
import {AppStore} from './app';
import {RootStore} from './root';
setLivelynessChecking(
process.env.NODE_ENV === 'production' ? 'ignore' : 'error'
@ -76,6 +77,10 @@ export const RendererStore = types
parentId?: string;
[propName: string]: any;
}): IStoreNode {
if (store.storeType === RootStore.name) {
return addStore(RootStore.create(store, getEnv(self)));
}
const factory = find(
allowedStoreList,
item => item.name === store.storeType

44
src/store/root.ts Normal file
View File

@ -0,0 +1,44 @@
import {Instance, types} from 'mobx-state-tree';
import qs from 'qs';
import {createObject} from '../utils/helper';
import {ServiceStore} from './service';
export const RootStore = ServiceStore.named('RootStore')
.props({
runtimeError: types.frozen(),
runtimeErrorStack: types.frozen(),
query: types.frozen()
})
.views(self => ({
get downStream() {
return self.query
? createObject(
{
...(self.data && self.data.__super ? self.data.__super : null),
...self.query,
__query: self.query
},
self.data
)
: self.data;
}
}))
.actions(self => ({
setRuntimeError(error: any, errorStack: any) {
self.runtimeError = error;
self.runtimeErrorStack = errorStack;
},
updateLocation(location?: any) {
const query =
(location && location.query) ||
(location &&
location.search &&
qs.parse(location.search.substring(1))) ||
(window.location.search &&
qs.parse(window.location.search.substring(1)));
self.query = query;
}
}));
export type IRootStore = Instance<typeof RootStore>;