Pick combo 修改相关 (#9878)

* fix: 修复 table2 树形数据展示对应错误问题

* fix: 修复 combo 同步父级数据可能存在展示值和实际值不一致的问题 Close: #8773 (#8831)

* fix: 修复 combo 中有 pipeIn & pipeOut 场景时报错 Close: #8970

* fix: 修复 combo tabs 模式新成员中有必填字段未填写也能通过校验的问题

* fix: 修复 combo 可能无限触发 onChange 的问题

* fix: 修复 combo 同步父级数据可能存在展示值和实际值不一致的问题 Close: #8773

* fix: 修复 combo 中有 pipeIn & pipeOut 场景时报错 Close: #8970 (#8980)

* fix: 修复页面设计器重复执行onChange的问题

* fix: 修复数据下发同步问题 (#9625)

* fix: 修复 crud 重置失效的问题 Close: #9686 (#9693)

* fix: crud2条件查询表单重置失效 (#9706)

* chore: combo 中减少表单项重绘

* fix typecheck error

---------

Co-authored-by: wutong25 <wutong25@baidu.com>
Co-authored-by: walkin <wyc19966@hotmail.com>
This commit is contained in:
liaoxuezhi 2024-03-26 14:20:19 +08:00 committed by GitHub
parent b36586253b
commit 6d3cac5920
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
15 changed files with 379 additions and 87 deletions

View File

@ -64,7 +64,8 @@ export const RENDERER_TRANSMISSION_OMIT_PROPS = [
'label',
'renderLabel',
'trackExpression',
'editorSetting'
'editorSetting',
'updatePristineAfterStoreDataReInit'
];
const componentCache: SimpleMap = new SimpleMap();

View File

@ -207,7 +207,8 @@ export function HocStoreFactory(renderer: {
...(store.hasRemoteData ? store.data : null), // todo 只保留 remote 数据
...this.formatData(props.defaultData),
...this.formatData(props.data)
})
}),
props.updatePristineAfterStoreDataReInit === false
);
}
} else if (
@ -234,7 +235,8 @@ export function HocStoreFactory(renderer: {
store,
props.syncSuperStore === true
)
)
),
props.updatePristineAfterStoreDataReInit === false
);
} else if (props.data && (props.data as any).__super) {
store.initData(
@ -250,16 +252,20 @@ export function HocStoreFactory(renderer: {
props.store?.storeType === 'ComboStore'
? undefined
: syncDataFromSuper(
props.data,
{...store.data, ...props.data},
(props.data as any).__super,
(prevProps.data as any).__super,
store,
false
)
)
),
props.updatePristineAfterStoreDataReInit === false
);
} else {
store.initData(createObject(props.scope, props.data));
store.initData(
createObject(props.scope, props.data),
props.updatePristineAfterStoreDataReInit === false
);
}
} else if (
!props.trackExpression &&
@ -282,8 +288,9 @@ export function HocStoreFactory(renderer: {
...store.data
}),
store.storeType === 'FormStore' &&
prevProps.store?.storeType === 'CRUDStore'
props.updatePristineAfterStoreDataReInit === false ||
(store.storeType === 'FormStore' &&
prevProps.store?.storeType === 'CRUDStore')
);
}
// nextProps.data.__super !== props.data.__super) &&
@ -299,7 +306,8 @@ export function HocStoreFactory(renderer: {
createObject(props.scope, {
// ...nextProps.data,
...store.data
})
}),
props.updatePristineAfterStoreDataReInit === false
);
}
}

View File

