From c869ea3fbf5b2d91e3dc923cc5721e04645e1cd2 Mon Sep 17 00:00:00 2001 From: 2betop <2betop.cn@gmail.com> Date: Thu, 9 Nov 2023 16:01:32 +0800 Subject: [PATCH] =?UTF-8?q?fix:=20=E4=BF=AE=E5=A4=8D=20combo=20tabs=20?= =?UTF-8?q?=E6=A8=A1=E5=BC=8F=E6=96=B0=E6=88=90=E5=91=98=E4=B8=AD=E6=9C=89?= =?UTF-8?q?=E5=BF=85=E5=A1=AB=E5=AD=97=E6=AE=B5=E6=9C=AA=E5=A1=AB=E5=86=99?= =?UTF-8?q?=E4=B9=9F=E8=83=BD=E9=80=9A=E8=BF=87=E6=A0=A1=E9=AA=8C=E7=9A=84?= =?UTF-8?q?=E9=97=AE=E9=A2=98?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- packages/amis-core/src/renderers/Form.tsx | 127 ++++++++++-------- packages/amis-core/src/store/combo.ts | 13 +- packages/amis-ui/scss/_components.scss | 2 + packages/amis-ui/scss/components/_tabs.scss | 4 + .../amis-ui/scss/components/form/_combo.scss | 6 + packages/amis-ui/src/components/Tabs.tsx | 1 + packages/amis/src/renderers/Form/Combo.tsx | 127 +++++++++++++++--- 7 files changed, 203 insertions(+), 77 deletions(-) diff --git a/packages/amis-core/src/renderers/Form.tsx b/packages/amis-core/src/renderers/Form.tsx index 3b27ded2a..82b8e417b 100644 --- a/packages/amis-core/src/renderers/Form.tsx +++ b/packages/amis-core/src/renderers/Form.tsx @@ -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 { 'onChange', 'onFailed', 'onFinished', + 'onValidate', + 'onValidChange', 'onSaved', 'canAccessSuperData', 'lazyChange', @@ -460,8 +464,7 @@ export default class Form extends React.Component { [propName: string]: Array<() => Promise>; } = {}; asyncCancel: () => void; - disposeOnValidate: () => void; - disposeRulesValidate: () => void; + toDispose: Array<() => void> = []; shouldLoadInitApi: boolean = false; timer: ReturnType; mounted: boolean; @@ -532,6 +535,7 @@ export default class Form extends React.Component { store, messages: {fetchSuccess, fetchFailed}, onValidate, + onValidChange, promptPageLeave, env, rules @@ -541,49 +545,63 @@ export default class Form extends React.Component { if (onValidate) { const finalValidate = promisify(onValidate); - this.disposeOnValidate = this.addHook(async () => { - const result = await finalValidate(store.data, store); + this.toDispose.push( + this.addHook(async () => { + const result = await finalValidate(store.data, store); - if (result && isObject(result)) { - Object.keys(result).forEach(key => { - let msg = result[key]; - const items = store.getItemsByPath(key); + if (result && isObject(result)) { + Object.keys(result).forEach(key => { + let msg = result[key]; + const items = store.getItemsByPath(key); - // 没有找到 - if (!Array.isArray(items) || !items.length) { - return; - } + // 没有找到 + if (!Array.isArray(items) || !items.length) { + return; + } - // 在setError之前,提前把残留的error信息清除掉,否则每次onValidate后都会一直把报错 append 上去 - items.forEach(item => item.clearError()); + // 在setError之前,提前把残留的error信息清除掉,否则每次onValidate后都会一直把报错 append 上去 + items.forEach(item => item.clearError()); - if (msg) { - msg = Array.isArray(msg) ? msg : [msg]; - items.forEach(item => item.addError(msg)); - } + if (msg) { + msg = Array.isArray(msg) ? msg : [msg]; + items.forEach(item => item.addError(msg)); + } - delete result[key]; - }); + delete result[key]; + }); - isEmpty(result) - ? store.clearRestError() - : store.setRestError(Object.keys(result).map(key => result[key])); - } - }); + isEmpty(result) + ? 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(() => { - if (!store.valid) { - return; - } + this.toDispose.push( + this.addHook(() => { + if (!store.valid) { + return; + } - rules.forEach( - item => - !evalExpression(item.rule, store.data) && - store.addRestError(item.message, item.name) - ); - }); + rules.forEach( + item => + !evalExpression(item.rule, store.data) && + store.addRestError(item.message, item.name) + ); + }) + ); } if (isEffectiveApi(initApi, store.data, initFetch, initFetchOn)) { @@ -655,8 +673,8 @@ export default class Form extends React.Component { // 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?.(); } @@ -836,30 +854,27 @@ export default class Form extends React.Component { return this.props.store.validated; } - validate( + async validate( forceValidate?: boolean, - throwErrors: boolean = false + throwErrors: boolean = false, + toastErrors: boolean = true ): Promise { const {store, dispatchEvent, data, messages, translate: __} = this.props; this.flush(); - return store - .validate( - this.hooks['validate'] || [], - forceValidate, - throwErrors, - typeof messages?.validateFailed === 'string' - ? __(filter(messages.validateFailed, store.data)) - : undefined - ) - .then((result: boolean) => { - if (result) { - dispatchEvent('validateSucc', data); - } else { - dispatchEvent('validateError', data); - } - return result; - }); + const result = await store.validate( + this.hooks['validate'] || [], + forceValidate, + throwErrors, + toastErrors === false + ? '' + : typeof messages?.validateFailed === 'string' + ? __(filter(messages.validateFailed, store.data)) + : undefined + ); + + dispatchEvent(result ? 'validateSucc' : 'validateError', data); + return result; } setErrors(errors: {[propName: string]: string}, tag = 'remote') { diff --git a/packages/amis-core/src/store/combo.ts b/packages/amis-core/src/store/combo.ts index 1b10e7347..67a781966 100644 --- a/packages/amis-core/src/store/combo.ts +++ b/packages/amis-core/src/store/combo.ts @@ -35,7 +35,8 @@ export const ComboStore = iRendererStore minLength: 0, maxLength: 0, length: 0, - activeKey: 0 + activeKey: 0, + memberValidMap: types.optional(types.frozen(), {}) }) .views(self => { function getForms() { @@ -170,13 +171,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 }; }); diff --git a/packages/amis-ui/scss/_components.scss b/packages/amis-ui/scss/_components.scss index 483d6c9fc..74e9b563a 100644 --- a/packages/amis-ui/scss/_components.scss +++ b/packages/amis-ui/scss/_components.scss @@ -2053,6 +2053,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)}; @@ -4131,6 +4132,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 ) diff --git a/packages/amis-ui/scss/components/_tabs.scss b/packages/amis-ui/scss/components/_tabs.scss index 04e4f29ee..05e5a6a20 100644 --- a/packages/amis-ui/scss/components/_tabs.scss +++ b/packages/amis-ui/scss/components/_tabs.scss @@ -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; + } } } diff --git a/packages/amis-ui/scss/components/form/_combo.scss b/packages/amis-ui/scss/components/form/_combo.scss index e90f64b52..8a636c20f 100644 --- a/packages/amis-ui/scss/components/form/_combo.scss +++ b/packages/amis-ui/scss/components/form/_combo.scss @@ -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 { diff --git a/packages/amis-ui/src/components/Tabs.tsx b/packages/amis-ui/src/components/Tabs.tsx index ea5c2f0f5..31d595899 100644 --- a/packages/amis-ui/src/components/Tabs.tsx +++ b/packages/amis-ui/src/components/Tabs.tsx @@ -50,6 +50,7 @@ export interface TabProps extends ThemeProps { tip?: string; tab?: Schema; className?: string; + tabClassName?: string; activeKey?: string | number; reload?: boolean; mountOnEnter?: boolean; diff --git a/packages/amis/src/renderers/Form/Combo.tsx b/packages/amis/src/renderers/Form/Combo.tsx index 8d51f0f53..0dea000a6 100644 --- a/packages/amis/src/renderers/Form/Combo.tsx +++ b/packages/amis/src/renderers/Form/Combo.tsx @@ -8,7 +8,10 @@ import { resolveEventData, ApiObject, FormHorizontal, - evalExpressionWithConditionBuilder + evalExpressionWithConditionBuilder, + IFormStore, + getVariable, + IFormItemStore } from 'amis-core'; import {ActionObject, Api} from 'amis-core'; import {ComboStore, IComboStore} from 'amis-core'; @@ -37,7 +40,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, @@ -48,7 +55,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; @@ -395,6 +401,7 @@ export default class ComboControl extends React.Component { 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 }; @@ -797,6 +804,11 @@ export default class ComboControl extends React.Component { ); } + handleSubFormValid(valid: boolean, {index}: any) { + const {store} = this.props; + store.setMemberValid(valid, index); + } + handleFormInit(values: any, {index}: any) { const { syncDefaultValue, @@ -806,9 +818,15 @@ export default class ComboControl extends React.Component { formInited, onChange, submitOnChange, - setPrinstineValue + setPrinstineValue, + formItem } = this.props; + // 已经开始验证了,那么打开成员的时候,就要验证一下。 + if (formItem?.validated) { + this.subForms[index]?.validate(true, false, false); + } + this.subFormDefaultValues.push({ index, values, @@ -881,7 +899,13 @@ export default class ComboControl extends React.Component { } 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'); @@ -896,18 +920,62 @@ export default class ComboControl extends React.Component { (messages && messages.maxLengthValidateFailed) || 'Combo.maxLength', {maxLength} ); - } else if (this.subForms.length && (!nullable || value)) { - return Promise.all(this.subForms.map(item => item.validate())).then( - values => { - if (~values.indexOf(false)) { - return __( - (messages && messages.validateFailed) || 'validateFailed' - ); - } + } 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); - return; + 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; + }); + } 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; + }); } } @@ -1253,6 +1321,12 @@ export default class ComboControl extends React.Component { // 不能按需渲染,因为 unique 会失效。 mountOnEnter={!hasUnique} unmountOnExit={false} + className={ + store.memberValidMap[index] === false ? 'has-error' : '' + } + tabClassName={ + store.memberValidMap[index] === false ? 'has-error' : '' + } > {condition && typeSwitchable !== false ? (
@@ -1485,7 +1559,8 @@ export default class ComboControl extends React.Component { itemClassName, itemsWrapperClassName, static: isStatic, - mobileUI + mobileUI, + store } = this.props; let items = this.props.items; @@ -1543,7 +1618,11 @@ export default class ComboControl extends React.Component { return (
{!isStatic && !disabled && draggable && thelist.length > 1 ? ( @@ -1622,7 +1701,8 @@ export default class ComboControl extends React.Component { nullable, translate: __, itemClassName, - mobileUI + mobileUI, + store } = this.props; let items = this.props.items; @@ -1646,7 +1726,13 @@ export default class ComboControl extends React.Component { disabled ? 'is-disabled' : '' )} > -
+
{condition && typeSwitchable !== false ? (
@@ -1715,11 +1801,13 @@ export default class ComboControl extends React.Component { 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, @@ -1749,6 +1837,7 @@ export default class ComboControl extends React.Component { onAction: this.handleAction, onRadioChange: this.handleRadioChange, ref: this.makeFormRef(index), + onValidChange: this.handleSubFormValid, canAccessSuperData, lazyChange: changeImmediately ? false : true, formLazyChange: false,