This commit is contained in:
liaoxuezhi 2019-12-30 21:58:19 +08:00
commit 07df519931
19 changed files with 688 additions and 614 deletions

View File

@ -12,6 +12,7 @@ CRUD 支持三种模式:`table`、`cards`、`list`,默认为 `table`。
| className | `string` | | 表格外层 Dom 的类名 |
| [api](#api) | [Api](./Types.md#Api) | | CRUD 用来获取列表数据的 api。 |
| loadDataOnce | `boolean` | | 是否一次性加载所有数据(前端分页) |
| loadDataOnceFetchOnFilter | `boolean` | `true` | 在开启loadDataOnce时filter时是否去重新请求api |
| source | `string` | | 数据映射接口返回某字段的值,不设置会默认把接口返回的`items`或者`rows`填充进`mode`区域 |
| filter | [Form](./Form/Form.md) | | 设置过滤器,当该表单提交后,会把数据带给当前 `mode` 刷新列表。 |
| filterTogglable | `boolean` | `false` | 是否可显隐过滤器 |

File diff suppressed because it is too large Load Diff

View File

@ -42,6 +42,7 @@
"hoist-non-react-statics": "3.3.0",
"jquery": "^3.2.1",
"keycode": "^2.1.9",
"lodash": "^4.17.15",
"match-sorter": "2.2.1",
"mobx": "^4.5.0",
"mobx-react": "^6.1.4",
@ -72,8 +73,7 @@
"sortablejs": "1.10.0",
"tslib": "^1.10.0",
"uncontrollable": "4.1.0",
"video-react": "0.9.4",
"lodash": "^4.17.15"
"video-react": "0.9.4"
},
"devDependencies": {
"@types/dom-helpers": "^3.4.1",

View File

@ -90,6 +90,20 @@
animation-name: bounceOut;
}
&-close {
position: absolute;
top: $gap-xs;
right: $gap-sm;
color: $white;
line-height: 1;
opacity: 0.8;
&:hover {
color: $white;
opacity: 1;
}
}
&-title {
display: $Toast-display;
font-size: $fontSizeMd;

View File

@ -6,6 +6,7 @@
import React from 'react';
import VisibilitySensor = require('react-visibility-sensor');
import Spinner from './Spinner';
export interface LazyComponentProps {
component?: React.ReactType;
@ -27,7 +28,7 @@ export default class LazyComponent extends React.Component<
LazyComponentState
> {
static defaultProps = {
placeholder: '加载中...',
placeholder: <Spinner />,
unMountOnHidden: false,
partialVisibility: true
};
@ -89,6 +90,7 @@ export default class LazyComponent extends React.Component<
childProps,
visiblilityProps,
partialVisibility,
children,
...rest
} = this.props;
@ -105,6 +107,8 @@ export default class LazyComponent extends React.Component<
<div className="visibility-sensor">
{Component && visible ? (
<Component {...rest} {...childProps} />
) : children && visible ? (
children
) : (
placeholder
)}
@ -126,6 +130,8 @@ export default class LazyComponent extends React.Component<
} else if (Component) {
// 只监听不可见到可见,一旦可见了,就销毁检查。
return <Component {...rest} {...childProps} />;
} else if (children) {
return children;
}
return <div>{placeholder}</div>;

View File

@ -13,8 +13,14 @@ import Transition, {
import React from 'react';
import cx from 'classnames';
import Html from './Html';
import {uuid, autobind} from '../utils/helper';
import {ClassNamesFn, themeable} from '../theme';
import {uuid, autobind, noop} from '../utils/helper';
import {ClassNamesFn, themeable, classnames} from '../theme';
import {Icon} from './icons';
interface Config {
closeButton?: boolean;
timeout?: number;
}
const fadeStyles: {
[propName: string]: string;
@ -25,12 +31,6 @@ const fadeStyles: {
};
let toastRef: any = null;
let config: {
closeButton?: boolean;
timeOut?: number;
extendedTimeOut?: number;
} = {};
const show = (
content: string,
title: string = '',
@ -40,7 +40,7 @@ const show = (
if (!toastRef || !toastRef[method]) {
return;
}
toastRef[method](content, title || '', {...config, ...conf});
toastRef[method](content, title || '', {...conf});
};
interface ToastComponentProps {
@ -52,14 +52,13 @@ interface ToastComponentProps {
| 'bottom-left'
| 'bottom-right';
closeButton: boolean;
timeOut: number;
extendedTimeOut: number;
timeout: number;
classPrefix: string;
classnames: ClassNamesFn;
className?: string;
}
interface Item {
interface Item extends Config {
title?: string;
body: string;
level: 'info' | 'success' | 'error' | 'warning';
@ -76,12 +75,11 @@ export class ToastComponent extends React.Component<
> {
static defaultProps: Pick<
ToastComponentProps,
'position' | 'closeButton' | 'timeOut' | 'extendedTimeOut'
'position' | 'closeButton' | 'timeout'
> = {
position: 'top-right',
closeButton: false,
timeOut: 5000,
extendedTimeOut: 3000
timeout: 5000
};
// 当前ToastComponent是否真正render了
@ -90,15 +88,6 @@ export class ToastComponent extends React.Component<
items: []
};
componentWillMount() {
const {closeButton, timeOut, extendedTimeOut} = this.props;
config = {
closeButton,
timeOut,
extendedTimeOut
};
}
componentDidMount() {
this.hasRendered = true;
toastRef = this;
@ -157,27 +146,27 @@ export class ToastComponent extends React.Component<
return null;
}
const {classPrefix: ns, className, timeOut, position} = this.props;
const {classnames: cx, className, timeout, position} = this.props;
const items = this.state.items;
return (
<div
className={cx(
`${ns}Toast-wrap ${ns}Toast-wrap--${position.replace(
/\-(\w)/g,
(_, l) => l.toUpperCase()
`Toast-wrap Toast-wrap--${position.replace(/\-(\w)/g, (_, l) =>
l.toUpperCase()
)}`,
className
)}
>
{items.map((item, index) => (
<ToastMessage
classnames={classnames}
key={item.id}
classPrefix={ns}
title={item.title}
body={item.body}
level={item.level || 'info'}
timeOut={timeOut}
timeout={item.timeout ?? timeout}
closeButton={item.closeButton}
onDismiss={this.handleDismissed.bind(this, index)}
/>
))}
@ -192,7 +181,8 @@ interface ToastMessageProps {
title?: string;
body: string;
level: 'info' | 'success' | 'error' | 'warning';
timeOut: number;
timeout: number;
closeButton?: boolean;
position:
| 'top-right'
| 'top-center'
@ -201,7 +191,7 @@ interface ToastMessageProps {
| 'bottom-left'
| 'bottom-right';
onDismiss?: () => void;
classPrefix: string;
classnames: ClassNamesFn;
allowHtml: boolean;
}
@ -209,9 +199,12 @@ interface ToastMessageState {
visible: boolean;
}
export class ToastMessage extends React.Component<ToastMessageProps> {
export class ToastMessage extends React.Component<
ToastMessageProps,
ToastMessageState
> {
static defaultProps = {
timeOut: 5000,
timeout: 5000,
classPrefix: '',
position: 'top-right',
allowHtml: true,
@ -225,15 +218,6 @@ export class ToastMessage extends React.Component<ToastMessageProps> {
// content: React.RefObject<HTMLDivElement>;
timer: NodeJS.Timeout;
mounted: boolean = false;
constructor(props: ToastMessageProps) {
super(props);
// this.content = React.createRef();
this.handleMouseEnter = this.handleMouseEnter.bind(this);
this.handleMouseLeave = this.handleMouseLeave.bind(this);
this.handleEntered = this.handleEntered.bind(this);
this.close = this.close.bind(this);
}
componentDidMount() {
this.mounted = true;
@ -247,21 +231,25 @@ export class ToastMessage extends React.Component<ToastMessageProps> {
this.mounted = false;
}
@autobind
handleMouseEnter() {
clearTimeout(this.timer);
}
@autobind
handleMouseLeave() {
this.handleEntered();
}
@autobind
handleEntered() {
const timeOut = this.props.timeOut;
if (this.mounted) {
this.timer = setTimeout(this.close, timeOut);
const timeout = this.props.timeout;
if (this.mounted && timeout) {
this.timer = setTimeout(this.close, timeout);
}
}
@autobind
close() {
clearTimeout(this.timer);
this.setState({
@ -272,8 +260,8 @@ export class ToastMessage extends React.Component<ToastMessageProps> {
render() {
const {
onDismiss,
classPrefix: ns,
position,
classnames: cx,
closeButton,
title,
body,
allowHtml,
@ -290,26 +278,20 @@ export class ToastMessage extends React.Component<ToastMessageProps> {
onExited={onDismiss}
>
{(status: string) => {
// if (status === ENTERING) {
// // force reflow
// // 由于从 mount 进来到加上 in 这个 class 估计是时间太短上次的样式还没应用进去所以这里强制reflow一把。
// // 否则看不到动画。
// this.content.current && this.content.current.offsetWidth;
// }
return (
<div
// ref={this.content}
className={cx(
`${ns}Toast ${ns}Toast--${level}`,
fadeStyles[status]
)}
className={cx(`Toast Toast--${level}`, fadeStyles[status])}
onMouseEnter={this.handleMouseEnter}
onMouseLeave={this.handleMouseLeave}
onClick={this.close}
onClick={closeButton ? noop : this.close}
>
{title ? <div className={`${ns}Toast-title`}>{title}</div> : null}
<div className={`${ns}Toast-body`}>
{closeButton ? (
<a onClick={this.close} className={cx(`Toast-close`)}>
<Icon icon="close" className="icon" />
</a>
) : null}
{title ? <div className={cx('Toast-title')}>{title}</div> : null}
<div className={cx('Toast-body')}>
{allowHtml ? <Html html={body} /> : body}
</div>
</div>

View File

@ -461,10 +461,14 @@ class SchemaRenderer extends React.Component<SchemaRendererProps, any> {
// 限制:只有 schema 除外的 props 变化,或者 schema 里面的某个成员值发生变化才更新。
shouldComponentUpdate(nextProps: SchemaRendererProps) {
const props = this.props;
const list: Array<string> = difference(Object.keys(nextProps), ['schema']);
const list: Array<string> = difference(Object.keys(nextProps), [
'schema',
'scope'
]);
if (
difference(Object.keys(props), ['schema']).length !== list.length ||
difference(Object.keys(props), ['schema', 'scope']).length !==
list.length ||
anyChanged(list, this.props, nextProps)
) {
return true;
@ -763,7 +767,11 @@ export function HocStoreFactory(renderer: {
...store.data
})
);
} else if (props.scope !== nextProps.scope) {
} else if (
nextProps.scope &&
nextProps.data === nextProps.store!.data &&
props.data !== nextProps.data
) {
store.initData(
createObject(nextProps.scope, {
// ...nextProps.data,

View File

@ -69,6 +69,7 @@ interface CRUDProps extends RendererProps {
syncResponse2Query?: boolean;
keepItemSelectionOnPageChange?: boolean;
loadDataOnce?: boolean;
loadDataOnceFetchOnFilter?: boolean; // 在开启loadDataOnce时filter时是否去重新请求api
source?: string;
}
@ -114,6 +115,7 @@ export default class CRUD extends React.Component<CRUDProps, any> {
'labelTpl',
'labelField',
'loadDataOnce',
'loadDataOnceFetchOnFilter',
'source'
];
static defaultProps = {
@ -129,7 +131,8 @@ export default class CRUD extends React.Component<CRUDProps, any> {
silentPolling: false,
filterTogglable: false,
filterDefaultVisible: true,
loadDataOnce: false
loadDataOnce: false,
loadDataOnceFetchOnFilter: true
};
control: any;
@ -379,7 +382,8 @@ export default class CRUD extends React.Component<CRUDProps, any> {
onAction,
messages,
pageField,
stopAutoRefreshWhenModalIsOpen
stopAutoRefreshWhenModalIsOpen,
env
} = this.props;
if (!selectedItems.length && action.requireSelected !== false) {
@ -430,6 +434,9 @@ export default class CRUD extends React.Component<CRUDProps, any> {
action.reload
? this.reloadTarget(action.reload, store.data)
: this.search({[pageField || 'page']: 1}, undefined, true);
action.redirect &&
env.jumpTo(filter(action.redirect, store.data), action);
})
.catch(() => null);
} else if (onAction) {
@ -681,6 +688,7 @@ export default class CRUD extends React.Component<CRUDProps, any> {
pickerMode,
env,
loadDataOnce,
loadDataOnceFetchOnFilter,
source
} = this.props;
@ -716,6 +724,7 @@ export default class CRUD extends React.Component<CRUDProps, any> {
autoAppend: true,
forceReload,
loadDataOnce,
loadDataOnceFetchOnFilter,
source,
silent,
pageField,

View File

@ -58,7 +58,7 @@ export default class ChainedSelectControl extends React.Component<
componentDidUpdate(prevProps: ChainedSelectProps) {
const props = this.props;
if (props.value !== prevProps.value) {
if (props.formInited && props.value !== prevProps.value) {
this.loadMore();
}
}

View File

@ -21,20 +21,7 @@ import Select from '../../components/Select';
import {dataMapping} from '../../utils/tpl-builtin';
import {isEffectiveApi} from '../../utils/api';
import {Alert2} from '../../components';
import memoize from 'fast-memoize';
const formatValue = memoize(
(value: any, index: number, data: any) => {
return createObject(
extendObject(data, {index, __index: index, ...data}),
value
);
},
{
serializer: (args: Array<any>) => JSON.stringify(args.slice(0, 2))
}
);
import memoize from 'lodash/memoize';
export interface Condition {
test: string;
controls: Array<Schema>;
@ -73,6 +60,8 @@ export interface ComboProps extends FormControlProps {
tabsMode: boolean;
tabsStyle: '' | 'line' | 'card' | 'radio';
tabsLabelTpl?: string;
lazyLoad?: boolean;
strictMode?: boolean;
messages?: {
validateFailed?: string;
minLengthValidateFailed?: string;
@ -116,7 +105,9 @@ export default class ComboControl extends React.Component<ComboProps> {
'noBorder',
'conditions',
'tabsMode',
'tabsStyle'
'tabsStyle',
'lazyLoad',
'strictMode'
];
subForms: Array<any> = [];
@ -188,6 +179,8 @@ export default class ComboControl extends React.Component<ComboProps> {
this.toDispose.forEach(fn => fn());
this.toDispose = [];
this.memoizedFormatValue.cache.clear?.();
this.makeFormRef.cache.clear?.();
}
getValueAsArray(props = this.props) {
@ -540,8 +533,19 @@ export default class ComboControl extends React.Component<ComboProps> {
}
}
memoizedFormatValue = memoize(
(strictMode: boolean, value: any, index: number, data: any) => {
return createObject(
extendObject(data, {index, __index: index, ...data}),
value
);
},
(strictMode: boolean, ...args: Array<any>) =>
strictMode ? JSON.stringify(args.slice(0, 2)) : JSON.stringify(args)
);
formatValue(value: any, index: number) {
const {flat, data, store} = this.props;
const {flat, data, strictMode} = this.props;
if (flat) {
value = {
@ -551,7 +555,7 @@ export default class ComboControl extends React.Component<ComboProps> {
value = value || this.defaultValue;
return formatValue(value, index, data);
return this.memoizedFormatValue(strictMode !== false, value, index, data);
}
pickCondition(value: any): Condition | null {
@ -818,7 +822,8 @@ export default class ComboControl extends React.Component<ComboProps> {
dragIcon,
deleteIcon,
noBorder,
conditions
conditions,
lazyLoad
} = this.props;
let controls = this.props.controls;
@ -943,6 +948,7 @@ export default class ComboControl extends React.Component<ComboProps> {
onInit: this.handleFormInit,
onAction: this.handleAction,
ref: this.makeFormRef(index),
lazyLoad,
canAccessSuperData,
value: undefined,
formItemValue: undefined

View File

@ -1023,7 +1023,7 @@ export default class FileControl extends React.Component<FileProps, FileState> {
{failed ? (
<div className={cx('FileControl-sum')}>
{uploaded}{failed}
{uploaded}{failed}
<a onClick={this.retry}></a>
</div>
) : null}

View File

@ -86,8 +86,8 @@ export default class MatrixCheckbox extends React.Component<
rows: nextProps.rows || []
});
} else if (
nextProps.source !== props.source ||
props.data !== nextProps.data
nextProps.formInited &&
(nextProps.source !== props.source || props.data !== nextProps.data)
) {
let prevApi = buildApi(props.source as string, props.data as object, {
ignoreData: true

View File

@ -3,7 +3,7 @@
* ListButtonGroup
*/
import {Api, Schema} from '../../types';
import {isEffectiveApi, isApiOutdated} from '../../utils/api';
import {isEffectiveApi, isApiOutdated, isValidApi} from '../../utils/api';
import {isAlive} from 'mobx-state-tree';
import {
anyChanged,
@ -158,15 +158,6 @@ export function registerOptionsControl(config: OptionsConfig) {
let loadOptions: boolean = initFetch !== false;
if (isPureVariable(source as string) && formItem) {
formItem.setOptions(
normalizeOptions(
resolveVariableAndFilter(source as string, data, '| raw') || []
)
);
loadOptions = false;
}
if (formItem && joinValues === false && defaultValue) {
const selectedOptions = extractValue
? formItem
@ -210,7 +201,7 @@ export function registerOptionsControl(config: OptionsConfig) {
const props = this.props;
const formItem = props.formItem as IFormItemStore;
if (!formItem) {
if (!formItem || !props.formInited) {
return;
} else if (!prevProps.formItem) {
// todo 优化 name 变化情况。
@ -242,6 +233,7 @@ export function registerOptionsControl(config: OptionsConfig) {
prevOptions !== options &&
formItem.setOptions(normalizeOptions(options || []));
} else if (
isEffectiveApi(props.source, props.data) &&
isApiOutdated(
prevProps.source,
props.source,
@ -440,7 +432,14 @@ export function registerOptionsControl(config: OptionsConfig) {
reload() {
const {source, formItem, data, onChange} = this.props;
if (!formItem || !isEffectiveApi(source, data)) {
if (formItem && isPureVariable(source as string)) {
formItem.setOptions(
normalizeOptions(
resolveVariableAndFilter(source as string, data, '| raw') || []
)
);
return;
} else if (!formItem || !isEffectiveApi(source, data)) {
return;
}

View File

@ -31,6 +31,8 @@ import qs = require('qs');
import {dataMapping} from '../../utils/tpl-builtin';
import {isApiOutdated, isEffectiveApi} from '../../utils/api';
import Spinner from '../../components/Spinner';
import {LazyComponent} from '../../components';
import {isAlive} from 'mobx-state-tree';
export type FormGroup = FormSchema & {
title?: string;
className?: string;
@ -88,6 +90,7 @@ export interface FormProps extends RendererProps, FormSchema {
persistData: boolean; // 开启本地缓存
clearPersistDataAfterSubmit: boolean; // 提交成功后清空本地缓存
trimValues?: boolean;
lazyLoad?: boolean;
onInit?: (values: object, props: any) => any;
onReset?: (values: object) => void;
onSubmit?: (values: object, action: any) => any;
@ -155,7 +158,8 @@ export default class Form extends React.Component<FormProps, object> {
'onFinished',
'canAccessSuperData',
'lazyChange',
'formLazyChange'
'formLazyChange',
'lazyLoad'
];
hooks: {
@ -317,7 +321,7 @@ export default class Form extends React.Component<FormProps, object> {
store.parentStore.storeType === 'ComboStore'
) {
const combo = store.parentStore as IComboStore;
combo.removeForm(store);
isAlive(combo) && combo.removeForm(store);
}
}
@ -1058,7 +1062,8 @@ export default class Form extends React.Component<FormProps, object> {
actionsClassName,
bodyClassName,
classnames: cx,
affixFooter
affixFooter,
lazyLoad
} = this.props;
let body: JSX.Element = this.renderBody();
@ -1086,6 +1091,10 @@ export default class Form extends React.Component<FormProps, object> {
) as JSX.Element;
}
if (lazyLoad) {
body = <LazyComponent>{body}</LazyComponent>;
}
return body;
}
}

View File

@ -1,4 +1,4 @@
import {types, SnapshotIn} from 'mobx-state-tree';
import {types, SnapshotIn, isAlive} from 'mobx-state-tree';
import {iRendererStore} from './iRenderer';
import {FormItemStore, IFormItemStore} from './formItem';
import {FormStore, IFormStore} from './form';
@ -93,7 +93,9 @@ export const ComboStore = iRendererStore
}
function removeForm(form: IFormStore) {
self.forms.remove(form);
// form 可能再它自己销毁的是已经被移除了。因为调用的是 destroy所以 self.forms 里面也被一起移除。
// 再来尝试移除,会报错。
self.forms.includes(form) && self.forms.remove(form);
}
function setActiveKey(key: number) {

View File

@ -110,6 +110,7 @@ export const CRUDStore = ServiceStore.named('CRUDStore')
options?: fetchOptions & {
forceReload?: boolean;
loadDataOnce?: boolean; // 配置数据是否一次性加载,如果是这样,由前端来完成分页,排序等功能。
loadDataOnceFetchOnFilter?: boolean; // 在开启loadDataOnce时filter时是否去重新请求api
source?: string; // 支持自定义属于映射,默认不配置,读取 rows 或者 items
loadDataMode?: boolean;
syncResponse2Query?: boolean;
@ -120,6 +121,7 @@ export const CRUDStore = ServiceStore.named('CRUDStore')
options: fetchOptions & {
forceReload?: boolean;
loadDataOnce?: boolean; // 配置数据是否一次性加载,如果是这样,由前端来完成分页,排序等功能。
loadDataOnceFetchOnFilter?: boolean; // 在开启loadDataOnce时filter时是否去重新请求api
source?: string; // 支持自定义属于映射,默认不配置,读取 rows 或者 items
loadDataMode?: boolean;
syncResponse2Query?: boolean;
@ -127,7 +129,8 @@ export const CRUDStore = ServiceStore.named('CRUDStore')
) {
try {
if (
options.forceReload === false &&
(options.forceReload === false ||
options.loadDataOnceFetchOnFilter === false) &&
options.loadDataOnce &&
self.total
) {

View File

@ -1,4 +1,4 @@
import {types, getRoot, Instance, destroy} from 'mobx-state-tree';
import {types, getRoot, Instance, destroy, isAlive} from 'mobx-state-tree';
import {extendObject, createObject} from '../utils/helper';
import {IRendererStore} from './index';
import {dataMapping} from '../utils/tpl-builtin';
@ -26,7 +26,8 @@ export const iRendererStore = types
return {
// todo 不能自己引用自己
get parentStore(): any {
return self.parentId &&
return isAlive(self) &&
self.parentId &&
getRoot(self) &&
(getRoot(self) as IRendererStore).storeType === 'RendererStore'
? (getRoot(self) as IRendererStore).stores.get(self.parentId)

View File

@ -80,6 +80,10 @@ export function syncDataFromSuper(
if (superObject || prevSuperObject) {
keys.forEach(key => {
if (!key) {
return;
}
if (
((superObject && typeof superObject[key] !== 'undefined') ||
(prevSuperObject && typeof prevSuperObject[key] !== 'undefined')) &&

View File

@ -351,11 +351,13 @@ export const resolveVariable = (path: string, data: any = {}): any => {
}, data);
};
export const isPureVariable = (path: string) =>
/^\$(?:([a-z0-9_.]+)|{[^}{]+})$/.test(path);
export const isPureVariable = (path?: any) =>
typeof path === 'string'
? /^\$(?:([a-z0-9_.]+)|{[^}{]+})$/.test(path)
: false;
export const resolveVariableAndFilter = (
path: string,
path?: string,
data: object = {},
defaultFilter: string = '| html'
): any => {