@ -50,6 +50,7 @@ import {isAlive} from 'mobx-state-tree';
import type {LabelAlign} from './Item';
import {injectObjectChain} from '../utils';
import {reaction} from 'mobx';
export interface FormHorizontal {
left?: number;
@ -371,6 +372,7 @@ export interface FormProps
onFailed?: (reason: string, errors: any) => any;
onFinished: (values: object, action: any) => any;
onValidate: (values: object, form: any) => any;
onValidChange?: (valid: boolean, props: any) => void; // 表单数据合法性变更
messages: {
fetchSuccess?: string;
fetchFailed?: string;
@ -443,6 +445,8 @@ export default class Form extends React.Component<FormProps, object> {
'onChange',
'onFailed',
'onFinished',
'onValidate',
'onValidChange',
'onSaved',
'canAccessSuperData',
'lazyChange',
@ -460,8 +464,7 @@ export default class Form extends React.Component<FormProps, object> {
[propName: string]: Array<() => Promise<any>>;
} = {};
asyncCancel: () => void;
disposeOnValidate: () => void;
disposeRulesValidate: () => void;
toDispose: Array<() => void> = [];
shouldLoadInitApi: boolean = false;
timer: ReturnType<typeof setTimeout>;
mounted: boolean;
@ -518,6 +521,18 @@ export default class Form extends React.Component<FormProps, object> {
)
);
}
// withStore 里面与上层数据会做同步
// 这个时候变更的数据没有同步 onChange 出去,出现数据不一致的问题。
// https://github.com/baidu/amis/issues/8773
this.toDispose.push(
reaction(
() => store.initedAt,
() => {
store.inited && this.emitChange(!!this.props.submitOnChange, true);
}
)
);
}
componentDidMount() {
@ -531,6 +546,7 @@ export default class Form extends React.Component<FormProps, object> {
store,
messages: {fetchSuccess, fetchFailed},
onValidate,
onValidChange,
promptPageLeave,
env,
rules
@ -540,7 +556,8 @@ export default class Form extends React.Component<FormProps, object> {
if (onValidate) {
const finalValidate = promisify(onValidate);
this.disposeOnValidate = this.addHook(async () => {
this.toDispose.push(
this.addHook(async () => {
const result = await finalValidate(store.data, store);
if (result && isObject(result)) {
@ -568,11 +585,23 @@ export default class Form extends React.Component<FormProps, object> {
? store.clearRestError()
: store.setRestError(Object.keys(result).map(key => result[key]));
}
});
})
);
}
// 表单校验结果发生变化时,触发 onValidChange
if (onValidChange) {
this.toDispose.push(
reaction(
() => store.valid,
valid => onValidChange(valid, this.props)
)
);
}
if (Array.isArray(rules) && rules.length) {
this.disposeRulesValidate = this.addHook(() => {
this.toDispose.push(
this.addHook(() => {
if (!store.valid) {
return;
}
@ -582,7 +611,8 @@ export default class Form extends React.Component<FormProps, object> {
!evalExpression(item.rule, store.data) &&
store.addRestError(item.message, item.name)
);
});
})
);
}
if (isEffectiveApi(initApi, store.data, initFetch, initFetchOn)) {
@ -654,8 +684,8 @@ export default class Form extends React.Component<FormProps, object> {
// this.lazyHandleChange.flush();
this.lazyEmitChange.cancel();
this.asyncCancel && this.asyncCancel();
this.disposeOnValidate && this.disposeOnValidate();
this.disposeRulesValidate && this.disposeRulesValidate();
this.toDispose.forEach(fn => fn());
this.toDispose = [];
window.removeEventListener('beforeunload', this.beforePageUnload);
this.unBlockRouting?.();
}
@ -984,21 +1014,21 @@ export default class Form extends React.Component<FormProps, object> {
};
}
async emitChange(submit: boolean) {
async emitChange(submit: boolean, skipIfNothingChanges: boolean = false) {
const {onChange, store, submitOnChange, dispatchEvent, data} = this.props;
if (!isAlive(store)) {
return;
}
const diff = difference(store.data, store.pristine);
if (skipIfNothingChanges && !Object.keys(diff).length) {
return;
}
// 提前准备好 onChange 的参数。
// 因为 store.data 会在 await 期间被 WithStore.componentDidUpdate 中的 store.initData 改变。导致数据丢失
const changeProps = [
store.data,
difference(store.data, store.pristine),
this.props
];
const changeProps = [store.data, diff, this.props];
const dispatcher = await dispatchEvent(
'change',
createObject(data, store.data)

View File

@ -34,7 +34,8 @@ export const ComboStore = iRendererStore
minLength: 0,
maxLength: 0,
length: 0,
activeKey: 0
activeKey: 0,
memberValidMap: types.optional(types.frozen(), {})
})
.views(self => {
function getForms() {
@ -166,13 +167,21 @@ export const ComboStore = iRendererStore
self.activeKey = key;
}
function setMemberValid(valid: boolean, index: number) {
self.memberValidMap = {
...self.memberValidMap,
[index]: valid
};
}
return {
config,
setActiveKey,
bindUniuqueItem,
unBindUniuqueItem,
addForm,
onChildStoreDispose
onChildStoreDispose,
setMemberValid
};
});

View File

@ -2052,6 +2052,7 @@
--Tabs-onActive-bg: var(--background);
--Tabs-onActive-borderColor: var(--borderColor);
--Tabs-onActive-color: var(--colors-neutral-text-2);
--Tabs-onError-color: var(--colors-error-5);
--Tabs-onDisabled-color: var(--colors-neutral-text-7);
--Tabs-onHover-borderColor: var(--colors-neutral-line-8);
--Tabs-add-icon-size: #{px2rem(15px)};
@ -4120,6 +4121,7 @@
var(--combo-vertical-right-border-color)
var(--combo-vertical-bottom-border-color)
var(--combo-vertical-left-border-color);
--Combo--vertical-item--onError-borderColor: var(--colors-error-5);
--Combo--vertical-item-borderRadius: var(
--combo-vertical-top-left-border-radius
)

View File

@ -242,6 +242,10 @@
border-color: var(--Tabs-onActive-borderColor);
border-bottom-color: transparent;
}
&.has-error > a:first-child {
color: var(--Tabs-onError-color) !important;
}
}
}

View File

@ -258,6 +258,12 @@
var(--combo-vertical-paddingRight) var(--combo-vertical-paddingBottom)
var(--combo-vertical-paddingLeft);
position: relative;
&.has-error {
border-color: var(
--Combo--vertical-item--onError-borderColor
) !important; // 因为下面的规则权重更高 &:not(.is-disabled) > .#{$ns}Combo-items > .#{$ns}Combo-item:hover
}
}
> .#{$ns}Combo-items > .#{$ns}Combo-item {

View File

@ -50,6 +50,7 @@ export interface TabProps extends ThemeProps {
tip?: string;
tab?: Schema;
className?: string;
tabClassName?: string;
activeKey?: string | number;
reload?: boolean;
mountOnEnter?: boolean;

View File

@ -0,0 +1,85 @@
import {fireEvent, render} from '@testing-library/react';
import '../../../src';
import {render as amisRender} from '../../../src';
import {makeEnv, wait} from '../../helper';
test('paginationWrapper: service + crud', async () => {
const fetcher = jest.fn().mockImplementation(() =>
Promise.resolve({
data: {
status: 0,
data: {
items: [
{
label: '110101',
name: '东城区',
sale: 46861
},
{
label: '110102',
name: '西城区',
sale: 44882
}
]
}
}
})
);
const {container} = render(
amisRender(
{
type: 'page',
body: [
{
type: 'service',
id: 'u:ff652047d747',
api: {
method: 'get',
url: 'https://yapi.baidu-int.com/mock/42601/amis-chart/chart/sales/data2'
},
body: [
{
type: 'pagination-wrapper',
body: [
{
type: 'crud',
source: '${items}',
columns: [
{
name: 'label',
label: '地区',
type: 'text',
id: 'u:331ab3342710'
},
{
name: 'sale',
label: '销售',
type: 'text',
id: 'u:3dba120eda1d'
}
],
id: 'u:b3c77cb44fc8',
perPageAvailable: [10]
}
],
inputName: 'items',
outputName: 'items',
perPage: 20,
position: 'bottom'
}
]
}
]
},
{},
makeEnv({
fetcher
})
)
);
await wait(200);
const tds = [].slice
.call(container.querySelectorAll('tbody td'))
.map((td: any) => td.textContent);
expect(tds).toEqual(['110101', '46861', '110102', '44882']);
});

View File

@ -659,6 +659,17 @@ test('Renderer:combo with canAccessSuperData & strictMode & syncFields', async (
expect(comboInputs[0]!.value).toBe('');
expect(comboInputs[1]!.value).toBe('123');
expect(comboInputs[2]!.value).toBe('123456');
fireEvent.click(submitBtn);
await wait(300);
expect(onSubmit).toHaveBeenCalled();
expect(onSubmit.mock.calls[0][0]).toMatchObject({
super_text: '123456',
combo1: [{}],
combo2: [{super_text: '123'}],
combo3: [{super_text: '123456'}]
});
});
// 9. tabsMode

View File

@ -194,6 +194,7 @@ test('Renderer:inputArray with minLength & maxLength', async () => {
expect(container.querySelector('.cxd-Combo-addBtn')).toBeInTheDocument();
// 最大值
fireEvent.click(container.querySelector('.cxd-Combo-addBtn')!);
await wait(300);
await waitFor(() => {
expect(container.querySelector('a.cxd-Combo-delBtn')).toBeInTheDocument();
expect(

View File

@ -915,8 +915,13 @@ export default class CRUD extends React.Component<CRUDProps, any> {
handleFilterReset(values: object, action: any) {
const {store, syncLocation, env, pageField, perPageField} = this.props;
const resetQuery: any = {};
Object.keys(values).forEach(key => (resetQuery[key] = ''));
store.updateQuery(
store.pristineQuery,
{
...resetQuery,
...store.pristineQuery
},
syncLocation && env && env.updateLocation
? (location: any) => env.updateLocation(location)
: undefined,

View File

@ -465,7 +465,7 @@ export default class CRUD2 extends React.Component<CRUD2Props, any> {
: query;
store.updateQuery(
resetQuery ? this.props.store.pristineQuery : query,
resetQuery ? {...query, ...this.props.store.pristineQuery} : query,
syncLocation && env && env.updateLocation
? (location: any) => env.updateLocation(location, true)
: undefined,
@ -1086,11 +1086,16 @@ export default class CRUD2 extends React.Component<CRUD2Props, any> {
key: index + 'filter',
data: this.props.store.filterData,
onSubmit: (data: any) => this.handleSearch({query: data}),
onReset: () =>
onReset: (data: any) => {
const resetQueries: any = {};
Object.keys(data!).forEach(key => (resetQueries[key] = ''));
this.handleSearch({
query: resetQueries,
resetQuery: true,
replaceQuery: true
})
});
}
})
);
}

View File

@ -7,7 +7,12 @@ import {
FormBaseControl,
resolveEventData,
ApiObject,
FormHorizontal
FormHorizontal,
evalExpressionWithConditionBuilder,
IFormStore,
getVariable,
IFormItemStore,
deleteVariable
} from 'amis-core';
import {ActionObject, Api} from 'amis-core';
import {ComboStore, IComboStore} from 'amis-core';
@ -36,7 +41,11 @@ import {isEffectiveApi, str2AsyncFunction} from 'amis-core';
import {Alert2} from 'amis-ui';
import memoize from 'lodash/memoize';
import {Icon} from 'amis-ui';
import {isAlive} from 'mobx-state-tree';
import {
isAlive,
clone as cloneModel,
destroy as destroyModel
} from 'mobx-state-tree';
import {
FormBaseControlSchema,
SchemaApi,
@ -47,7 +56,6 @@ import {
import {ListenerAction} from 'amis-core';
import type {SchemaTokenizeableString} from '../../Schema';
import isPlainObject from 'lodash/isPlainObject';
import {isMobile} from 'amis-core';
export type ComboCondition = {
test: string;
@ -393,6 +401,7 @@ export default class ComboControl extends React.Component<ComboProps> {
this.dragTipRef = this.dragTipRef.bind(this);
this.flush = this.flush.bind(this);
this.handleComboTypeChange = this.handleComboTypeChange.bind(this);
this.handleSubFormValid = this.handleSubFormValid.bind(this);
this.defaultValue = {
...props.scaffold
};
@ -532,8 +541,11 @@ export default class ComboControl extends React.Component<ComboProps> {
}
getValueAsArray(props = this.props) {
const {flat, joinValues, delimiter, type} = props;
let value = props.value;
const {flat, joinValues, delimiter, type, formItem} = props;
// 因为 combo 多个子表单可能同时发生变化。
// onChagne 触发多次,上次变更还没应用到 props.value 上来,这次触发变更就会包含历史数据,把上次触发的数据给重置成旧的了。
// 通过 props.getValue() 拿到的是最新的
let value = props.getValue();
if (joinValues && flat && typeof value === 'string') {
value = value.split(delimiter || ',');
@ -704,13 +716,32 @@ export default class ComboControl extends React.Component<ComboProps> {
}
handleChange(values: any, diff: any, {index}: any) {
const {flat, store, joinValues, delimiter, disabled, submitOnChange, type} =
this.props;
const {
flat,
store,
joinValues,
delimiter,
disabled,
submitOnChange,
type,
syncFields,
name
} = this.props;
if (disabled) {
return;
}
// 不要递归更新自己
if (Array.isArray(syncFields)) {
syncFields.forEach(field => {
if (name?.startsWith(field)) {
values = {...values};
deleteVariable(values, name);
}
});
}
let value = this.getValueAsArray();
value[index] = flat ? values.flat : {...values};
@ -795,6 +826,11 @@ export default class ComboControl extends React.Component<ComboProps> {
);
}
handleSubFormValid(valid: boolean, {index}: any) {
const {store} = this.props;
store.setMemberValid(valid, index);
}
handleFormInit(values: any, {index}: any) {
const {
syncDefaultValue,
@ -804,9 +840,27 @@ export default class ComboControl extends React.Component<ComboProps> {
formInited,
onChange,
submitOnChange,
setPrinstineValue
setPrinstineValue,
formItem,
name,
syncFields
} = this.props;
// 不要递归更新自己
if (Array.isArray(syncFields)) {
syncFields.forEach(field => {
if (name?.startsWith(field)) {
values = {...values};
deleteVariable(values, name);
}
});
}
// 已经开始验证了,那么打开成员的时候,就要验证一下。
if (formItem?.validated) {
this.subForms[index]?.validate(true, false, false);
}
this.subFormDefaultValues.push({
index,
values,
@ -879,7 +933,13 @@ export default class ComboControl extends React.Component<ComboProps> {
}
validate(): any {
const {messages, nullable, translate: __} = this.props;
const {
messages,
nullable,
value: rawValue,
translate: __,
store
} = this.props;
const value = this.getValueAsArray();
const minLength = this.resolveVariableProps(this.props, 'minLength');
const maxLength = this.resolveVariableProps(this.props, 'maxLength');
@ -894,18 +954,62 @@ export default class ComboControl extends React.Component<ComboProps> {
(messages && messages.maxLengthValidateFailed) || 'Combo.maxLength',
{maxLength}
);
} else if (this.subForms.length && (!nullable || value)) {
return Promise.all(this.subForms.map(item => item.validate())).then(
values => {
} else if (nullable && !rawValue) {
return; // 不校验
} else if (value.length) {
return Promise.all(
value.map(async (values: any, index: number) => {
const subForm = this.subForms[index];
if (subForm) {
return subForm.validate(true, false, false);
} else {
// 那些还没有渲染出来的数据
// 因为有可能存在分页,有可能存在懒加载,所以没办法直接用 subForm 去校验了
const subForm = this.subForms[Object.keys(this.subForms)[0] as any];
if (subForm) {
const form: IFormStore = subForm.props.store;
let valid = false;
for (let formitem of form.items) {
const cloned: IFormItemStore = cloneModel(formitem);
let value: any = getVariable(values, formitem.name, false);
if (formitem.extraName) {
value = [
getVariable(values, formitem.name, false),
getVariable(values, formitem.extraName, false)
];
}
cloned.changeTmpValue(value, 'dataChanged');
valid = await cloned.validate(values);
destroyModel(cloned);
if (valid === false) {
break;
}
}
store.setMemberValid(valid, index);
return valid;
}
}
})
).then(values => {
if (~values.indexOf(false)) {
return __(
(messages && messages.validateFailed) || 'validateFailed'
);
return __((messages && messages.validateFailed) || 'validateFailed');
}
return;
});
} else if (this.subForms.length) {
return Promise.all(
this.subForms.map(item => item.validate(true, false, false))
).then(values => {
if (~values.indexOf(false)) {
return __((messages && messages.validateFailed) || 'validateFailed');
}
);
return;
});
}
}
@ -1251,6 +1355,12 @@ export default class ComboControl extends React.Component<ComboProps> {
// 不能按需渲染,因为 unique 会失效。
mountOnEnter={!hasUnique}
unmountOnExit={false}
className={
store.memberValidMap[index] === false ? 'has-error' : ''
}
tabClassName={
store.memberValidMap[index] === false ? 'has-error' : ''
}
>
{condition && typeSwitchable !== false ? (
<div className={cx('Combo-itemTag')}>
@ -1483,7 +1593,8 @@ export default class ComboControl extends React.Component<ComboProps> {
itemClassName,
itemsWrapperClassName,
static: isStatic,
mobileUI
mobileUI,
store
} = this.props;
let items = this.props.items;
@ -1541,7 +1652,11 @@ export default class ComboControl extends React.Component<ComboProps> {
return (
<div
className={cx(`Combo-item`, itemClassName)}
className={cx(
`Combo-item`,
itemClassName,
store.memberValidMap[index] === false ? 'has-error' : ''
)}
key={this.keys[index]}
>
{!isStatic && !disabled && draggable && thelist.length > 1 ? (
@ -1620,7 +1735,8 @@ export default class ComboControl extends React.Component<ComboProps> {
nullable,
translate: __,
itemClassName,
mobileUI
mobileUI,
store
} = this.props;
let items = this.props.items;
@ -1644,7 +1760,13 @@ export default class ComboControl extends React.Component<ComboProps> {
disabled ? 'is-disabled' : ''
)}
>
<div className={cx(`Combo-item`, itemClassName)}>
<div
className={cx(
`Combo-item`,
itemClassName,
store.memberValidMap[0] === false ? 'has-error' : ''
)}
>
{condition && typeSwitchable !== false ? (
<div className={cx('Combo-itemTag')}>
<label>{__('Combo.type')}</label>
@ -1712,14 +1834,17 @@ export default class ComboControl extends React.Component<ComboProps> {
className: cx(`Combo-form`, formClassName)
},
{
index: 0,
disabled: disabled,
static: isStatic,
data,
onChange: this.handleSingleFormChange,
ref: this.makeFormRef(0),
onValidChange: this.handleSubFormValid,
onInit: this.handleSingleFormInit,
canAccessSuperData,
formStore: undefined
formStore: undefined,
updatePristineAfterStoreDataReInit: false
}
);
} else if (multiple && index !== undefined && index >= 0) {
@ -1744,13 +1869,15 @@ export default class ComboControl extends React.Component<ComboProps> {
onAction: this.handleAction,
onRadioChange: this.handleRadioChange,
ref: this.makeFormRef(index),
onValidChange: this.handleSubFormValid,
canAccessSuperData,
lazyChange: changeImmediately ? false : true,
formLazyChange: false,
value: undefined,
formItemValue: undefined,
formStore: undefined,
...(tabsMode ? {} : {lazyLoad})
...(tabsMode ? {} : {lazyLoad}),
updatePristineAfterStoreDataReInit: false
}
);
}

View File

@ -355,15 +355,12 @@ export default observer((props: TableRowProps) => {
id={item.id}
newIndex={item.newIndex}
isHover={item.isHover}
partial={item.partial}
checked={item.checked}
modified={item.modified}
moved={item.moved}
depth={item.depth}
expandable={item.expandable}
checkdisable={item.checkdisable}
loading={item.loading}
error={item.error}
// data 在 TableRow 里面没有使用,这里写上是为了当列数据变化的时候 TableRow 重新渲染,
// 不是 item.locals 的原因是 item.locals 会变化多次,比如父级上下文变化也会进来,但是 item.data 只会变化一次。
data={canAccessSuperData ? item.locals : item.data}